diff --git a/.github/skills/azure-pipelines/SKILL.md b/.github/skills/azure-pipelines/SKILL.md new file mode 100644 index 0000000000000..b7b2e164e038d --- /dev/null +++ b/.github/skills/azure-pipelines/SKILL.md @@ -0,0 +1,241 @@ +--- +name: azure-pipelines +description: Use when validating Azure DevOps pipeline changes for the VS Code build. Covers queueing builds, checking build status, viewing logs, and iterating on pipeline YAML changes without waiting for full CI runs. +--- + +# Validating Azure Pipeline Changes + +When modifying Azure DevOps pipeline files (YAML files in `build/azure-pipelines/`), you can validate changes locally using the Azure CLI before committing. This avoids the slow feedback loop of pushing changes, waiting for CI, and checking results. + +## Prerequisites + +1. **Check if Azure CLI is installed**: + ```bash + az --version + ``` + + If not installed, install it: + ```bash + # macOS + brew install azure-cli + + # Windows (PowerShell as Administrator) + winget install Microsoft.AzureCLI + + # Linux (Debian/Ubuntu) + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + ``` + +2. **Check if the DevOps extension is installed**: + ```bash + az extension show --name azure-devops + ``` + + If not installed, add it: + ```bash + az extension add --name azure-devops + ``` + +3. **Authenticate**: + ```bash + az login + az devops configure --defaults organization=https://dev.azure.com/monacotools project=Monaco + ``` + +## VS Code Main Build + +The main VS Code build pipeline: +- **Organization**: `monacotools` +- **Project**: `Monaco` +- **Definition ID**: `111` +- **URL**: https://dev.azure.com/monacotools/Monaco/_build?definitionId=111 + +## VS Code Insider Scheduled Builds + +Two Insider builds run automatically on a scheduled basis: +- **Morning build**: ~7:00 AM CET +- **Evening build**: ~7:00 PM CET + +These scheduled builds use the same pipeline definition (`111`) but run on the `main` branch to produce Insider releases. + +--- + +## Queueing a Build + +Use the [queue command](./azure-pipeline.ts) to queue a validation build: + +```bash +# Queue a build on the current branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue + +# Queue with a specific source branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --branch my-feature-branch + +# Queue with custom variables (e.g., to skip certain stages) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "SKIP_TESTS=true" +``` + +> **Important**: Before queueing a new build, cancel any previous builds on the same branch that you no longer need. This frees up build agents and reduces resource waste: +> ```bash +> # Find the build ID from status, then cancel it +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +> ``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--branch ` | Source branch to build (default: current git branch) | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--variables ` | Pipeline variables in `KEY=value` format, space-separated | +| `--dry-run` | Print the command without executing | + +--- + +## Checking Build Status + +Use the [status command](./azure-pipeline.ts) to monitor a running build: + +```bash +# Get status of the most recent build on your branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status + +# Get overview of a specific build by ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 + +# Watch build status (refreshes every 30 seconds) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch + +# Watch with custom interval (60 seconds) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch 60 +``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--build-id ` | Specific build ID (default: most recent on current branch) | +| `--branch ` | Filter builds by branch name (shows last 20 builds for branch) | +| `--reason ` | Filter builds by reason: `manual`, `individualCI`, `batchedCI`, `schedule`, `pullRequest` | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--watch [seconds]` | Continuously poll status until build completes (default: 30s) | +| `--download-log ` | Download a specific log to /tmp | +| `--download-artifact ` | Download artifact to /tmp | +| `--json` | Output raw JSON for programmatic consumption | + +--- + +## Cancelling a Build + +Use the [cancel command](./azure-pipeline.ts) to stop a running build: + +```bash +# Cancel a build by ID (use status command to find IDs) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 + +# Dry run (show what would be cancelled) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--build-id ` | Build ID to cancel (required) | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--dry-run` | Print what would be cancelled without executing | + +--- + +## Common Workflows + +### 1. Quick Pipeline Validation + +```bash +# Make your YAML changes, then: +git add -A && git commit -m "test: pipeline changes" +git push origin HEAD + +# Check for any previous builds on this branch and cancel if needed +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id # if there's an active build + +# Queue and watch the new build +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +``` + +### 2. Investigate a Build + +```bash +# Get overview of a build (shows stages, artifacts, and log IDs) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 + +# Download a specific log for deeper inspection +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-log 5 + +# Download an artifact +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-artifact unsigned_vscode_cli_win32_x64_cli +``` + +### 3. Test with Modified Variables + +```bash +# Skip expensive stages during validation +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "VSCODE_BUILD_SKIP_INTEGRATION_TESTS=true" +``` + +### 4. Cancel a Running Build + +```bash +# First, find the build ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status + +# Cancel a specific build by ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 + +# Dry run to see what would be cancelled +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +``` + +### 5. Iterate on Pipeline Changes + +When iterating on pipeline YAML changes, always cancel obsolete builds before queueing new ones: + +```bash +# Push new changes +git add -A && git commit --amend --no-edit +git push --force-with-lease origin HEAD + +# Find the outdated build ID and cancel it +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id + +# Queue a fresh build and monitor +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +``` + +--- + +## Troubleshooting + +### Authentication Issues +```bash +# Re-authenticate +az logout +az login + +# Check current account +az account show +``` + +### Extension Not Found +```bash +az extension add --name azure-devops --upgrade +``` + +### Rate Limiting +If you hit rate limits, add delays between API calls or use `--watch` with a longer interval. diff --git a/.github/skills/azure-pipelines/azure-pipeline.ts b/.github/skills/azure-pipelines/azure-pipeline.ts new file mode 100644 index 0000000000000..7fad554050bb3 --- /dev/null +++ b/.github/skills/azure-pipelines/azure-pipeline.ts @@ -0,0 +1,1858 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Azure DevOps Pipeline CLI + * + * A unified command-line tool for managing Azure Pipeline builds. + * + * Usage: + * node --experimental-strip-types azure-pipeline.ts [options] + * + * Commands: + * queue - Queue a new pipeline build + * status - Check build status and download logs/artifacts + * cancel - Cancel a running build + * + * Run with --help for detailed usage of each command. + */ + +import { spawn, execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// ============================================================================ +// Constants +// ============================================================================ + +const ORGANIZATION = 'https://dev.azure.com/monacotools'; +const PROJECT = 'Monaco'; +const DEFAULT_DEFINITION_ID = '111'; +const DEFAULT_WATCH_INTERVAL = 30; + +// Validation patterns +const NUMERIC_ID_PATTERN = /^\d+$/; +const MAX_ID_LENGTH = 15; +const BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/; +const MAX_BRANCH_LENGTH = 256; +const VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*=[a-zA-Z0-9_\-./: ]*$/; +const MAX_VARIABLE_LENGTH = 256; +const ARTIFACT_NAME_PATTERN = /^[a-zA-Z0-9_\-.]+$/; +const MAX_ARTIFACT_NAME_LENGTH = 256; +const MIN_WATCH_INTERVAL = 5; +const MAX_WATCH_INTERVAL = 3600; + +// ============================================================================ +// Types +// ============================================================================ + +interface Build { + id: number; + buildNumber: string; + status: string; + result?: string; + sourceBranch?: string; + reason?: string; + startTime?: string; + finishTime?: string; + requestedBy?: { displayName?: string }; + requestedFor?: { displayName?: string }; +} + +interface TimelineRecord { + id: string; + parentId?: string; + type: string; + name?: string; + state?: string; + result?: string; + order?: number; + log?: { id?: number }; +} + +interface Timeline { + records: TimelineRecord[]; +} + +interface Artifact { + name: string; + resource?: { + downloadUrl?: string; + properties?: { artifactsize?: string }; + }; +} + +interface QueueArgs { + branch: string; + definitionId: string; + variables: string; + dryRun: boolean; + help: boolean; +} + +interface StatusArgs { + buildId: string; + branch: string; + reason: string; + definitionId: string; + watch: boolean; + watchInterval: number; + downloadLog: string; + downloadArtifact: string; + jsonOutput: boolean; + help: boolean; +} + +interface CancelArgs { + buildId: string; + definitionId: string; + dryRun: boolean; + help: boolean; +} + +// ============================================================================ +// Colors +// ============================================================================ + +const colors = { + red: (text: string) => `\x1b[0;31m${text}\x1b[0m`, + green: (text: string) => `\x1b[0;32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[0;33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[0;34m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[0;36m${text}\x1b[0m`, + gray: (text: string) => `\x1b[0;90m${text}\x1b[0m`, +}; + +// ============================================================================ +// Validation Functions +// ============================================================================ + +function validateNumericId(value: string, name: string): void { + if (!value) { + return; + } + if (value.length > MAX_ID_LENGTH) { + console.error(colors.red(`Error: ${name} is too long (max ${MAX_ID_LENGTH} characters)`)); + process.exit(1); + } + if (!NUMERIC_ID_PATTERN.test(value)) { + console.error(colors.red(`Error: ${name} must contain only digits`)); + process.exit(1); + } +} + +function validateBranch(value: string): void { + if (!value) { + return; + } + if (value.length > MAX_BRANCH_LENGTH) { + console.error(colors.red(`Error: --branch is too long (max ${MAX_BRANCH_LENGTH} characters)`)); + process.exit(1); + } + if (!BRANCH_PATTERN.test(value)) { + console.error(colors.red('Error: --branch contains invalid characters')); + console.log('Allowed: alphanumeric, hyphens, underscores, slashes, dots'); + process.exit(1); + } +} + +function validateVariables(value: string): void { + if (!value) { + return; + } + const vars = value.split(' ').filter(v => v.length > 0); + for (const v of vars) { + if (v.length > MAX_VARIABLE_LENGTH) { + console.error(colors.red(`Error: Variable '${v.substring(0, 20)}...' is too long (max ${MAX_VARIABLE_LENGTH} characters)`)); + process.exit(1); + } + if (!VARIABLE_PATTERN.test(v)) { + console.error(colors.red(`Error: Invalid variable format '${v}'`)); + console.log('Expected format: KEY=value (alphanumeric, underscores, hyphens, dots, slashes, colons, spaces in value)'); + process.exit(1); + } + } +} + +function validateArtifactName(value: string): void { + if (!value) { + return; + } + if (value.length > MAX_ARTIFACT_NAME_LENGTH) { + console.error(colors.red(`Error: --download-artifact name is too long (max ${MAX_ARTIFACT_NAME_LENGTH} characters)`)); + process.exit(1); + } + if (!ARTIFACT_NAME_PATTERN.test(value)) { + console.error(colors.red('Error: --download-artifact name contains invalid characters')); + console.log('Allowed: alphanumeric, hyphens, underscores, dots'); + process.exit(1); + } + if (value.includes('..') || value.startsWith('.') || value.startsWith('/') || value.startsWith('\\')) { + console.error(colors.red('Error: --download-artifact name contains unsafe path components')); + process.exit(1); + } +} + +function validateWatchInterval(value: number): void { + if (value < MIN_WATCH_INTERVAL || value > MAX_WATCH_INTERVAL) { + console.error(colors.red(`Error: Watch interval must be between ${MIN_WATCH_INTERVAL} and ${MAX_WATCH_INTERVAL} seconds`)); + process.exit(1); + } +} + +// ============================================================================ +// CLI Helpers +// ============================================================================ + +function commandExists(command: string): boolean { + try { + execSync(`${process.platform === 'win32' ? 'where' : 'which'} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function hasAzureDevOpsExtension(): boolean { + try { + execSync('az extension show --name azure-devops', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function getCurrentBranch(): string { + try { + return execSync('git branch --show-current', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +function ensureAzureCli(): void { + if (!commandExists('az')) { + console.error(colors.red('Error: Azure CLI (az) is not installed.')); + console.log('Install it with: brew install azure-cli (macOS) or see https://docs.microsoft.com/en-us/cli/azure/install-azure-cli'); + console.log('Then add the DevOps extension: az extension add --name azure-devops'); + process.exit(1); + } + + if (!hasAzureDevOpsExtension()) { + console.log(colors.yellow('Installing azure-devops extension...')); + try { + execSync('az extension add --name azure-devops', { stdio: 'inherit' }); + } catch { + console.error(colors.red('Failed to install azure-devops extension.')); + process.exit(1); + } + } +} + +function sleep(seconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} + +function clearScreen(): void { + process.stdout.write('\x1Bc'); +} + +// ============================================================================ +// Display Utilities +// ============================================================================ + +function formatStatus(status: string): string { + switch (status) { + case 'completed': + return colors.green('completed'); + case 'inProgress': + return colors.blue('in progress'); + case 'notStarted': + return colors.gray('not started'); + case 'cancelling': + case 'postponed': + return colors.yellow(status); + default: + return status || ''; + } +} + +function formatResult(result: string): string { + switch (result) { + case 'succeeded': + return colors.green('✓ succeeded'); + case 'failed': + return colors.red('✗ failed'); + case 'canceled': + return colors.yellow('⊘ canceled'); + case 'partiallySucceeded': + return colors.yellow('◐ partially succeeded'); + default: + return result || 'pending'; + } +} + +function formatTimelineStatus(state: string, result: string): string { + if (state === 'completed') { + if (result === 'succeeded') { + return colors.green('✓'); + } + if (result === 'failed') { + return colors.red('✗'); + } + if (result === 'skipped') { + return colors.gray('○'); + } + return colors.yellow('◐'); + } + if (state === 'inProgress') { + return colors.blue('●'); + } + return colors.gray('○'); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatRelativeTime(dateStr: string): string { + if (!dateStr) { + return ''; + } + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'just now'; + } + if (diffMins < 60) { + return `${diffMins}m ago`; + } + if (diffHours < 24) { + return `${diffHours}h ago`; + } + return `${diffDays}d ago`; +} + +function formatReason(reason: string): string { + switch (reason) { + case 'manual': + return 'Manual'; + case 'individualCI': + return 'CI'; + case 'batchedCI': + return 'Batched CI'; + case 'schedule': + return 'Scheduled'; + case 'pullRequest': + return 'PR'; + case 'buildCompletion': + return 'Build Completion'; + case 'resourceTrigger': + return 'Resource Trigger'; + default: + return reason || 'Unknown'; + } +} + +function padOrTruncate(str: string, width: number): string { + if (str.length > width) { + return str.slice(0, width - 1) + '…'; + } + return str.padEnd(width); +} + +function displayBuildSummary(build: Build): void { + const id = build.id; + const buildNumber = build.buildNumber; + const status = build.status; + const result = build.result; + const sourceBranch = (build.sourceBranch || '').replace('refs/heads/', ''); + const startTime = build.startTime; + const finishTime = build.finishTime; + const requestedBy = build.requestedBy?.displayName; + + console.log(''); + console.log(colors.blue('Azure Pipeline Build Status')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(id))}`); + console.log(`Build Number: ${colors.green(buildNumber)}`); + console.log(`Branch: ${colors.green(sourceBranch)}`); + console.log(`Status: ${formatStatus(status)}`); + console.log(`Result: ${formatResult(result || '')}`); + if (requestedBy) { + console.log(`Requested By: ${colors.cyan(requestedBy)}`); + } + if (startTime) { + console.log(`Started: ${colors.gray(startTime)}`); + } + if (finishTime) { + console.log(`Finished: ${colors.gray(finishTime)}`); + } + console.log(`URL: ${colors.blue(`${ORGANIZATION}/${PROJECT}/_build/results?buildId=${id}`)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayBuildList(builds: Build[]): void { + console.log(''); + console.log(colors.blue('Recent Builds')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(colors.gray(`${'ID'.padEnd(10)} ${'Status'.padEnd(14)} ${'Reason'.padEnd(12)} ${'Branch'.padEnd(25)} ${'Requested By'.padEnd(20)} ${'Started'.padEnd(12)}`)); + console.log('─────────────────────────────────────────────────────────────────────────────────────────────────────────────────'); + + if (!builds || builds.length === 0) { + console.log(colors.gray('No builds found')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + for (const build of builds) { + const id = String(build.id).padEnd(10); + const branch = padOrTruncate((build.sourceBranch || '').replace('refs/heads/', ''), 25); + const requestedBy = padOrTruncate(build.requestedBy?.displayName || build.requestedFor?.displayName || 'Unknown', 20); + const reason = padOrTruncate(formatReason(build.reason || ''), 12); + const started = padOrTruncate(formatRelativeTime(build.startTime || ''), 12); + + let statusStr: string; + if (build.status === 'completed') { + switch (build.result) { + case 'succeeded': + statusStr = colors.green('✓ succeeded'.padEnd(14)); + break; + case 'failed': + statusStr = colors.red('✗ failed'.padEnd(14)); + break; + case 'canceled': + statusStr = colors.yellow('⊘ canceled'.padEnd(14)); + break; + case 'partiallySucceeded': + statusStr = colors.yellow('◐ partial'.padEnd(14)); + break; + default: + statusStr = colors.gray((build.result || 'unknown').padEnd(14)); + } + } else if (build.status === 'inProgress') { + statusStr = colors.blue('● in progress'.padEnd(14)); + } else if (build.status === 'notStarted') { + statusStr = colors.gray('○ queued'.padEnd(14)); + } else if (build.status === 'cancelling') { + statusStr = colors.yellow('⊘ cancelling'.padEnd(14)); + } else { + statusStr = colors.gray((build.status || 'unknown').padEnd(14)); + } + + console.log(`${colors.cyan(id)} ${statusStr} ${reason} ${branch} ${requestedBy} ${colors.gray(started)}`); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log(colors.gray('Use --build-id to see details for a specific build')); +} + +function displayTimeline(timeline: Timeline | null): void { + console.log(''); + console.log(colors.blue('Pipeline Stages')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (!timeline || !timeline.records) { + console.log(colors.gray('Timeline not available')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + const records = timeline.records; + const stages = records.filter(r => r.type === 'Stage'); + const phases = records.filter(r => r.type === 'Phase'); + const jobs = records.filter(r => r.type === 'Job'); + + const phaseToStage = new Map(); + for (const phase of phases) { + if (phase.parentId) { + phaseToStage.set(phase.id, phase.parentId); + } + } + + stages.sort((a, b) => (a.order || 0) - (b.order || 0)); + + for (const stage of stages) { + const status = formatTimelineStatus(stage.state || '', stage.result || ''); + const name = stage.name || 'Unknown'; + console.log(`${status} ${name}`); + + const stagePhaseIds = new Set(phases.filter(p => p.parentId === stage.id).map(p => p.id)); + const stageJobs = jobs.filter(j => j.parentId && stagePhaseIds.has(j.parentId)); + + stageJobs.sort((a, b) => (a.order || 0) - (b.order || 0)); + + for (const job of stageJobs) { + const jobStatus = formatTimelineStatus(job.state || '', job.result || ''); + const jobName = job.name || 'Unknown'; + const logId = job.log?.id; + const logInfo = logId ? colors.gray(` (log #${logId})`) : ''; + console.log(` ${jobStatus} ${jobName}${logInfo}`); + } + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayArtifacts(artifacts: Artifact[]): void { + console.log(''); + console.log(colors.blue('Build Artifacts')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (!artifacts || artifacts.length === 0) { + console.log(colors.gray('No artifacts available')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + for (const artifact of artifacts) { + const name = artifact.name || 'Unknown'; + const size = artifact.resource?.properties?.artifactsize; + if (!size || parseInt(size, 10) === 0) { + continue; + } + const sizeStr = ` (${formatBytes(parseInt(size, 10))})`; + console.log(` ${colors.cyan(name)}${colors.gray(sizeStr)}`); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayNextSteps(buildId: string): void { + console.log(''); + console.log(colors.blue('Next Steps')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(colors.gray(` Download artifact: status --build-id ${buildId} --download-artifact `)); + console.log(colors.gray(` Download log: status --build-id ${buildId} --download-log `)); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +// ============================================================================ +// Azure DevOps Client +// ============================================================================ + +class AzureDevOpsClient { + protected readonly organization: string; + protected readonly project: string; + + constructor(organization: string, project: string) { + this.organization = organization; + this.project = project; + } + + protected runAzCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('az', args, { shell: true }); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || stdout || `Command failed with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + } + + private async rest(method: string, url: string, body?: string): Promise { + const args = [ + 'rest', + '--method', method, + '--url', url, + '--resource', '499b84ac-1321-427f-aa17-267ca6975798', + ]; + + if (body) { + const tmpDir = os.tmpdir(); + const bodyFile = path.join(tmpDir, `azdo-request-${Date.now()}.json`); + fs.writeFileSync(bodyFile, body); + args.push('--headers', 'Content-Type=application/json'); + args.push('--body', `@${bodyFile}`); + + try { + const result = await this.runAzCommand(args); + return JSON.parse(result); + } finally { + try { + fs.unlinkSync(bodyFile); + } catch { + // Ignore cleanup errors + } + } + } + + const result = await this.runAzCommand(args); + return JSON.parse(result); + } + + async queueBuild(definitionId: string, branch: string, variables?: string): Promise { + const args = [ + 'pipelines', 'run', + '--organization', this.organization, + '--project', this.project, + '--id', definitionId, + '--branch', branch, + ]; + + if (variables) { + args.push('--variables', ...variables.split(' ')); + } + + args.push('--output', 'json'); + const result = await this.runAzCommand(args); + return JSON.parse(result); + } + + async getBuild(buildId: string): Promise { + try { + const args = [ + 'pipelines', 'build', 'show', + '--organization', this.organization, + '--project', this.project, + '--id', buildId, + '--output', 'json', + ]; + const result = await this.runAzCommand(args); + return JSON.parse(result); + } catch { + return null; + } + } + + async listBuilds(definitionId: string, options: { branch?: string; reason?: string; top?: number } = {}): Promise { + try { + const args = [ + 'pipelines', 'build', 'list', + '--organization', this.organization, + '--project', this.project, + '--definition-ids', definitionId, + '--top', String(options.top || 20), + '--output', 'json', + ]; + if (options.branch) { + args.push('--branch', options.branch); + } + if (options.reason) { + args.push('--reason', options.reason); + } + const result = await this.runAzCommand(args); + return JSON.parse(result); + } catch { + return []; + } + } + + async findRecentBuild(branch: string, definitionId: string): Promise { + try { + const args = [ + 'pipelines', 'build', 'list', + '--organization', this.organization, + '--project', this.project, + '--definition-ids', definitionId, + '--branch', branch, + '--top', '1', + '--query', '[0].id', + '--output', 'tsv', + ]; + const result = await this.runAzCommand(args); + return result.trim(); + } catch { + return ''; + } + } + + async cancelBuild(buildId: string): Promise { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}?api-version=7.0`; + return this.rest('patch', url, JSON.stringify({ status: 'cancelling' })); + } + + async getTimeline(buildId: string): Promise { + try { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/timeline?api-version=7.0`; + return await this.rest('get', url); + } catch { + return null; + } + } + + async getArtifacts(buildId: string): Promise { + try { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/artifacts?api-version=7.0`; + const response = await this.rest<{ value: Artifact[] }>('get', url); + return response.value || []; + } catch { + return []; + } + } + + async downloadLog(buildId: string, logId: string): Promise { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/logs/${logId}?api-version=7.0`; + const args = ['rest', '--method', 'get', '--url', url, '--resource', '499b84ac-1321-427f-aa17-267ca6975798']; + const content = await this.runAzCommand(args); + + const tmpDir = os.tmpdir(); + const outputPath = path.join(tmpDir, `build-${buildId}-log-${logId}.txt`); + + console.log(colors.blue(`Downloading log #${logId}...`)); + console.log(colors.gray(`Destination: ${outputPath}`)); + + fs.writeFileSync(outputPath, content); + return outputPath; + } + + async downloadArtifact(buildId: string, artifactName: string): Promise { + const artifacts = await this.getArtifacts(buildId); + const artifact = artifacts.find(a => a.name === artifactName); + + if (!artifact) { + const available = artifacts.map(a => a.name).join(', '); + throw new Error(`Artifact '${artifactName}' not found. Available artifacts: ${available || 'none'}`); + } + + const downloadUrl = artifact.resource?.downloadUrl; + if (!downloadUrl) { + throw new Error(`Artifact '${artifactName}' has no download URL`); + } + + const tmpDir = os.tmpdir(); + const outputPath = path.join(tmpDir, `${artifactName}.zip`); + + console.log(colors.blue(`Downloading artifact '${artifactName}'...`)); + console.log(colors.gray(`Destination: ${outputPath}`)); + + const tokenArgs = ['account', 'get-access-token', '--resource', '499b84ac-1321-427f-aa17-267ca6975798', '--query', 'accessToken', '--output', 'tsv']; + const token = (await this.runAzCommand(tokenArgs)).trim(); + + const response = await fetch(downloadUrl, { + headers: { 'Authorization': `Bearer ${token}` }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Failed to download artifact: ${response.status} ${response.statusText}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(outputPath, buffer); + + return outputPath; + } +} + +// ============================================================================ +// Queue Command +// ============================================================================ + +function printQueueUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Queue an Azure DevOps pipeline build for VS Code.'); + console.log(''); + console.log('Options:'); + console.log(' --branch Source branch to build (default: current git branch)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --variables Pipeline variables in "KEY=value KEY2=value2" format'); + console.log(' --dry-run Print the command without executing'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} # Queue build on current branch`); + console.log(` ${scriptName} --branch my-feature # Queue build on specific branch`); + console.log(` ${scriptName} --variables "SKIP_TESTS=true" # Queue with custom variables`); +} + +function parseQueueArgs(args: string[]): QueueArgs { + const result: QueueArgs = { + branch: '', + definitionId: DEFAULT_DEFINITION_ID, + variables: '', + dryRun: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--branch': + result.branch = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--variables': + result.variables = args[++i] || ''; + break; + case '--dry-run': + result.dryRun = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printQueueUsage(); + process.exit(1); + } + } + + return result; +} + +function validateQueueArgs(args: QueueArgs): void { + validateNumericId(args.definitionId, '--definition'); + validateBranch(args.branch); + validateVariables(args.variables); +} + +async function runQueueCommand(args: string[]): Promise { + const parsedArgs = parseQueueArgs(args); + + if (parsedArgs.help) { + printQueueUsage(); + process.exit(0); + } + + validateQueueArgs(parsedArgs); + ensureAzureCli(); + + let branch = parsedArgs.branch; + if (!branch) { + branch = getCurrentBranch(); + if (!branch) { + console.error(colors.red('Error: Could not determine current git branch.')); + console.log('Please specify a branch with --branch '); + process.exit(1); + } + validateBranch(branch); + } + + console.log(colors.blue('Queueing Azure Pipeline Build')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Organization: ${colors.green(ORGANIZATION)}`); + console.log(`Project: ${colors.green(PROJECT)}`); + console.log(`Definition: ${colors.green(parsedArgs.definitionId)}`); + console.log(`Branch: ${colors.green(branch)}`); + if (parsedArgs.variables) { + console.log(`Variables: ${colors.green(parsedArgs.variables)}`); + } + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + + if (parsedArgs.dryRun) { + console.log(colors.yellow('Dry run - command would be:')); + const cmdArgs = [ + 'pipelines', 'run', + '--organization', ORGANIZATION, + '--project', PROJECT, + '--id', parsedArgs.definitionId, + '--branch', branch, + ]; + if (parsedArgs.variables) { + cmdArgs.push('--variables', ...parsedArgs.variables.split(' ')); + } + cmdArgs.push('--output', 'json'); + console.log(`az ${cmdArgs.join(' ')}`); + process.exit(0); + } + + console.log(colors.blue('Queuing build...')); + + try { + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + const data = await client.queueBuild(parsedArgs.definitionId, branch, parsedArgs.variables); + + const buildId = data.id; + const buildNumber = data.buildNumber; + const buildUrl = `${ORGANIZATION}/${PROJECT}/_build/results?buildId=${buildId}`; + + console.log(''); + console.log(colors.green('✓ Build queued successfully!')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(buildId))}`); + if (buildNumber) { + console.log(`Build Number: ${colors.green(buildNumber)}`); + } + console.log(`URL: ${colors.blue(buildUrl)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log('To check status, run:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + console.log(''); + console.log('To watch progress:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId} --watch`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + console.error(colors.red('Error queuing build:')); + console.error(error.message); + process.exit(1); + } +} + +// ============================================================================ +// Status Command +// ============================================================================ + +function printStatusUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Get status and logs of an Azure DevOps pipeline build.'); + console.log(''); + console.log('Options:'); + console.log(' --build-id Specific build ID (default: list last 20 builds)'); + console.log(' --branch Filter builds by branch name (shows last 20 builds for branch)'); + console.log(' --reason Filter builds by reason (manual, individualCI, batchedCI, schedule, pullRequest)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --watch [seconds] Continuously poll status until build completes (default: 30)'); + console.log(' --download-log Download a specific log to /tmp'); + console.log(' --download-artifact Download artifact to /tmp'); + console.log(' --json Output raw JSON'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} # List last 20 builds`); + console.log(` ${scriptName} --branch main # List last 20 builds for main branch`); + console.log(` ${scriptName} --reason schedule # List last 20 scheduled builds`); + console.log(` ${scriptName} --build-id 123456 # Status of specific build`); + console.log(` ${scriptName} --watch # Watch build until completion (30s interval)`); + console.log(` ${scriptName} --watch 60 # Watch with 60s interval`); + console.log(` ${scriptName} --build-id 123456 --download-log 5 # Download log to /tmp`); +} + +function parseStatusArgs(args: string[]): StatusArgs { + const result: StatusArgs = { + buildId: '', + branch: '', + reason: '', + definitionId: DEFAULT_DEFINITION_ID, + watch: false, + watchInterval: DEFAULT_WATCH_INTERVAL, + downloadLog: '', + downloadArtifact: '', + jsonOutput: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--build-id': + result.buildId = args[++i] || ''; + break; + case '--branch': + result.branch = args[++i] || ''; + break; + case '--reason': + result.reason = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--watch': + result.watch = true; + if (args[i + 1] && /^\d+$/.test(args[i + 1])) { + result.watchInterval = parseInt(args[++i], 10) || DEFAULT_WATCH_INTERVAL; + } + break; + case '--download-log': + result.downloadLog = args[++i] || ''; + break; + case '--download-artifact': + result.downloadArtifact = args[++i] || ''; + break; + case '--json': + result.jsonOutput = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printStatusUsage(); + process.exit(1); + } + } + + return result; +} + +function validateStatusArgs(args: StatusArgs): void { + validateNumericId(args.buildId, '--build-id'); + validateNumericId(args.definitionId, '--definition'); + validateNumericId(args.downloadLog, '--download-log'); + validateArtifactName(args.downloadArtifact); + if (args.watch) { + validateWatchInterval(args.watchInterval); + } +} + +async function runStatusCommand(args: string[]): Promise { + const parsedArgs = parseStatusArgs(args); + + if (parsedArgs.help) { + printStatusUsage(); + process.exit(0); + } + + validateStatusArgs(parsedArgs); + ensureAzureCli(); + + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + + // If no build ID specified, show list of recent builds + let buildId = parsedArgs.buildId; + if (!buildId && !parsedArgs.downloadLog && !parsedArgs.downloadArtifact && !parsedArgs.watch) { + const builds = await client.listBuilds(parsedArgs.definitionId, { + branch: parsedArgs.branch, + reason: parsedArgs.reason, + top: 20, + }); + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(builds, null, 2)); + } else { + const filters: string[] = []; + if (parsedArgs.branch) { + filters.push(`branch: ${parsedArgs.branch}`); + } + if (parsedArgs.reason) { + filters.push(`reason: ${parsedArgs.reason}`); + } + if (filters.length > 0) { + console.log(colors.gray(`Filtering by ${filters.join(', ')}`)); + } + displayBuildList(builds); + } + return; + } + + // For watch mode or download operations without a build ID, find the most recent build on current branch + if (!buildId) { + const branch = getCurrentBranch(); + if (!branch) { + console.error(colors.red('Error: Could not determine current git branch.')); + console.log('Please specify a build ID with --build-id '); + process.exit(1); + } + + console.log(colors.gray(`Finding most recent build for branch: ${branch}`)); + buildId = await client.findRecentBuild(branch, parsedArgs.definitionId); + + if (!buildId) { + console.error(colors.red(`Error: No builds found for branch '${branch}'.`)); + console.log('You can queue a new build with: node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'); + process.exit(1); + } + } + + // Download specific log + if (parsedArgs.downloadLog) { + try { + const outputPath = await client.downloadLog(buildId, parsedArgs.downloadLog); + console.log(colors.green(`✓ Log downloaded to: ${outputPath}`)); + } catch (e) { + console.error(colors.red((e as Error).message)); + process.exit(1); + } + return; + } + + // Download artifact + if (parsedArgs.downloadArtifact) { + try { + const outputPath = await client.downloadArtifact(buildId, parsedArgs.downloadArtifact); + console.log(colors.green(`✓ Artifact downloaded to: ${outputPath}`)); + } catch (e) { + console.error(colors.red((e as Error).message)); + process.exit(1); + } + return; + } + + // Watch mode + if (parsedArgs.watch) { + console.log(colors.blue(`Watching build ${buildId} (Ctrl+C to stop)`)); + console.log(''); + + while (true) { + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red('Error: Could not fetch build status')); + process.exit(1); + } + + clearScreen(); + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(build, null, 2)); + } else { + displayBuildSummary(build); + const timeline = await client.getTimeline(buildId); + displayTimeline(timeline); + + const artifacts = await client.getArtifacts(buildId); + displayArtifacts(artifacts); + displayNextSteps(buildId); + } + + if (build.status === 'completed') { + console.log(''); + console.log(colors.green('Build completed!')); + process.exit(0); + } + + console.log(''); + console.log(colors.gray(`Refreshing in ${parsedArgs.watchInterval} seconds... (Ctrl+C to stop)`)); + await sleep(parsedArgs.watchInterval); + } + } else { + // Single status check + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red(`Error: Could not fetch build status for ID ${buildId}`)); + process.exit(1); + } + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(build, null, 2)); + } else { + displayBuildSummary(build); + const timeline = await client.getTimeline(buildId); + displayTimeline(timeline); + + const artifacts = await client.getArtifacts(buildId); + displayArtifacts(artifacts); + displayNextSteps(buildId); + } + } +} + +// ============================================================================ +// Cancel Command +// ============================================================================ + +function printCancelUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel'; + console.log(`Usage: ${scriptName} --build-id [options]`); + console.log(''); + console.log('Cancel a running Azure DevOps pipeline build.'); + console.log(''); + console.log('Options:'); + console.log(' --build-id Build ID to cancel (required)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --dry-run Print what would be cancelled without executing'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} --build-id 123456 # Cancel specific build`); + console.log(` ${scriptName} --build-id 123456 --dry-run # Show what would be cancelled`); +} + +function parseCancelArgs(args: string[]): CancelArgs { + const result: CancelArgs = { + buildId: '', + definitionId: DEFAULT_DEFINITION_ID, + dryRun: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--build-id': + result.buildId = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--dry-run': + result.dryRun = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printCancelUsage(); + process.exit(1); + } + } + + return result; +} + +function validateCancelArgs(args: CancelArgs): void { + validateNumericId(args.buildId, '--build-id'); + validateNumericId(args.definitionId, '--definition'); +} + +async function runCancelCommand(args: string[]): Promise { + const parsedArgs = parseCancelArgs(args); + + if (parsedArgs.help) { + printCancelUsage(); + process.exit(0); + } + + validateCancelArgs(parsedArgs); + ensureAzureCli(); + + const buildId = parsedArgs.buildId; + + if (!buildId) { + console.error(colors.red('Error: --build-id is required.')); + console.log(''); + console.log('To find build IDs, run:'); + console.log(' node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'); + process.exit(1); + } + + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red(`Error: Could not fetch build status for ID ${buildId}`)); + process.exit(1); + } + + const buildUrl = `${ORGANIZATION}/${PROJECT}/_build/results?buildId=${buildId}`; + + console.log(''); + console.log(colors.blue('Azure Pipeline Build Cancel')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(build.id))}`); + console.log(`Build Number: ${colors.green(build.buildNumber || 'N/A')}`); + console.log(`Status: ${colors.yellow(build.status)}`); + console.log(`URL: ${colors.blue(buildUrl)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (build.status === 'completed') { + console.log(''); + console.log(colors.yellow('Build is already completed. Nothing to cancel.')); + process.exit(0); + } + + if (build.status === 'cancelling') { + console.log(''); + console.log(colors.yellow('Build is already being cancelled.')); + process.exit(0); + } + + if (parsedArgs.dryRun) { + console.log(''); + console.log(colors.yellow('Dry run - would cancel build:')); + console.log(` Build ID: ${buildId}`); + console.log(` API: PATCH ${ORGANIZATION}/${PROJECT}/_apis/build/builds/${buildId}?api-version=7.0`); + console.log(` Body: {"status": "cancelling"}`); + process.exit(0); + } + + console.log(''); + console.log(colors.blue('Cancelling build...')); + + try { + await client.cancelBuild(buildId); + console.log(''); + console.log(colors.green('✓ Build cancellation requested successfully!')); + console.log(''); + console.log('The build will transition to "cancelling" state and then "canceled".'); + console.log('Check status with:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + console.error(''); + console.error(colors.red('Error cancelling build:')); + console.error(error.message); + process.exit(1); + } +} + +// ============================================================================ +// Testable Azure DevOps Client +// ============================================================================ + +/** + * A testable version of AzureDevOpsClient that captures az command calls + * instead of executing them. + */ +class TestableAzureDevOpsClient extends AzureDevOpsClient { + public capturedCommands: string[][] = []; + private mockResponses: Map = new Map(); + + constructor(organization: string, project: string) { + super(organization, project); + } + + setMockResponse(commandPattern: string, response: unknown): void { + this.mockResponses.set(commandPattern, response); + } + + protected override runAzCommand(args: string[]): Promise { + this.capturedCommands.push(args); + + // Find a matching mock response + const commandKey = args.join(' '); + for (const [pattern, response] of this.mockResponses) { + if (commandKey.includes(pattern)) { + return Promise.resolve(JSON.stringify(response)); + } + } + + // Default mock responses based on command type + if (args.includes('pipelines') && args.includes('run')) { + return Promise.resolve(JSON.stringify({ id: 12345, buildNumber: '20260218.1' })); + } + if (args.includes('pipelines') && args.includes('build') && args.includes('show')) { + return Promise.resolve(JSON.stringify({ + id: 12345, + buildNumber: '20260218.1', + status: 'inProgress', + sourceBranch: 'refs/heads/main' + })); + } + if (args.includes('pipelines') && args.includes('build') && args.includes('list')) { + return Promise.resolve(JSON.stringify([ + { id: 12345, buildNumber: '20260218.1', status: 'completed', result: 'succeeded' } + ])); + } + if (args.includes('rest') && args.includes('patch')) { + return Promise.resolve(JSON.stringify({ id: 12345, status: 'cancelling' })); + } + if (args.includes('rest') && args.includes('timeline')) { + return Promise.resolve(JSON.stringify({ records: [] })); + } + if (args.includes('rest') && args.includes('artifacts')) { + return Promise.resolve(JSON.stringify({ value: [] })); + } + + return Promise.resolve('{}'); + } +} + +// ============================================================================ +// Tests (using Node.js built-in test runner) +// ============================================================================ + +async function runAllTests(): Promise { + const { describe, it } = await import('node:test'); + const assert = await import('node:assert'); + + describe('Validation Functions', () => { + it('validateNumericId accepts valid numeric IDs', () => { + validateNumericId('12345', 'test'); + validateNumericId('1', 'test'); + validateNumericId('999999999999999', 'test'); + }); + + it('validateNumericId accepts empty string', () => { + validateNumericId('', 'test'); + }); + + it('validateBranch accepts valid branch names', () => { + validateBranch('main'); + validateBranch('feature/my-feature'); + validateBranch('release/v1.0.0'); + validateBranch('user/john_doe/fix-123'); + validateBranch('refs/heads/main'); + }); + + it('validateBranch accepts empty string', () => { + validateBranch(''); + }); + + it('validateVariables accepts valid variable formats', () => { + validateVariables('KEY=value'); + validateVariables('MY_VAR=some-value'); + validateVariables('A=1 B=2 C=3'); + validateVariables('PATH=/usr/bin:path'); + }); + + it('validateVariables accepts empty string', () => { + validateVariables(''); + }); + + it('validateArtifactName accepts valid artifact names', () => { + validateArtifactName('my-artifact'); + validateArtifactName('artifact_1.0.0'); + validateArtifactName('Build-Output'); + }); + + it('validateArtifactName accepts empty string', () => { + validateArtifactName(''); + }); + + it('validateWatchInterval accepts valid intervals', () => { + validateWatchInterval(5); + validateWatchInterval(30); + validateWatchInterval(3600); + }); + }); + + describe('Argument Parsing', () => { + it('parseQueueArgs parses --branch correctly', () => { + const args = parseQueueArgs(['--branch', 'my-feature']); + assert.strictEqual(args.branch, 'my-feature'); + }); + + it('parseQueueArgs parses --definition correctly', () => { + const args = parseQueueArgs(['--definition', '222']); + assert.strictEqual(args.definitionId, '222'); + }); + + it('parseQueueArgs parses --variables correctly', () => { + const args = parseQueueArgs(['--variables', 'KEY=value']); + assert.strictEqual(args.variables, 'KEY=value'); + }); + + it('parseQueueArgs parses --dry-run correctly', () => { + const args = parseQueueArgs(['--dry-run']); + assert.strictEqual(args.dryRun, true); + }); + + it('parseQueueArgs parses combined arguments', () => { + const args = parseQueueArgs(['--branch', 'main', '--definition', '333', '--variables', 'A=1 B=2', '--dry-run']); + assert.strictEqual(args.branch, 'main'); + assert.strictEqual(args.definitionId, '333'); + assert.strictEqual(args.variables, 'A=1 B=2'); + assert.strictEqual(args.dryRun, true); + }); + + it('parseStatusArgs parses --build-id correctly', () => { + const args = parseStatusArgs(['--build-id', '12345']); + assert.strictEqual(args.buildId, '12345'); + }); + + it('parseStatusArgs parses --branch correctly', () => { + const args = parseStatusArgs(['--branch', 'main']); + assert.strictEqual(args.branch, 'main'); + }); + + it('parseStatusArgs parses --watch without interval', () => { + const args = parseStatusArgs(['--watch']); + assert.strictEqual(args.watch, true); + assert.strictEqual(args.watchInterval, 30); + }); + + it('parseStatusArgs parses --watch with interval', () => { + const args = parseStatusArgs(['--watch', '60']); + assert.strictEqual(args.watch, true); + assert.strictEqual(args.watchInterval, 60); + }); + + it('parseStatusArgs parses --download-log correctly', () => { + const args = parseStatusArgs(['--download-log', '5']); + assert.strictEqual(args.downloadLog, '5'); + }); + + it('parseStatusArgs parses --download-artifact correctly', () => { + const args = parseStatusArgs(['--download-artifact', 'my-artifact']); + assert.strictEqual(args.downloadArtifact, 'my-artifact'); + }); + + it('parseStatusArgs parses --json correctly', () => { + const args = parseStatusArgs(['--json']); + assert.strictEqual(args.jsonOutput, true); + }); + + it('parseCancelArgs parses --build-id correctly', () => { + const args = parseCancelArgs(['--build-id', '12345']); + assert.strictEqual(args.buildId, '12345'); + }); + + it('parseCancelArgs parses --dry-run correctly', () => { + const args = parseCancelArgs(['--dry-run']); + assert.strictEqual(args.dryRun, true); + }); + }); + + describe('Azure Command Construction', () => { + it('queueBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'main'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('run')); + assert.ok(cmd.includes('--organization')); + assert.ok(cmd.includes(ORGANIZATION)); + assert.ok(cmd.includes('--project')); + assert.ok(cmd.includes(PROJECT)); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--output')); + assert.ok(cmd.includes('json')); + }); + + it('queueBuild includes variables when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'main', 'KEY=value OTHER=test'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('KEY=value')); + assert.ok(cmd.includes('OTHER=test')); + }); + + it('getBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getBuild('12345'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('show')); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('12345')); + }); + + it('listBuilds constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('list')); + assert.ok(cmd.includes('--definition-ids')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('20')); + }); + + it('listBuilds includes branch filter when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { branch: 'feature/test' }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('feature/test')); + }); + + it('listBuilds includes reason filter when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { reason: 'manual' }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--reason')); + assert.ok(cmd.includes('manual')); + }); + + it('listBuilds includes custom top value', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { top: 50 }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('50')); + }); + + it('findRecentBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.findRecentBuild('main', '111'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('list')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('1')); + assert.ok(cmd.includes('--query')); + assert.ok(cmd.includes('[0].id')); + assert.ok(cmd.includes('--output')); + assert.ok(cmd.includes('tsv')); + }); + + it('cancelBuild constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.cancelBuild('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('patch')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345')); + }); + + it('getTimeline constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getTimeline('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/timeline')); + }); + + it('getArtifacts constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getArtifacts('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/artifacts')); + }); + + it('downloadLog constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + + // Capture console output to avoid noise + const originalLog = console.log; + console.log = () => { }; + try { + await client.downloadLog('12345', '7'); + } finally { + console.log = originalLog; + } + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/logs/7')); + }); + }); + + describe('Display Format Functions', () => { + it('formatStatus returns correct format for completed', () => { + const result = formatStatus('completed'); + assert.ok(result.includes('completed')); + }); + + it('formatStatus returns correct format for inProgress', () => { + const result = formatStatus('inProgress'); + assert.ok(result.includes('in progress')); + }); + + it('formatResult returns correct format for succeeded', () => { + const result = formatResult('succeeded'); + assert.ok(result.includes('succeeded')); + assert.ok(result.includes('✓')); + }); + + it('formatResult returns correct format for failed', () => { + const result = formatResult('failed'); + assert.ok(result.includes('failed')); + assert.ok(result.includes('✗')); + }); + + it('formatResult returns correct format for canceled', () => { + const result = formatResult('canceled'); + assert.ok(result.includes('canceled')); + assert.ok(result.includes('⊘')); + }); + + it('formatBytes formats correctly', () => { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1024), '1 KB'); + assert.strictEqual(formatBytes(1048576), '1 MB'); + assert.strictEqual(formatBytes(1073741824), '1 GB'); + }); + + it('formatReason returns correct labels', () => { + assert.strictEqual(formatReason('manual'), 'Manual'); + assert.strictEqual(formatReason('individualCI'), 'CI'); + assert.strictEqual(formatReason('pullRequest'), 'PR'); + assert.strictEqual(formatReason('schedule'), 'Scheduled'); + }); + + it('padOrTruncate pads short strings', () => { + assert.strictEqual(padOrTruncate('abc', 6), 'abc '); + }); + + it('padOrTruncate truncates long strings', () => { + assert.strictEqual(padOrTruncate('abcdefghij', 6), 'abcde…'); + }); + + it('formatTimelineStatus returns correct symbols', () => { + const succeeded = formatTimelineStatus('completed', 'succeeded'); + assert.ok(succeeded.includes('✓')); + + const failed = formatTimelineStatus('completed', 'failed'); + assert.ok(failed.includes('✗')); + + const inProgress = formatTimelineStatus('inProgress', ''); + assert.ok(inProgress.includes('●')); + }); + }); + + describe('Integration Tests', () => { + it('full queue command flow constructs correct az commands', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'feature/test', 'DEBUG=true'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('run')); + assert.ok(cmd.includes('--organization')); + assert.ok(cmd.includes(ORGANIZATION)); + assert.ok(cmd.includes('--project')); + assert.ok(cmd.includes(PROJECT)); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('feature/test')); + assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('DEBUG=true')); + }); + + it('full status command flow constructs correct az commands', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + + await client.getBuild('99999'); + await client.getTimeline('99999'); + await client.getArtifacts('99999'); + + assert.strictEqual(client.capturedCommands.length, 3); + + const showCmd = client.capturedCommands[0]; + assert.ok(showCmd.includes('build')); + assert.ok(showCmd.includes('show')); + assert.ok(showCmd.includes('--id')); + assert.ok(showCmd.includes('99999')); + + const timelineCmd = client.capturedCommands[1]; + assert.ok(timelineCmd.includes('rest')); + assert.ok(timelineCmd.join(' ').includes('timeline')); + + const artifactsCmd = client.capturedCommands[2]; + assert.ok(artifactsCmd.includes('rest')); + assert.ok(artifactsCmd.join(' ').includes('artifacts')); + }); + + it('cancel command constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.cancelBuild('88888'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('patch')); + assert.ok(cmd.join(' ').includes('88888')); + assert.ok(cmd.join(' ').includes('api-version=7.0')); + }); + + it('list builds with filters constructs correct command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { branch: 'main', reason: 'pullRequest', top: 10 }); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--reason')); + assert.ok(cmd.includes('pullRequest')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('10')); + }); + }); +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +function printMainUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Azure DevOps Pipeline CLI for VS Code builds.'); + console.log(''); + console.log('Commands:'); + console.log(' queue Queue a new pipeline build'); + console.log(' status Check build status, list builds, download logs/artifacts'); + console.log(' cancel Cancel a running build'); + console.log(''); + console.log('Options:'); + console.log(' --help Show help for a command'); + console.log(' --tests Run the test suite'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} queue # Queue build on current branch`); + console.log(` ${scriptName} status # List recent builds`); + console.log(` ${scriptName} status --build-id 123456 # Get build details`); + console.log(` ${scriptName} cancel --build-id 123456 # Cancel a build`); + console.log(` ${scriptName} --tests # Run test suite`); + console.log(''); + console.log('Run any command with --help for detailed usage.'); +} + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + printMainUsage(); + process.exit(0); + } + + if (args[0] === '--tests') { + await runAllTests(); + return; + } + + const command = args[0]; + const commandArgs = args.slice(1); + + switch (command) { + case 'queue': + await runQueueCommand(commandArgs); + break; + case 'status': + await runStatusCommand(commandArgs); + break; + case 'cancel': + await runCancelCommand(commandArgs); + break; + default: + console.error(colors.red(`Error: Unknown command: ${command}`)); + console.log(''); + printMainUsage(); + process.exit(1); + } +} + +main(); diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 831a02068fb3d..b988d19f49ea6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -81,6 +81,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check cyclic dependencies + run: node build/lib/checkCyclicDependencies.ts out-build + linux-cli-tests: name: Linux uses: ./.github/workflows/pr-linux-cli-test.yml diff --git a/.vscode/settings.json b/.vscode/settings.json index 7307607bef73f..f65efbf06ee50 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,9 @@ "scripts/test-integration.bat": true, "scripts/test-integration.sh": true, }, + "chat.tools.edits.autoApprove": { + ".github/skills/azure-pipelines/azure-pipeline.ts": false + }, "chat.viewSessions.enabled": true, "chat.editing.explainChanges.enabled": true, // --- Editor --- diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index 74d58f3840699..a2eb47535f4dd 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -334,7 +334,10 @@ async function buildWebExtensions(isWatch: boolean): Promise { promises.push( ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script }))), // Also run type check on extensions - ...esbuildConfigLocations.map(script => ext.typeCheckExtension(path.dirname(script), true)) + ...esbuildConfigLocations.flatMap(script => { + const roots = ext.getBuildRootsForExtension(path.dirname(script)); + return roots.map(root => ext.typeCheckExtension(root, true)); + }) ); } diff --git a/build/lib/checkCyclicDependencies.ts b/build/lib/checkCyclicDependencies.ts new file mode 100644 index 0000000000000..dddaf76ad5a40 --- /dev/null +++ b/build/lib/checkCyclicDependencies.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'fs'; +import path from 'path'; +import * as ts from 'typescript'; + +// --- Graph (extracted from build/lib/tsb/utils.ts) --- + +export class Node { + readonly incoming = new Map(); + readonly outgoing = new Map(); + + readonly data: string; + + constructor(data: string) { + this.data = data; + } +} + +export class Graph { + private _nodes = new Map(); + + inertEdge(from: string, to: string): void { + const fromNode = this.lookupOrInsertNode(from); + const toNode = this.lookupOrInsertNode(to); + fromNode.outgoing.set(toNode.data, toNode); + toNode.incoming.set(fromNode.data, fromNode); + } + + lookupOrInsertNode(data: string): Node { + let node = this._nodes.get(data); + if (!node) { + node = new Node(data); + this._nodes.set(data, node); + } + return node; + } + + lookup(data: string): Node | undefined { + return this._nodes.get(data); + } + + findCycles(allData: string[]): Map { + const result = new Map(); + const checked = new Set(); + for (const data of allData) { + const node = this.lookup(data); + if (!node) { + continue; + } + const cycle = this._findCycle(node, checked, new Set()); + result.set(node.data, cycle); + } + return result; + } + + private _findCycle(node: Node, checked: Set, seen: Set): string[] | undefined { + if (checked.has(node.data)) { + return undefined; + } + for (const child of node.outgoing.values()) { + if (seen.has(child.data)) { + const seenArr = Array.from(seen); + const idx = seenArr.indexOf(child.data); + seenArr.push(child.data); + return idx > 0 ? seenArr.slice(idx) : seenArr; + } + seen.add(child.data); + const result = this._findCycle(child, checked, seen); + seen.delete(child.data); + if (result) { + return result; + } + } + checked.add(node.data); + return undefined; + } +} + +// --- Dependency scanning & cycle detection --- + +export function normalize(p: string): string { + return p.replace(/\\/g, '/'); +} + +export function collectJsFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectJsFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + results.push(full); + } + } + return results; +} + +export function processFile(filename: string, graph: Graph): void { + const content = fs.readFileSync(filename, 'utf-8'); + const info = ts.preProcessFile(content, true); + + for (const ref of info.importedFiles) { + if (!ref.fileName.startsWith('.')) { + continue; // skip node_modules + } + if (ref.fileName.endsWith('.css')) { + continue; + } + + const dir = path.dirname(filename); + let resolvedPath = path.resolve(dir, ref.fileName); + if (resolvedPath.endsWith('.js')) { + resolvedPath = resolvedPath.slice(0, -3); + } + const normalizedResolved = normalize(resolvedPath); + + if (fs.existsSync(normalizedResolved + '.js')) { + graph.inertEdge(normalize(filename), normalizedResolved + '.js'); + } else if (fs.existsSync(normalizedResolved + '.ts')) { + graph.inertEdge(normalize(filename), normalizedResolved + '.ts'); + } + } +} + +function main(): void { + const folder = process.argv[2]; + if (!folder) { + console.error('Usage: node build/lib/checkCyclicDependencies.ts '); + process.exit(1); + } + + const rootDir = path.resolve(folder); + if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { + console.error(`Not a directory: ${rootDir}`); + process.exit(1); + } + + const files = collectJsFiles(rootDir); + const graph = new Graph(); + + for (const file of files) { + processFile(file, graph); + } + + const allNormalized = files.map(normalize).sort((a, b) => a.localeCompare(b)); + const cycles = graph.findCycles(allNormalized); + + const cyclicPaths = new Set(); + for (const [_filename, cycle] of cycles) { + if (cycle) { + const path = cycle.join(' -> '); + if (cyclicPaths.has(path)) { + continue; + } + cyclicPaths.add(path); + console.error(`CYCLIC dependency: ${path}`); + } + } + + if (cyclicPaths.size > 0) { + process.exit(1); + } else { + console.log(`No cyclic dependencies found in ${files.length} files.`); + } +} + +if (process.argv[1] && normalize(path.resolve(process.argv[1])).endsWith('checkCyclicDependencies.ts')) { + main(); +} diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 6c1e65b0f57f3..5710f4d6919fd 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -85,7 +85,7 @@ function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolea // Unlike webpack, esbuild only does bundling so we still want to run a separate type check step input = es.merge( fromLocalEsbuild(extensionPath, esbuildConfigFileName), - typeCheckExtensionStream(extensionPath, forWeb), + ...getBuildRootsForExtension(extensionPath).map(root => typeCheckExtensionStream(root, forWeb)), ); isBundled = true; } else if (hasWebpack) { @@ -766,3 +766,15 @@ export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Prom outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined }))); } + +export function getBuildRootsForExtension(extensionPath: string): string[] { + // These extensions split their code between a client and server folder. We should treat each as build roots + if (extensionPath.endsWith('css-language-features') || extensionPath.endsWith('html-language-features') || extensionPath.endsWith('json-language-features')) { + return [ + path.join(extensionPath, 'client'), + path.join(extensionPath, 'server'), + ]; + } + + return [extensionPath]; +} diff --git a/build/lib/test/checkCyclicDependencies.test.ts b/build/lib/test/checkCyclicDependencies.test.ts new file mode 100644 index 0000000000000..bbffbc55fabd7 --- /dev/null +++ b/build/lib/test/checkCyclicDependencies.test.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { Graph, collectJsFiles, processFile, normalize } from '../checkCyclicDependencies.ts'; + +suite('checkCyclicDependencies', () => { + + suite('Graph', () => { + + test('no cycles in linear chain', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + const cycles = graph.findCycles(['a', 'b', 'c']); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + + test('detects simple cycle', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'a'); + const cycles = graph.findCycles(['a', 'b']); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('detects 3-node cycle', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + graph.inertEdge('c', 'a'); + const cycles = graph.findCycles(['a', 'b', 'c']); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('no false positives with shared dependencies', () => { + const graph = new Graph(); + // diamond: a -> b, a -> c, b -> d, c -> d + graph.inertEdge('a', 'b'); + graph.inertEdge('a', 'c'); + graph.inertEdge('b', 'd'); + graph.inertEdge('c', 'd'); + const cycles = graph.findCycles(['a', 'b', 'c', 'd']); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + + test('lookupOrInsertNode returns same node for same data', () => { + const graph = new Graph(); + const node1 = graph.lookupOrInsertNode('x'); + const node2 = graph.lookupOrInsertNode('x'); + assert.strictEqual(node1, node2); + }); + + test('lookup returns undefined for unknown node', () => { + const graph = new Graph(); + assert.strictEqual(graph.lookup('unknown'), undefined); + }); + + test('findCycles skips unknown data', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + const cycles = graph.findCycles(['nonexistent']); + assert.strictEqual(cycles.get('nonexistent'), undefined); + }); + + test('cycle path contains the cycle nodes', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + graph.inertEdge('c', 'b'); + const cycles = graph.findCycles(['a', 'b', 'c']); + const cyclePath = Array.from(cycles.values()).find(c => c !== undefined); + assert.ok(cyclePath); + assert.ok(cyclePath.includes('b')); + assert.ok(cyclePath.includes('c')); + // cycle should start and end with same node + assert.strictEqual(cyclePath[0], cyclePath[cyclePath.length - 1]); + }); + }); + + suite('normalize', () => { + + test('replaces backslashes with forward slashes', () => { + assert.strictEqual(normalize('a\\b\\c'), 'a/b/c'); + }); + + test('leaves forward slashes unchanged', () => { + assert.strictEqual(normalize('a/b/c'), 'a/b/c'); + }); + }); + + suite('collectJsFiles and processFile', () => { + + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cyclic-test-')); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('collectJsFiles finds .js files recursively', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), ''); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), ''); + fs.mkdirSync(path.join(tmpDir, 'sub')); + fs.writeFileSync(path.join(tmpDir, 'sub', 'c.js'), ''); + const files = collectJsFiles(tmpDir); + assert.strictEqual(files.length, 2); + assert.ok(files.some(f => f.endsWith('a.js'))); + assert.ok(files.some(f => f.endsWith('c.js'))); + }); + + test('processFile adds edges for relative imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), ''); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + const aNode = graph.lookup(normalize(path.join(tmpDir, 'a.js'))); + assert.ok(aNode); + assert.strictEqual(aNode.outgoing.size, 1); + }); + + test('processFile skips non-relative imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import fs from "fs";'); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + // no relative imports, so no edges and no node created + assert.strictEqual(graph.lookup(normalize(path.join(tmpDir, 'a.js'))), undefined); + }); + + test('processFile skips CSS imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import "./styles.css";'); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + // CSS imports are ignored, so no edges and no node created + assert.strictEqual(graph.lookup(normalize(path.join(tmpDir, 'a.js'))), undefined); + }); + + test('end-to-end: detects cycle in JS files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), 'import { y } from "./a";'); + const files = collectJsFiles(tmpDir); + const graph = new Graph(); + for (const file of files) { + processFile(file, graph); + } + const allNormalized = files.map(normalize); + const cycles = graph.findCycles(allNormalized); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('end-to-end: no cycle in acyclic JS files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), ''); + const files = collectJsFiles(tmpDir); + const graph = new Graph(); + for (const file of files) { + processFile(file, graph); + } + const allNormalized = files.map(normalize); + const cycles = graph.findCycles(allNormalized); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + }); +}); diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index a4ef0b2fada0c..6e28e550f4699 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/package-lock.json b/build/package-lock.json index ffcaa1455e746..36b85902e22d6 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -307,17 +307,17 @@ } }, "node_modules/@azure/core-xml": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.4.tgz", - "integrity": "sha512-J4FYAqakGXcbfeZjwjMzjNcpcH4E+JtEBv+xcV1yL0Ydn/6wbQfeFKTCHh9wttAi0lmajHw7yBbHPRG+YHckZQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", + "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", "dev": true, "license": "MIT", "dependencies": { - "fast-xml-parser": "^4.4.1", - "tslib": "^2.6.2" + "fast-xml-parser": "^5.0.7", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/cosmos": { @@ -5358,23 +5358,19 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", - "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" - }, - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" } ], "license": "MIT", "dependencies": { - "strnum": "^1.0.5" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8933,10 +8929,16 @@ } }, "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT" }, "node_modules/structured-source": { @@ -9433,10 +9435,11 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", diff --git a/extensions/css-language-features/.vscodeignore b/extensions/css-language-features/.vscodeignore index f6411e76fdb27..c3f4d9fe3dcf4 100644 --- a/extensions/css-language-features/.vscodeignore +++ b/extensions/css-language-features/.vscodeignore @@ -6,16 +6,12 @@ client/src/** server/src/** client/out/** server/out/** -client/tsconfig.json -server/tsconfig.json +**/tsconfig*.json server/test/** server/bin/** server/build/** server/package-lock.json server/.npmignore package-lock.json -server/extension.webpack.config.js -extension.webpack.config.js -server/extension-browser.webpack.config.js -extension-browser.webpack.config.js +**/esbuild*.mts CONTRIBUTING.md diff --git a/extensions/css-language-features/client/tsconfig.browser.json b/extensions/css-language-features/client/tsconfig.browser.json new file mode 100644 index 0000000000000..d10ec3ba37121 --- /dev/null +++ b/extensions/css-language-features/client/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} diff --git a/extensions/css-language-features/esbuild.browser.mts b/extensions/css-language-features/esbuild.browser.mts new file mode 100644 index 0000000000000..83ed767ec5f7e --- /dev/null +++ b/extensions/css-language-features/esbuild.browser.mts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const extensionRoot = import.meta.dirname; + +await Promise.all([ + // Build client + run({ + platform: 'browser', + entryPoints: { + 'cssClientMain': path.join(extensionRoot, 'client', 'src', 'browser', 'cssClientMain.ts'), + }, + srcDir: path.join(extensionRoot, 'client', 'src'), + outdir: path.join(extensionRoot, 'client', 'dist', 'browser'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'client', 'tsconfig.browser.json'), + }, + }, process.argv), + + // Build server + run({ + platform: 'browser', + entryPoints: { + 'cssServerMain': path.join(extensionRoot, 'server', 'src', 'browser', 'cssServerWorkerMain.ts'), + }, + srcDir: path.join(extensionRoot, 'server', 'src'), + outdir: path.join(extensionRoot, 'server', 'dist', 'browser'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'server', 'tsconfig.browser.json'), + }, + }, process.argv), +]); diff --git a/extensions/css-language-features/esbuild.mts b/extensions/css-language-features/esbuild.mts new file mode 100644 index 0000000000000..cef9c7455dce2 --- /dev/null +++ b/extensions/css-language-features/esbuild.mts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const extensionRoot = import.meta.dirname; + +await Promise.all([ + // Build client + run({ + platform: 'node', + entryPoints: { + 'cssClientMain': path.join(extensionRoot, 'client', 'src', 'node', 'cssClientMain.ts'), + }, + srcDir: path.join(extensionRoot, 'client', 'src'), + outdir: path.join(extensionRoot, 'client', 'dist', 'node'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'client', 'tsconfig.json'), + }, + }, process.argv), + + // Build server + run({ + platform: 'node', + entryPoints: { + 'cssServerMain': path.join(extensionRoot, 'server', 'src', 'node', 'cssServerNodeMain.ts'), + }, + srcDir: path.join(extensionRoot, 'server', 'src'), + outdir: path.join(extensionRoot, 'server', 'dist', 'node'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'server', 'tsconfig.json'), + }, + }, process.argv), +]); diff --git a/extensions/css-language-features/extension-browser.webpack.config.js b/extensions/css-language-features/extension-browser.webpack.config.js deleted file mode 100644 index ea4a69dd9c139..0000000000000 --- a/extensions/css-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: path.join(import.meta.dirname, 'client'), - entry: { - extension: './src/browser/cssClientMain.ts' - }, - output: { - filename: 'cssClientMain.js', - path: path.join(import.meta.dirname, 'client', 'dist', 'browser') - } -}); diff --git a/extensions/css-language-features/extension.webpack.config.js b/extensions/css-language-features/extension.webpack.config.js deleted file mode 100644 index d8a29c8797dd7..0000000000000 --- a/extensions/css-language-features/extension.webpack.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withDefaults({ - context: path.join(import.meta.dirname, 'client'), - entry: { - extension: './src/node/cssClientMain.ts', - }, - output: { - filename: 'cssClientMain.js', - path: path.join(import.meta.dirname, 'client', 'dist', 'node') - } -}); diff --git a/extensions/css-language-features/server/extension-browser.webpack.config.js b/extensions/css-language-features/server/extension-browser.webpack.config.js deleted file mode 100644 index 131d293a7c50c..0000000000000 --- a/extensions/css-language-features/server/extension-browser.webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/browser/cssServerWorkerMain.ts', - }, - output: { - filename: 'cssServerMain.js', - path: path.join(import.meta.dirname, 'dist', 'browser'), - libraryTarget: 'var', - library: 'serverExportVar' - } -}); diff --git a/extensions/css-language-features/server/extension.webpack.config.js b/extensions/css-language-features/server/extension.webpack.config.js deleted file mode 100644 index 5f07bd8f0a1a2..0000000000000 --- a/extensions/css-language-features/server/extension.webpack.config.js +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../../shared.webpack.config.mjs'; -import path from 'path'; - -export default withDefaults({ - context: path.join(import.meta.dirname), - entry: { - extension: './src/node/cssServerNodeMain.ts', - }, - output: { - filename: 'cssServerMain.js', - path: path.join(import.meta.dirname, 'dist', 'node'), - } -}); diff --git a/extensions/css-language-features/server/tsconfig.browser.json b/extensions/css-language-features/server/tsconfig.browser.json new file mode 100644 index 0000000000000..d10ec3ba37121 --- /dev/null +++ b/extensions/css-language-features/server/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 17cb3439dddae..2ef19c0e59d42 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -54,9 +54,9 @@ "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", "list.activeSelectionBackground": "#3994BC26", - "list.activeSelectionForeground": "#bfbfbf", + "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", - "list.inactiveSelectionForeground": "#bfbfbf", + "list.inactiveSelectionForeground": "#ededed", "list.hoverBackground": "#262728", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 6dfbb2888a496..ec6b840db4708 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -61,11 +61,16 @@ } /* Ensure iframe containers in pane-body render above sidebar z-index */ -.monaco-workbench > div[data-keybinding-context], .monaco-workbench > div[data-keybinding-context] { z-index: 50 !important; } +/* Ensure in-editor pane iframes render below sidebar z-index */ +.monaco-workbench > div[data-parent-flow-to-element-id] { + z-index: 0 !important; +} + + /* Ensure webview containers render above sidebar z-index */ .monaco-workbench .part.sidebar .webview, .monaco-workbench .part.sidebar .webview-container, @@ -200,7 +205,7 @@ background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; } -.quick-input-list .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { height: 16px; margin-top: 2px; display: flex; @@ -208,14 +213,18 @@ font-size: 11px; padding: 0 4px 1px 4px; border-radius: var(--vscode-cornerRadius-small) !important; - background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; color: var(--vscode-badge-foreground) !important; margin-right: 8px; } -.monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench.vs-dark .quick-input-list .quick-input-list-entry .quick-input-list-separator { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + +.monaco-workbench .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { background: transparent !important; color: inherit !important; padding: 0; @@ -437,6 +446,15 @@ box-shadow: var(--shadow-sm); } +.monaco-workbench .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; +} + +.monaco-workbench.vs-dark .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + /* Welcome Tiles */ .monaco-workbench .part.editor .welcomePageContainer .tile { box-shadow: var(--shadow-md); diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 70e0d21044499..baf20de579dc0 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1451,208 +1451,431 @@ "title": "%configuration.format%", "order": 23, "properties": { + "js/ts.format.enabled": { + "type": "boolean", + "default": true, + "description": "%format.enable%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.enable": { "type": "boolean", "default": true, "description": "%javascript.format.enable%", + "markdownDeprecationMessage": "%configuration.format.enable.unifiedDeprecationMessage%", "scope": "window" }, "typescript.format.enable": { "type": "boolean", "default": true, "description": "%typescript.format.enable%", + "markdownDeprecationMessage": "%configuration.format.enable.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.format.insertSpaceAfterCommaDelimiter": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterCommaDelimiter%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterCommaDelimiter": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterCommaDelimiter%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterCommaDelimiter": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterCommaDelimiter%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterConstructor": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterConstructor%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterConstructor": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterConstructor%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterConstructor": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterConstructor%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterSemicolonInForStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterSemicolonInForStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterSemicolonInForStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceBeforeAndAfterBinaryOperators": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceBeforeAndAfterBinaryOperators": { "type": "boolean", "default": true, "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": { "type": "boolean", "default": true, "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterKeywordsInControlFlowStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceBeforeFunctionParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceBeforeFunctionParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceBeforeFunctionParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterTypeAssertion": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterTypeAssertion%", + "scope": "language-overridable", + "tags": [ + "TypeScript" + ] + }, "typescript.format.insertSpaceAfterTypeAssertion": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterTypeAssertion%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterTypeAssertion.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.placeOpenBraceOnNewLineForFunctions": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.placeOpenBraceOnNewLineForFunctions": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.placeOpenBraceOnNewLineForFunctions": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.placeOpenBraceOnNewLineForControlBlocks": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.placeOpenBraceOnNewLineForControlBlocks": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.placeOpenBraceOnNewLineForControlBlocks": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.semicolons": { + "type": "string", + "default": "ignore", + "description": "%format.semicolons%", + "scope": "language-overridable", + "enum": [ + "ignore", + "insert", + "remove" + ], + "enumDescriptions": [ + "%format.semicolons.ignore%", + "%format.semicolons.insert%", + "%format.semicolons.remove%" + ], + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.semicolons": { "type": "string", "default": "ignore", "description": "%format.semicolons%", + "markdownDeprecationMessage": "%configuration.format.semicolons.unifiedDeprecationMessage%", "scope": "resource", "enum": [ "ignore", @@ -1669,6 +1892,7 @@ "type": "string", "default": "ignore", "description": "%format.semicolons%", + "markdownDeprecationMessage": "%configuration.format.semicolons.unifiedDeprecationMessage%", "scope": "resource", "enum": [ "ignore", @@ -1681,16 +1905,28 @@ "%format.semicolons.remove%" ] }, + "js/ts.format.indentSwitchCase": { + "type": "boolean", + "default": true, + "description": "%format.indentSwitchCase%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.indentSwitchCase": { "type": "boolean", "default": true, "description": "%format.indentSwitchCase%", + "markdownDeprecationMessage": "%configuration.format.indentSwitchCase.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.indentSwitchCase": { "type": "boolean", "default": true, "description": "%format.indentSwitchCase%", + "markdownDeprecationMessage": "%configuration.format.indentSwitchCase.unifiedDeprecationMessage%", "scope": "resource" } } diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 59a9ea92331bb..cc9fc7e38cc2a 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -49,6 +49,26 @@ "format.semicolons.insert": "Insert semicolons at statement ends.", "format.semicolons.remove": "Remove unnecessary semicolons.", "format.indentSwitchCase": "Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.", + "format.enable": "Enable/disable the default JavaScript and TypeScript formatter.", + "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enable#` instead.", + "configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterCommaDelimiter#` instead.", + "configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterConstructor#` instead.", + "configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterSemicolonInForStatements#` instead.", + "configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceBeforeAndAfterBinaryOperators#` instead.", + "configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterKeywordsInControlFlowStatements#` instead.", + "configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions#` instead.", + "configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceBeforeFunctionParenthesis#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces#` instead.", + "configuration.format.insertSpaceAfterTypeAssertion.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterTypeAssertion#` instead.", + "configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.placeOpenBraceOnNewLineForFunctions#` instead.", + "configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.placeOpenBraceOnNewLineForControlBlocks#` instead.", + "configuration.format.semicolons.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.semicolons#` instead.", + "configuration.format.indentSwitchCase.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.indentSwitchCase#` instead.", "javascript.validate.enable": "Enable/disable JavaScript validation.", "javascript.goToProjectConfig.title": "Go to Project Configuration (jsconfig / tsconfig)", "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", diff --git a/extensions/typescript-language-features/src/languageFeatures/completions.ts b/extensions/typescript-language-features/src/languageFeatures/completions.ts index a6378deaece6d..030bb84ea0034 100644 --- a/extensions/typescript-language-features/src/languageFeatures/completions.ts +++ b/extensions/typescript-language-features/src/languageFeatures/completions.ts @@ -16,7 +16,7 @@ import * as typeConverters from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient, ServerResponse } from '../typescriptService'; import TypingsStatus from '../ui/typingsStatus'; import { nulToken } from '../utils/cancellation'; -import { readUnifiedConfig } from '../utils/configuration'; +import { readUnifiedConfig, UnifiedConfigurationScope } from '../utils/configuration'; import FileConfigurationManager from './fileConfigurationManager'; import { applyCodeAction } from './util/codeAction'; import { conditionalRegistration, requireSomeCapability } from './util/dependentRegistration'; @@ -667,14 +667,14 @@ namespace CompletionConfiguration { export function getConfigurationForResource( modeId: string, - resource: vscode.Uri + scope: UnifiedConfigurationScope ): CompletionConfiguration { - const config = vscode.workspace.getConfiguration(modeId, resource); + const config = vscode.workspace.getConfiguration(modeId, scope); return { - completeFunctionCalls: readUnifiedConfig(CompletionConfiguration.completeFunctionCalls, false, { scope: resource, fallbackSection: modeId }), - pathSuggestions: readUnifiedConfig(CompletionConfiguration.pathSuggestions, true, { scope: resource, fallbackSection: modeId }), - autoImportSuggestions: readUnifiedConfig(CompletionConfiguration.autoImportSuggestions, true, { scope: resource, fallbackSection: modeId }), - nameSuggestions: readUnifiedConfig(CompletionConfiguration.nameSuggestions, true, { scope: resource, fallbackSection: modeId }), + completeFunctionCalls: readUnifiedConfig(CompletionConfiguration.completeFunctionCalls, false, { scope: scope, fallbackSection: modeId }), + pathSuggestions: readUnifiedConfig(CompletionConfiguration.pathSuggestions, true, { scope: scope, fallbackSection: modeId }), + autoImportSuggestions: readUnifiedConfig(CompletionConfiguration.autoImportSuggestions, true, { scope: scope, fallbackSection: modeId }), + nameSuggestions: readUnifiedConfig(CompletionConfiguration.nameSuggestions, true, { scope: scope, fallbackSection: modeId }), importStatementSuggestions: config.get(CompletionConfiguration.importStatementSuggestions, true), }; } @@ -727,7 +727,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider< } const line = document.lineAt(position.line); - const completionConfiguration = CompletionConfiguration.getConfigurationForResource(this.language.id, document.uri); + const completionConfiguration = CompletionConfiguration.getConfigurationForResource(this.language.id, document); if (!this.shouldTrigger(context, line, position, completionConfiguration)) { return undefined; diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 5b89c4340f329..21f48f20d632e 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -10,7 +10,7 @@ import { isTypeScriptDocument } from '../configuration/languageIds'; import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { ITypeScriptServiceClient } from '../typescriptService'; -import { readUnifiedConfig } from '../utils/configuration'; +import { readUnifiedConfig, UnifiedConfigurationScope } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import { equals } from '../utils/objects'; import { ResourceMap } from '../utils/resourceMap'; @@ -142,9 +142,7 @@ export default class FileConfigurationManager extends Disposable { document: vscode.TextDocument, options: FormattingOptions ): Proto.FormatCodeSettings { - const config = vscode.workspace.getConfiguration( - isTypeScriptDocument(document) ? 'typescript.format' : 'javascript.format', - document.uri); + const fallbackSection = isTypeScriptDocument(document) ? 'typescript' : 'javascript'; return { tabSize: options.tabSize, @@ -152,24 +150,24 @@ export default class FileConfigurationManager extends Disposable { convertTabsToSpaces: options.insertSpaces, // We can use \n here since the editor normalizes later on to its line endings. newLineCharacter: '\n', - insertSpaceAfterCommaDelimiter: config.get('insertSpaceAfterCommaDelimiter'), - insertSpaceAfterConstructor: config.get('insertSpaceAfterConstructor'), - insertSpaceAfterSemicolonInForStatements: config.get('insertSpaceAfterSemicolonInForStatements'), - insertSpaceBeforeAndAfterBinaryOperators: config.get('insertSpaceBeforeAndAfterBinaryOperators'), - insertSpaceAfterKeywordsInControlFlowStatements: config.get('insertSpaceAfterKeywordsInControlFlowStatements'), - insertSpaceAfterFunctionKeywordForAnonymousFunctions: config.get('insertSpaceAfterFunctionKeywordForAnonymousFunctions'), - insertSpaceBeforeFunctionParenthesis: config.get('insertSpaceBeforeFunctionParenthesis'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces'), - insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingEmptyBraces'), - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces'), - insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces'), - insertSpaceAfterTypeAssertion: config.get('insertSpaceAfterTypeAssertion'), - placeOpenBraceOnNewLineForFunctions: config.get('placeOpenBraceOnNewLineForFunctions'), - placeOpenBraceOnNewLineForControlBlocks: config.get('placeOpenBraceOnNewLineForControlBlocks'), - semicolons: config.get('semicolons'), - indentSwitchCase: config.get('indentSwitchCase'), + insertSpaceAfterCommaDelimiter: readUnifiedConfig('format.insertSpaceAfterCommaDelimiter', true, { scope: document, fallbackSection }), + insertSpaceAfterConstructor: readUnifiedConfig('format.insertSpaceAfterConstructor', false, { scope: document, fallbackSection }), + insertSpaceAfterSemicolonInForStatements: readUnifiedConfig('format.insertSpaceAfterSemicolonInForStatements', true, { scope: document, fallbackSection }), + insertSpaceBeforeAndAfterBinaryOperators: readUnifiedConfig('format.insertSpaceBeforeAndAfterBinaryOperators', true, { scope: document, fallbackSection }), + insertSpaceAfterKeywordsInControlFlowStatements: readUnifiedConfig('format.insertSpaceAfterKeywordsInControlFlowStatements', true, { scope: document, fallbackSection }), + insertSpaceAfterFunctionKeywordForAnonymousFunctions: readUnifiedConfig('format.insertSpaceAfterFunctionKeywordForAnonymousFunctions', true, { scope: document, fallbackSection }), + insertSpaceBeforeFunctionParenthesis: readUnifiedConfig('format.insertSpaceBeforeFunctionParenthesis', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces', true, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces', true, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces', false, { scope: document, fallbackSection }), + insertSpaceAfterTypeAssertion: readUnifiedConfig('format.insertSpaceAfterTypeAssertion', false, { scope: document, fallbackSection }), + placeOpenBraceOnNewLineForFunctions: readUnifiedConfig('format.placeOpenBraceOnNewLineForFunctions', false, { scope: document, fallbackSection }), + placeOpenBraceOnNewLineForControlBlocks: readUnifiedConfig('format.placeOpenBraceOnNewLineForControlBlocks', false, { scope: document, fallbackSection }), + semicolons: readUnifiedConfig('format.semicolons', 'ignore' as Proto.SemicolonPreference, { scope: document, fallbackSection }), + indentSwitchCase: readUnifiedConfig('format.indentSwitchCase', true, { scope: document, fallbackSection }), }; } @@ -213,7 +211,7 @@ export default class FileConfigurationManager extends Disposable { return preferences; } - private getAutoImportFileExcludePatternsPreference(scope: vscode.ConfigurationScope, fallbackSection: string, workspaceFolder: vscode.Uri | undefined): string[] | undefined { + private getAutoImportFileExcludePatternsPreference(scope: UnifiedConfigurationScope, fallbackSection: string, workspaceFolder: vscode.Uri | undefined): string[] | undefined { const patterns = readUnifiedConfig('preferences.autoImportFileExcludePatterns', undefined, { scope, fallbackSection }); return workspaceFolder && patterns?.map(p => { // Normalization rules: https://github.com/microsoft/TypeScript/pull/49578 @@ -256,7 +254,7 @@ export const InlayHintSettingNames = Object.freeze({ enumMemberValuesEnabled: 'inlayHints.enumMemberValues.enabled', }); -export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string) { +export function getInlayHintsPreferences(scope: UnifiedConfigurationScope, fallbackSection: string) { return { includeInlayParameterNameHints: getInlayParameterNameHintsPreference(scope, fallbackSection), includeInlayParameterNameHintsWhenArgumentMatchesName: !readUnifiedConfig(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true, { scope, fallbackSection }), @@ -269,7 +267,7 @@ export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallb } as const; } -function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getInlayParameterNameHintsPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig(InlayHintSettingNames.parameterNamesEnabled, 'none', { scope, fallbackSection })) { case 'none': return 'none'; case 'literals': return 'literals'; @@ -278,7 +276,7 @@ function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, } } -function getQuoteStylePreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getQuoteStylePreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.quoteStyle', 'auto', { scope, fallbackSection })) { case 'single': return 'single'; case 'double': return 'double'; @@ -286,7 +284,7 @@ function getQuoteStylePreference(scope: vscode.ConfigurationScope, fallbackSecti } } -function getImportModuleSpecifierPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getImportModuleSpecifierPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.importModuleSpecifier', 'shortest', { scope, fallbackSection })) { case 'project-relative': return 'project-relative'; case 'relative': return 'relative'; @@ -295,7 +293,7 @@ function getImportModuleSpecifierPreference(scope: vscode.ConfigurationScope, fa } } -function getImportModuleSpecifierEndingPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getImportModuleSpecifierEndingPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.importModuleSpecifierEnding', 'auto', { scope, fallbackSection })) { case 'minimal': return 'minimal'; case 'index': return 'index'; @@ -304,7 +302,7 @@ function getImportModuleSpecifierEndingPreference(scope: vscode.ConfigurationSco } } -function getJsxAttributeCompletionStyle(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getJsxAttributeCompletionStyle(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.jsxAttributeCompletionStyle', 'auto', { scope, fallbackSection })) { case 'braces': return 'braces'; case 'none': return 'none'; @@ -312,7 +310,7 @@ function getJsxAttributeCompletionStyle(scope: vscode.ConfigurationScope, fallba } } -function getOrganizeImportsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string): Proto.UserPreferences { +function getOrganizeImportsPreferences(scope: UnifiedConfigurationScope, fallbackSection: string): Proto.UserPreferences { const organizeImportsCollation = readUnifiedConfig<'ordinal' | 'unicode'>('preferences.organizeImports.unicodeCollation', 'ordinal', { scope, fallbackSection }); const organizeImportsCaseSensitivity = readUnifiedConfig<'auto' | 'caseInsensitive' | 'caseSensitive'>('preferences.organizeImports.caseSensitivity', 'auto', { scope, fallbackSection }); return { diff --git a/extensions/typescript-language-features/src/languageFeatures/formatting.ts b/extensions/typescript-language-features/src/languageFeatures/formatting.ts index 575487b502d7c..4b23268cd4ca7 100644 --- a/extensions/typescript-language-features/src/languageFeatures/formatting.ts +++ b/extensions/typescript-language-features/src/languageFeatures/formatting.ts @@ -10,7 +10,7 @@ import type * as Proto from '../tsServer/protocol/protocol'; import * as typeConverters from '../typeConverters'; import { ITypeScriptServiceClient } from '../typescriptService'; import FileConfigurationManager from './fileConfigurationManager'; -import { conditionalRegistration, requireGlobalConfiguration } from './util/dependentRegistration'; +import { conditionalRegistration, requireGlobalUnifiedConfig } from './util/dependentRegistration'; class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEditProvider, vscode.OnTypeFormattingEditProvider { public constructor( @@ -46,10 +46,10 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit ch: string, options: vscode.FormattingOptions, token: vscode.CancellationToken - ): Promise { + ): Promise { const file = this.client.toOpenTsFilePath(document); if (!file) { - return []; + return undefined; } await this.fileConfigurationManager.ensureConfigurationOptions(document, options, token); @@ -92,7 +92,7 @@ export function register( fileConfigurationManager: FileConfigurationManager ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'format.enable'), + requireGlobalUnifiedConfig('format.enabled', { fallbackSection: language.id, fallbackSubSectionNameOverride: 'format.enable' }), ], () => { const formattingProvider = new TypeScriptFormattingProvider(client, fileConfigurationManager); return vscode.Disposable.from( diff --git a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts index 916bfd8f3aecf..e390d9a29fa7b 100644 --- a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../tsServer/api'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { hasModifiedUnifiedConfig } from '../../utils/configuration'; +import { hasModifiedUnifiedConfig, readUnifiedConfig, ReadUnifiedConfigOptions } from '../../utils/configuration'; import { Disposable } from '../../utils/dispose'; export class Condition extends Disposable { @@ -118,6 +118,18 @@ export function requireHasModifiedUnifiedConfig( ); } +export function requireGlobalUnifiedConfig( + configValue: string, + options: ReadUnifiedConfigOptions +) { + return new Condition( + () => { + return !!readUnifiedConfig(configValue, undefined, options); + }, + vscode.workspace.onDidChangeConfiguration + ); +} + export function requireSomeCapability( client: ITypeScriptServiceClient, ...capabilities: readonly ClientCapability[] diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts index b10a70fd27a9d..e0038500cf4ba 100644 --- a/extensions/typescript-language-features/src/utils/configuration.ts +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -5,10 +5,16 @@ import * as vscode from 'vscode'; -type ConfigurationScope = vscode.ConfigurationScope | null | undefined; +export type UnifiedConfigurationScope = vscode.TextDocument | null | undefined; export const unifiedConfigSection = 'js/ts'; +export type ReadUnifiedConfigOptions = { + readonly scope?: UnifiedConfigurationScope; + readonly fallbackSection: string; + readonly fallbackSubSectionNameOverride?: string; +}; + /** * Gets a configuration value, checking the unified `js/ts` setting first, * then falling back to the language-specific setting. @@ -16,10 +22,7 @@ export const unifiedConfigSection = 'js/ts'; export function readUnifiedConfig( subSectionName: string, defaultValue: T, - options: { - readonly scope?: ConfigurationScope; - readonly fallbackSection: string; - } + options: ReadUnifiedConfigOptions ): T { // Check unified setting first const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); @@ -30,7 +33,7 @@ export function readUnifiedConfig( // Fall back to language-specific setting const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); - return languageConfig.get(subSectionName, defaultValue); + return languageConfig.get(options.fallbackSubSectionNameOverride ?? subSectionName, defaultValue); } /** @@ -58,7 +61,7 @@ function hasModifiedValue(inspect: ReturnType { * Optional toolbar actions shown when the item is focused or hovered. */ readonly toolbarActions?: IAction[]; - /** - * Optional section identifier. Items with the same section belong to the same - * collapsible group. Only meaningful when the ActionList is created with - * collapsible sections. - */ - readonly section?: string; - /** - * When true, clicking this item toggles the section's collapsed state - * instead of selecting it. - */ - readonly isSectionToggle?: boolean; - /** - * Optional CSS class name to add to the row container. - */ - readonly className?: string; - /** - * Optional badge text to display after the label (e.g., "New"). - */ - readonly badge?: string; - /** - * When set, the description is rendered as a primary button. - * The callback is invoked when the button is clicked. - */ - readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; } interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; - readonly badge: HTMLElement; readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly elementDisposables: DisposableStore; - previousClassName?: string; } export const enum ActionListItemKind { @@ -186,10 +159,6 @@ class ActionItemRenderer implements IListRenderer, IAction text.className = 'title'; container.append(text); - const badge = document.createElement('span'); - badge.className = 'action-item-badge'; - container.append(badge); - const description = document.createElement('span'); description.className = 'description'; container.append(description); @@ -202,7 +171,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -225,40 +194,10 @@ class ActionItemRenderer implements IListRenderer, IAction dom.setVisibility(!element.hideIcon, data.icon); - // Apply optional className - clean up previous to avoid stale classes - // from virtualized row reuse - if (data.previousClassName) { - data.container.classList.remove(data.previousClassName); - } - data.container.classList.toggle('action-list-custom', !!element.className); - if (element.className) { - data.container.classList.add(element.className); - } - data.previousClassName = element.className; - data.text.textContent = stripNewlines(element.label); - // Render optional badge - if (element.badge) { - data.badge.textContent = element.badge; - data.badge.style.display = ''; - } else { - data.badge.textContent = ''; - data.badge.style.display = 'none'; - } - // if there is a keybinding, prioritize over description for now - if (element.descriptionButton) { - data.description!.textContent = ''; - data.description!.style.display = 'inline'; - const button = new Button(data.description!, { ...defaultButtonStyles, small: true }); - button.label = element.descriptionButton.label; - data.elementDisposables.add(button.onDidClick(e => { - e?.stopPropagation(); - element.descriptionButton!.onDidClick(); - })); - data.elementDisposables.add(button); - } else if (element.keybinding) { + if (element.keybinding) { data.description!.textContent = element.keybinding.getLabel(); data.description!.style.display = 'inline'; data.description!.style.letterSpacing = '0.5px'; @@ -322,26 +261,6 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } -/** - * Options for configuring the action list. - */ -export interface IActionListOptions { - /** - * When true, shows a filter input at the bottom of the list. - */ - readonly showFilter?: boolean; - - /** - * Section IDs that should be collapsed by default. - */ - readonly collapsedByDefault?: ReadonlySet; - - /** - * Minimum width for the action list. - */ - readonly minWidth?: number; -} - export class ActionList extends Disposable { public readonly domNode: HTMLElement; @@ -358,20 +277,12 @@ export class ActionList extends Disposable { private _hover = this._register(new MutableDisposable()); - private readonly _collapsedSections = new Set(); - private _filterText = ''; - private readonly _filterInput: HTMLInputElement | undefined; - private readonly _filterContainer: HTMLElement | undefined; - private _lastMinWidth = 0; - private _hasLaidOut = false; - constructor( user: string, preview: boolean, items: readonly IActionListItem[], private readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, - private readonly _options: IActionListOptions | undefined, @IContextViewService private readonly _contextViewService: IContextViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @@ -380,14 +291,6 @@ export class ActionList extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); - - // Initialize collapsed sections - if (this._options?.collapsedByDefault) { - for (const section of this._options.collapsedByDefault) { - this._collapsedSections.add(section); - } - } - const virtualDelegate: IListVirtualDelegate> = { getHeight: element => { switch (element.kind) { @@ -409,7 +312,7 @@ export class ActionList extends Disposable { new SeparatorRenderer(), ], { keyboardSupport: false, - typeNavigationEnabled: !this._options?.showFilter, + typeNavigationEnabled: true, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel }, accessibilityProvider: { getAriaLabel: element => { @@ -449,151 +352,13 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); this._allMenuItems = items; - - // Create filter input - if (this._options?.showFilter) { - this._filterContainer = document.createElement('div'); - this._filterContainer.className = 'action-list-filter'; - - this._filterInput = document.createElement('input'); - this._filterInput.type = 'text'; - this._filterInput.className = 'action-list-filter-input'; - this._filterInput.placeholder = localize('actionList.filter.placeholder', "Search..."); - this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); - this._filterContainer.appendChild(this._filterInput); - - this._register(dom.addDisposableListener(this._filterInput, 'input', () => { - this._filterText = this._filterInput!.value; - this._applyFilter(); - })); - - // Keyboard navigation from filter input - this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowUp') { - e.preventDefault(); - this._list.domFocus(); - const lastIndex = this._list.length - 1; - if (lastIndex >= 0) { - this._list.focusLast(undefined, this.focusCondition); - } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - this._list.domFocus(); - this.focusNext(); - } else if (e.key === 'Enter') { - e.preventDefault(); - this.acceptSelected(); - } else if (e.key === 'Escape') { - if (this._filterText) { - e.preventDefault(); - e.stopPropagation(); - this._filterInput!.value = ''; - this._filterText = ''; - this._applyFilter(); - } - } - })); - } - - this._applyFilter(); + this._list.splice(0, this._list.length, this._allMenuItems); if (this._list.length) { this.focusNext(); } } - private _toggleSection(section: string): void { - if (this._collapsedSections.has(section)) { - this._collapsedSections.delete(section); - } else { - this._collapsedSections.add(section); - } - this._applyFilter(); - } - - private _applyFilter(): void { - const filterLower = this._filterText.toLowerCase(); - const isFiltering = filterLower.length > 0; - const visible: IActionListItem[] = []; - - for (const item of this._allMenuItems) { - if (item.kind === ActionListItemKind.Header) { - if (isFiltering) { - // When filtering, skip all headers - continue; - } - visible.push(item); - continue; - } - - if (item.kind === ActionListItemKind.Separator) { - if (isFiltering) { - continue; - } - visible.push(item); - continue; - } - - // Action item - if (isFiltering) { - // When filtering, skip section toggle items and only match content - if (item.isSectionToggle) { - continue; - } - // Match against label and description - const label = (item.label ?? '').toLowerCase(); - const desc = (item.description ?? '').toLowerCase(); - if (label.includes(filterLower) || desc.includes(filterLower)) { - visible.push(item); - } - } else { - // Update icon for section toggle items based on collapsed state - if (item.isSectionToggle && item.section) { - const collapsed = this._collapsedSections.has(item.section); - visible.push({ - ...item, - group: { ...item.group!, icon: collapsed ? Codicon.chevronRight : Codicon.chevronDown }, - }); - continue; - } - // Not filtering - check collapsed sections - if (item.section && this._collapsedSections.has(item.section)) { - continue; - } - visible.push(item); - } - } - - // Capture whether the filter input currently has focus before splice - // which may cause DOM changes that shift focus. - const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); - - this._list.splice(0, this._list.length, visible); - - // Re-layout to adjust height after items changed - if (this._hasLaidOut) { - this.layout(this._lastMinWidth); - // Restore focus after splice destroyed DOM elements, - // otherwise the blur handler in ActionWidgetService closes the widget. - // Keep focus on the filter input if the user is typing a filter. - if (filterInputHasFocus) { - this._filterInput!.focus(); - } else { - this._list.domFocus(); - } - // Reposition the context view so the widget grows in the correct direction - this._contextViewService.layout(); - } - } - - /** - * Returns the filter container element, if filter is enabled. - * The caller is responsible for appending it to the widget DOM. - */ - get filterContainer(): HTMLElement | undefined { - return this._filterContainer; - } - private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } @@ -606,57 +371,39 @@ export class ActionList extends Disposable { } layout(minWidth: number): number { - this._hasLaidOut = true; - this._lastMinWidth = minWidth; - // Compute height based on currently visible items in the list - const visibleCount = this._list.length; - let listHeight = 0; - for (let i = 0; i < visibleCount; i++) { - const element = this._list.element(i); - switch (element.kind) { - case ActionListItemKind.Header: - listHeight += this._headerLineHeight; - break; - case ActionListItemKind.Separator: - listHeight += this._separatorLineHeight; - break; - default: - listHeight += this._actionLineHeight; - break; - } - } - - this._list.layout(listHeight); - const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); - let maxWidth = effectiveMinWidth; - - if (visibleCount >= 50) { - maxWidth = Math.max(380, effectiveMinWidth); + // Updating list height, depending on how many separators and headers there are. + const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length; + const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length; + const itemsHeight = this._allMenuItems.length * this._actionLineHeight; + const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight; + const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight; + this._list.layout(heightWithSeparators); + let maxWidth = minWidth; + + if (this._allMenuItems.length >= 50) { + maxWidth = 380; } else { // For finding width dynamically (not using resize observer) - const itemWidths: number[] = []; - for (let i = 0; i < visibleCount; i++) { - const element = this._getRowElement(i); + const itemWidths: number[] = this._allMenuItems.map((_, index): number => { + const element = this._getRowElement(index); if (element) { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + return width; } - } + return 0; + }); // resize observer - can be used in the future since list widget supports dynamic height but not width - maxWidth = Math.max(...itemWidths, effectiveMinWidth); + maxWidth = Math.max(...itemWidths, minWidth); } - const filterHeight = this._filterContainer ? 36 : 0; const maxVhPrecentage = 0.7; - const maxHeight = this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage; - const height = Math.min(listHeight + filterHeight, maxHeight); - const listFinalHeight = height - filterHeight; - this._list.layout(listFinalHeight, maxWidth); + const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); + this._list.layout(height, maxWidth); - this.domNode.style.height = `${listFinalHeight}px`; + this.domNode.style.height = `${height}px`; this._list.domFocus(); return maxWidth; @@ -700,10 +447,6 @@ export class ActionList extends Disposable { } const element = e.elements[0]; - if (element.isSectionToggle) { - this._list.setSelection([]); - return; - } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -783,11 +526,6 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { - if (e.element && e.element.isSectionToggle && e.element.section) { - const section = e.element.section; - queueMicrotask(() => this._toggleSection(section)); - return; - } if (e.element && this.focusCondition(e.element)) { this._list.setFocus([]); } diff --git a/src/vs/platform/actionWidget/browser/actionListDropdown.css b/src/vs/platform/actionWidget/browser/actionListDropdown.css new file mode 100644 index 0000000000000..e4b24d38c6618 --- /dev/null +++ b/src/vs/platform/actionWidget/browser/actionListDropdown.css @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.action-list-dropdown { + font-size: 13px; + min-width: 100px; + max-width: 80vw; + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 5px; + background-color: var(--vscode-menu-background); + color: var(--vscode-menu-foreground); + padding: 4px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); +} + +.action-list-dropdown-items { + user-select: none; + -webkit-user-select: none; +} + +/* Action item rows */ +.action-list-dropdown .action-list-dropdown-item.action { + display: flex; + gap: 6px; + align-items: center; + padding: 0 4px 0 8px; + white-space: nowrap; + cursor: pointer; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-foreground); + outline: none; +} + +.action-list-dropdown .action-list-dropdown-item.action.focused:not(.option-disabled), +.action-list-dropdown .action-list-dropdown-item.action:hover:not(.option-disabled) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-menu-selectionBorder, transparent); + outline-offset: -1px; +} + +.action-list-dropdown .action-list-dropdown-item.action.option-disabled { + cursor: default; + color: var(--vscode-disabledForeground); +} + +.action-list-dropdown .action-list-dropdown-item.action .icon { + font-size: 12px; +} + +.action-list-dropdown .action-list-dropdown-item.action .title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-list-dropdown .action-list-dropdown-item.action .description { + opacity: 0.7; + margin-left: 0.5em; + flex-shrink: 0; +} + +.action-list-dropdown .action-list-dropdown-item.action .action-list-dropdown-item-badge { + padding: 0px 6px; + border-radius: 10px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 18px; + flex-shrink: 0; +} + +/* Separators */ +.action-list-dropdown .action-list-dropdown-item.separator { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + margin: 4px 0px; +} + +.action-list-dropdown .action-list-dropdown-item.separator:first-child { + border-top: none; + margin-top: 0; +} + +/* Filter input */ +.action-list-dropdown .action-list-dropdown-filter { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + padding: 4px; +} + +.action-list-dropdown .action-list-dropdown-filter-input { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + outline: none; +} + +.action-list-dropdown .action-list-dropdown-filter-input:focus { + border-color: var(--vscode-focusBorder); +} + +.action-list-dropdown .action-list-dropdown-filter-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +/* Manage models link */ +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link { + color: var(--vscode-textLink-foreground); +} + +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link:hover, +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link.focused { + color: var(--vscode-textLink-activeForeground); +} diff --git a/src/vs/platform/actionWidget/browser/actionListDropdown.ts b/src/vs/platform/actionWidget/browser/actionListDropdown.ts new file mode 100644 index 0000000000000..ee332a2e0569f --- /dev/null +++ b/src/vs/platform/actionWidget/browser/actionListDropdown.ts @@ -0,0 +1,473 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { KeyCode } from '../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { localize } from '../../../nls.js'; +import { IContextViewService } from '../../contextview/browser/contextView.js'; +import { ILayoutService } from '../../layout/browser/layoutService.js'; +import { defaultButtonStyles } from '../../theme/browser/defaultStyles.js'; +import './actionListDropdown.css'; + +/** + * Represents an item in the action list dropdown. + */ +export interface IActionListDropdownItem { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly icon?: ThemeIcon; + readonly checked?: boolean; + readonly disabled?: boolean; + readonly tooltip?: string; + readonly className?: string; + readonly badge?: string; + readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; + readonly section?: string; + readonly isSectionToggle?: boolean; + readonly run: () => void; +} + +/** + * The kind of entry in the action list dropdown. + */ +export const enum ActionListDropdownItemKind { + Action = 'action', + Separator = 'separator' +} + +/** + * An entry in the action list dropdown, either an action item or a separator. + */ +export interface IActionListDropdownEntry { + readonly item?: IActionListDropdownItem; + readonly kind: ActionListDropdownItemKind; +} + +/** + * Options for the action list dropdown. + */ +export interface IActionListDropdownOptions { + readonly collapsedByDefault?: ReadonlySet; + readonly minWidth?: number; +} + +/** + * Delegate that receives callbacks from the action list dropdown. + */ +export interface IActionListDropdownDelegate { + onSelect(item: IActionListDropdownItem): void; + onHide(): void; +} + +const ACTION_ITEM_HEIGHT = 24; +const SEPARATOR_HEIGHT = 8; + +/** + * A DOM-based dropdown widget with filtering and collapsible groups. + * Renders items directly as DOM elements without using the List widget. + */ +export class ActionListDropdown extends Disposable { + + private _isVisible = false; + private _domNode: HTMLElement | undefined; + private _previousFocusedElement: HTMLElement | undefined; + private readonly _showDisposables = this._register(new DisposableStore()); + private readonly _collapsedSections = new Set(); + + get isVisible(): boolean { + return this._isVisible; + } + + constructor( + @IContextViewService private readonly _contextViewService: IContextViewService, + @ILayoutService private readonly _layoutService: ILayoutService, + ) { + super(); + } + + /** + * Show the dropdown anchored to the given element. + */ + show(entries: IActionListDropdownEntry[], delegate: IActionListDropdownDelegate, anchor: HTMLElement, options?: IActionListDropdownOptions): void { + this.hide(); + + this._showDisposables.clear(); + this._previousFocusedElement = dom.getDocument(anchor).activeElement as HTMLElement | undefined; + this._focusedIndex = -1; + + this._collapsedSections.clear(); + if (options?.collapsedByDefault) { + for (const section of options.collapsedByDefault) { + this._collapsedSections.add(section); + } + } + + let filterText = ''; + let itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[] = []; + let itemsContainer: HTMLElement; + let filterContainer: HTMLElement; + + const showDisposables = this._showDisposables; + + let filterInput: HTMLInputElement; + + const renderItems = () => { + dom.clearNode(itemsContainer); + itemElements = []; + + const filtered = this._getVisibleEntries(entries, filterText); + for (const entry of filtered) { + const el = this._renderEntry(entry, delegate, renderItems, showDisposables); + itemsContainer.appendChild(el); + itemElements.push({ element: el, entry }); + } + + this._focusedIndex = -1; + this._updateWidth(itemsContainer, itemElements, options?.minWidth); + this._constrainHeight(itemsContainer, filterContainer); + + // Re-focus filter input after re-render to prevent blur-to-close + filterInput?.focus(); + }; + + const contextView = this._contextViewService.showContextView({ + getAnchor: () => anchor, + render: (container) => { + const disposables = new DisposableStore(); + + const widget = dom.append(container, dom.$('.action-list-dropdown')); + this._domNode = widget; + + itemsContainer = dom.append(widget, dom.$('.action-list-dropdown-items')); + + filterContainer = dom.append(widget, dom.$('.action-list-dropdown-filter')); + filterInput = dom.append(filterContainer, dom.$('input.action-list-dropdown-filter-input')); + filterInput.type = 'text'; + filterInput.placeholder = localize('filterPlaceholder', "Filter..."); + + disposables.add(dom.addDisposableListener(filterInput, 'input', () => { + filterText = filterInput.value; + renderItems(); + })); + + disposables.add(dom.addDisposableListener(filterInput, 'keydown', (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.DownArrow) { + e.preventDefault(); + this._focusedIndex = -1; + this._moveFocus(itemElements, 1); + } else if (event.keyCode === KeyCode.UpArrow) { + e.preventDefault(); + this._focusedIndex = itemElements.length; + this._moveFocus(itemElements, -1); + } else if (event.keyCode === KeyCode.Escape) { + e.preventDefault(); + if (filterText) { + filterInput.value = ''; + filterText = ''; + renderItems(); + } else { + this.hide(); + } + } + })); + + disposables.add(dom.addDisposableListener(widget, 'keydown', (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.DownArrow) { + e.preventDefault(); + this._moveFocus(itemElements, 1); + } else if (event.keyCode === KeyCode.UpArrow) { + e.preventDefault(); + this._moveFocus(itemElements, -1); + } else if (event.keyCode === KeyCode.Enter) { + e.preventDefault(); + if (this._focusedIndex >= 0 && this._focusedIndex < itemElements.length) { + const { entry } = itemElements[this._focusedIndex]; + if (entry.kind === ActionListDropdownItemKind.Action && entry.item) { + if (entry.item.isSectionToggle) { + this._toggleSection(entry.item.section); + renderItems(); + } else { + delegate.onSelect(entry.item); + } + } + } + } else if (event.keyCode === KeyCode.Escape) { + e.preventDefault(); + if (filterText) { + filterInput.value = ''; + filterText = ''; + renderItems(); + } else { + this.hide(); + } + } + })); + + renderItems(); + + // Focus tracking + const focusTracker = dom.trackFocus(widget); + disposables.add(focusTracker); + disposables.add(focusTracker.onDidBlur(() => { + const activeElement = dom.getDocument(widget).activeElement; + if (!widget.contains(activeElement)) { + this.hide(); + } + })); + + filterInput.focus(); + + return disposables; + }, + onHide: () => { + this._isVisible = false; + delegate.onHide(); + if (this._previousFocusedElement) { + this._previousFocusedElement.focus(); + this._previousFocusedElement = undefined; + } + }, + }, undefined, false); + + this._showDisposables.add({ dispose: () => contextView.close() }); + this._isVisible = true; + } + + /** + * Hide the dropdown. + */ + hide(): void { + if (!this._isVisible) { + return; + } + this._isVisible = false; + this._showDisposables.clear(); + this._domNode = undefined; + } + + private _getVisibleEntries(entries: IActionListDropdownEntry[], filter: string): IActionListDropdownEntry[] { + const isFiltering = filter.length > 0; + const filterLower = filter.toLowerCase(); + const result: IActionListDropdownEntry[] = []; + const seenIds = new Set(); + let pendingSeparator: IActionListDropdownEntry | undefined; + + for (const entry of entries) { + if (entry.kind === ActionListDropdownItemKind.Separator) { + pendingSeparator = entry; + continue; + } + + const item = entry.item; + if (!item) { + continue; + } + + // Skip section toggle items when filtering + if (isFiltering && item.isSectionToggle) { + continue; + } + + // Skip collapsed section items (but not the toggle itself) + if (!isFiltering && item.section && !item.isSectionToggle && this._collapsedSections.has(item.section)) { + continue; + } + + // Apply text filter + if (isFiltering) { + const label = item.label.toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + if (!label.includes(filterLower) && !desc.includes(filterLower)) { + continue; + } + // Deduplicate by id when filtering across sections + if (seenIds.has(item.id)) { + continue; + } + seenIds.add(item.id); + } + + // Emit pending separator (skip if this would be the first item) + if (pendingSeparator && result.length > 0) { + result.push(pendingSeparator); + } + pendingSeparator = undefined; + + result.push(entry); + } + + return result; + } + + private _renderEntry( + entry: IActionListDropdownEntry, + delegate: IActionListDropdownDelegate, + rerender: () => void, + disposables: DisposableStore, + ): HTMLElement { + if (entry.kind === ActionListDropdownItemKind.Separator) { + const separator = dom.$('.action-list-dropdown-item.separator'); + separator.style.height = `${SEPARATOR_HEIGHT}px`; + return separator; + } + + const item = entry.item!; + const row = dom.$('.action-list-dropdown-item.action'); + row.style.height = `${ACTION_ITEM_HEIGHT}px`; + row.tabIndex = 0; + + if (item.disabled) { + row.classList.add('option-disabled'); + } + if (item.className) { + row.classList.add(item.className); + } + if (item.tooltip) { + row.title = item.tooltip; + } + + // Icon + const iconContainer = dom.append(row, dom.$('.icon')); + if (item.isSectionToggle) { + const toggleIcon = this._collapsedSections.has(item.section ?? '') ? Codicon.chevronRight : Codicon.chevronDown; + iconContainer.classList.add(...ThemeIcon.asClassNameArray(toggleIcon)); + } else if (item.checked !== undefined) { + const checkIcon = item.checked ? Codicon.check : Codicon.blank; + iconContainer.classList.add(...ThemeIcon.asClassNameArray(checkIcon)); + } else if (item.icon) { + iconContainer.classList.add(...ThemeIcon.asClassNameArray(item.icon)); + } + + // Title + const title = dom.append(row, dom.$('span.title')); + title.textContent = item.label; + + // Badge + if (item.badge) { + const badge = dom.append(row, dom.$('span.action-list-dropdown-item-badge')); + badge.textContent = item.badge; + } + + // Description or description button + if (item.descriptionButton) { + const descContainer = dom.append(row, dom.$('span.description')); + const btn = new Button(descContainer, { ...defaultButtonStyles }); + disposables.add(btn); + btn.label = item.descriptionButton.label; + disposables.add(btn.onDidClick(() => { + item.descriptionButton!.onDidClick(); + })); + } else if (item.description) { + const desc = dom.append(row, dom.$('span.description')); + desc.textContent = item.description; + } + + // Click handler + if (!item.disabled || item.isSectionToggle) { + disposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e: MouseEvent) => { + e.stopPropagation(); + if (item.isSectionToggle) { + this._toggleSection(item.section); + rerender(); + } else { + delegate.onSelect(item); + } + })); + } + + return row; + } + + private _toggleSection(section: string | undefined): void { + if (!section) { + return; + } + if (this._collapsedSections.has(section)) { + this._collapsedSections.delete(section); + } else { + this._collapsedSections.add(section); + } + } + + private _moveFocus( + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + direction: 1 | -1, + ): void { + let idx = this._focusedIndex; + while (true) { + idx += direction; + if (idx < 0 || idx >= itemElements.length) { + return; + } + const { entry } = itemElements[idx]; + if (entry.kind === ActionListDropdownItemKind.Action && entry.item && !entry.item.disabled) { + this._setFocusedIndex(itemElements, idx); + return; + } + } + } + + private _focusedIndex = -1; + + private _setFocusedIndex( + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + index: number, + ): void { + // Remove previous focus + if (this._focusedIndex >= 0 && this._focusedIndex < itemElements.length) { + itemElements[this._focusedIndex].element.classList.remove('focused'); + } + this._focusedIndex = index; + if (index >= 0 && index < itemElements.length) { + const el = itemElements[index].element; + el.classList.add('focused'); + el.focus(); + } + } + + private _constrainHeight(itemsContainer: HTMLElement, filterContainer: HTMLElement): void { + if (!this._domNode) { + return; + } + const targetWindow = dom.getWindow(this._domNode); + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this._domNode.getBoundingClientRect().top; + const padding = 10; + const filterHeight = filterContainer.getBoundingClientRect().height || 30; + const availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + const maxHeight = Math.max(availableHeight, ACTION_ITEM_HEIGHT * 3 + filterHeight); + + itemsContainer.style.maxHeight = `${maxHeight - filterHeight}px`; + itemsContainer.style.overflowY = 'auto'; + } + + private _updateWidth( + itemsContainer: HTMLElement, + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + minWidth?: number, + ): void { + let maxWidth = minWidth ?? 0; + for (const { element, entry } of itemElements) { + if (entry.kind !== ActionListDropdownItemKind.Action) { + continue; + } + element.style.width = 'auto'; + const width = element.getBoundingClientRect().width; + element.style.width = ''; + maxWidth = Math.max(maxWidth, width); + } + if (maxWidth > 0) { + itemsContainer.style.width = `${maxWidth}px`; + } + } +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index e3969db45e1eb..b2aaa8b1e00f7 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -5,14 +5,13 @@ .action-widget { font-size: 13px; - border-radius: 0; min-width: 100px; max-width: 80vw; z-index: 40; display: block; width: 100%; border: 1px solid var(--vscode-editorHoverWidget-border) !important; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-menu-background); color: var(--vscode-menu-foreground); padding: 4px; @@ -61,12 +60,12 @@ cursor: pointer; touch-action: none; width: 100%; - border-radius: var(--vscode-cornerRadius-small); + border-radius: var(--vscode-cornerRadius-medium); } .action-widget .monaco-list .monaco-list-row.action.focused:not(.option-disabled) { - background-color: var(--vscode-list-activeSelectionBackground) !important; - color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-hoverBackground) !important; + color: var(--vscode-list-hoverForeground); outline: 1px solid var(--vscode-menu-selectionBorder, transparent); outline-offset: -1px; } @@ -122,7 +121,6 @@ display: flex; gap: 6px; align-items: center; - color: var(--vscode-foreground) !important; } .action-widget .monaco-list-row.action .codicon { @@ -151,16 +149,6 @@ text-overflow: ellipsis; } -.action-widget .monaco-list-row.action .action-item-badge { - padding: 0px 6px; - border-radius: 10px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - font-size: 11px; - line-height: 18px; - flex-shrink: 0; -} - .action-widget .monaco-list-row.action .monaco-keybinding > .monaco-keybinding-key { background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); @@ -214,12 +202,11 @@ } .action-widget .monaco-list .monaco-list-row .description { - opacity: 0.7; + color: var(--vscode-descriptionForeground); margin-left: 0.5em; - flex-shrink: 0; + font-size: 12px; } - /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -240,29 +227,3 @@ gap: 4px; font-size: 12px; } - -/* Filter input */ -.action-widget .action-list-filter { - border-top: 1px solid var(--vscode-editorHoverWidget-border); - padding: 4px; -} - -.action-widget .action-list-filter-input { - width: 100%; - box-sizing: border-box; - padding: 4px 8px; - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 3px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - font-size: 12px; - outline: none; -} - -.action-widget .action-list-filter-input:focus { - border-color: var(--vscode-focusBorder); -} - -.action-widget .action-list-filter-input::placeholder { - color: var(--vscode-input-placeholderForeground); -} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 53483956586f0..21b49245bebcc 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -10,7 +10,7 @@ import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import './actionWidget.css'; import { localize, localize2 } from '../../../nls.js'; -import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, previewSelectedActionCommand } from './actionList.js'; +import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, previewSelectedActionCommand } from './actionList.js'; import { Action2, registerAction2 } from '../../actions/common/actions.js'; import { IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; @@ -36,7 +36,7 @@ export const IActionWidgetService = createDecorator('actio export interface IActionWidgetService { readonly _serviceBrand: undefined; - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void; + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void; hide(didCancel?: boolean): void; @@ -60,10 +60,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { super(); } - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void { + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -137,11 +137,6 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } } - // Filter input (appended after the list, before action bar visually) - if (this._list.value?.filterContainer) { - widget.appendChild(this._list.value.filterContainer); - } - const width = this._list.value?.layout(actionBarWidth); widget.style.width = `${width}px`; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index b7b61da059f8e..296286bd51494 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -6,7 +6,7 @@ import { IActionWidgetService } from './actionWidget.js'; import { IAction } from '../../../base/common/actions.js'; import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from './actionList.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { Codicon } from '../../../base/common/codicons.js'; import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; @@ -52,11 +52,6 @@ export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions { * provided, no telemetry will be sent. */ readonly reporter?: { id: string; name?: string; includeOptions?: boolean }; - - /** - * Options for the underlying ActionList (filter, collapsible sections). - */ - readonly listOptions?: IActionListOptions; } /** @@ -82,7 +77,7 @@ export class ActionWidgetDropdown extends BaseDropdown { return; } - let actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; + const actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; const actions = this._options.actions ?? this._options.actionProvider?.getActions() ?? []; // Track the currently selected option before opening @@ -159,9 +154,13 @@ export class ActionWidgetDropdown extends BaseDropdown { const previouslyFocusedElement = getActiveElement(); + const auxiliaryActionIds = new Set(actionBarActions.map(action => action.id)); + const actionWidgetDelegate: IActionListDelegate = { onSelect: (action, preview) => { - selectedOption = action; + if (!auxiliaryActionIds.has(action.id)) { + selectedOption = action; + } this.actionWidgetService.hide(); action.run(); }, @@ -173,13 +172,30 @@ export class ActionWidgetDropdown extends BaseDropdown { } }; - actionBarActions = actionBarActions.map(action => ({ - ...action, - run: async (...args: unknown[]) => { - this.actionWidgetService.hide(); - return action.run(...args); + if (actionBarActions.length) { + if (actionWidgetItems.length) { + actionWidgetItems.push({ + label: '', + kind: ActionListItemKind.Separator, + canPreview: false, + disabled: false, + hideIcon: false, + }); } - })); + + for (const action of actionBarActions) { + actionWidgetItems.push({ + item: action, + tooltip: action.tooltip, + kind: ActionListItemKind.Action, + canPreview: false, + group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, + disabled: !action.enabled, + hideIcon: false, + label: action.label, + }); + } + } const accessibilityProvider: Partial>> = { isChecked(element) { @@ -188,7 +204,9 @@ export class ActionWidgetDropdown extends BaseDropdown { getRole: (e) => { switch (e.kind) { case ActionListItemKind.Action: - return 'menuitemcheckbox'; + // Auxiliary actions are not checkable options, so use 'menuitem' to + // avoid screen readers announcing them as unchecked checkboxes. + return e.item && auxiliaryActionIds.has(e.item.id) ? 'menuitem' : 'menuitemcheckbox'; case ActionListItemKind.Separator: return 'separator'; default: @@ -205,9 +223,8 @@ export class ActionWidgetDropdown extends BaseDropdown { actionWidgetDelegate, this._options.getAnchor?.() ?? this.element, undefined, - actionBarActions, - accessibilityProvider, - this._options.listOptions + [], + accessibilityProvider ); } diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index a10f4c9b3bbc5..45cb23ba6f3f7 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -53,6 +53,7 @@ export interface NativeParsedArgs { goto?: boolean; 'new-window'?: boolean; 'reuse-window'?: boolean; + 'sessions'?: boolean; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 35a833d5f903d..6d00ad0ae0908 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -99,6 +99,7 @@ export const OPTIONS: OptionDescriptions> = { 'goto': { type: 'boolean', cat: 'o', alias: 'g', args: 'file:line[:character]', description: localize('goto', "Open a file at the path on the specified line and character position.") }, 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, + 'sessions': { type: 'boolean', cat: 'o', description: localize('sessions', "Opens the sessions window.") }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css new file mode 100644 index 0000000000000..3d9b3a393fa2f --- /dev/null +++ b/src/vs/sessions/browser/media/sidebarActionButton.css @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sidebar-action-list .actions-container { + gap: 4px; +} + +.sidebar-action > .action-label { + /* Hide the default action-label rendered by ActionViewItem */ + display: none; +} + +/* Shared styling for interactive sidebar action buttons (account widget, customization links, etc.) */ +.sidebar-action-button { + display: flex; + align-items: center; + border: none; + padding: 4px 8px; + margin: 0; + font-size: 11px; + font-weight: 500; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + text-decoration: none; + border-radius: 4px; + cursor: pointer; + gap: 10px; + display: flex; +} + +.sidebar-action-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.sidebar-action-button.monaco-text-button:focus { + outline-offset: -1px !important; +} + +.sidebar-action-button.monaco-text-button .codicon { + margin: 0; +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index d8f4c72894978..0162bcb26d036 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -31,18 +31,27 @@ /* Sidebar Footer Container */ .monaco-workbench .part.sidebar > .sidebar-footer { display: flex; - align-items: center; + align-items: stretch; + gap: 4px; padding: 6px; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); flex-shrink: 0; } -/* Make the toolbar and its action-item fill the full footer width */ +/* Make the toolbar fill the footer width and stack actions vertically */ .monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, .monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, -.monaco-workbench .part.sidebar > .sidebar-footer .actions-container, + +.monaco-workbench .part.sidebar > .sidebar-footer .actions-container { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + cursor: default; +} + .monaco-workbench .part.sidebar > .sidebar-footer .action-item { - flex: 1; width: 100%; max-width: 100%; cursor: default; diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index d632124f3defb..f669616ce1780 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -53,8 +53,13 @@ export class SidebarPart extends AbstractPaneCompositePart { static readonly MARGIN_TOP = 0; static readonly MARGIN_BOTTOM = 0; static readonly MARGIN_LEFT = 0; - static readonly FOOTER_HEIGHT = 39; + private static readonly FOOTER_ITEM_HEIGHT = 26; + private static readonly FOOTER_ITEM_GAP = 4; + private static readonly FOOTER_VERTICAL_PADDING = 6; + private footerContainer: HTMLElement | undefined; + private footerToolbar: MenuWorkbenchToolBar | undefined; + private previousLayoutDimensions: { width: number; height: number; top: number; left: number } | undefined; //#region IView @@ -167,13 +172,41 @@ export class SidebarPart extends AbstractPaneCompositePart { } private createFooter(parent: HTMLElement): void { - const footer = append(parent, $('.sidebar-footer')); + const footer = append(parent, $('.sidebar-footer.sidebar-action-list')); + this.footerContainer = footer; - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { + this.footerToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { primaryGroup: () => true }, telemetrySource: 'sidebarFooter', })); + + this._register(this.footerToolbar.onDidChangeMenuItems(() => { + if (this.previousLayoutDimensions) { + const { width, height, top, left } = this.previousLayoutDimensions; + this.layout(width, height, top, left); + } + })); + } + + private getFooterHeight(): number { + const actionCount = this.footerToolbar?.getItemsLength() ?? 0; + if (actionCount === 0) { + return 0; + } + + return SidebarPart.FOOTER_VERTICAL_PADDING * 2 + + (actionCount * SidebarPart.FOOTER_ITEM_HEIGHT) + + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP); + } + + private updateFooterVisibility(): void { + const footer = this.footerContainer; + if (!footer) { + return; + } + + footer.style.display = this.getFooterHeight() > 0 ? '' : 'none'; } override updateStyles(): void { @@ -193,14 +226,19 @@ export class SidebarPart extends AbstractPaneCompositePart { } override layout(width: number, height: number, top: number, left: number): void { + this.previousLayoutDimensions = { width, height, top, left }; + if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { return; } + this.updateFooterVisibility(); + const footerHeight = Math.min(height, this.getFooterHeight()); + // Layout content with reduced height to account for footer super.layout( width, - height - SidebarPart.FOOTER_HEIGHT, + height - footerHeight, top, left ); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index fc9b4c7aedc8a..d19bc8ff3ae87 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../../browser/media/sidebarActionButton.css'; import './media/accountWidget.css'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -11,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -84,7 +85,6 @@ registerUpdateMenuItems(AccountMenu, '3_updates'); class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; - private updateButton: Button | undefined; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( @@ -94,14 +94,17 @@ class AccountWidget extends ActionViewItem { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IUpdateService private readonly updateService: IUpdateService, ) { super(undefined, action, { ...options, icon: false, label: false }); } + protected override getTooltip(): string | undefined { + return undefined; + } + override render(container: HTMLElement): void { super.render(container); - container.classList.add('account-widget'); + container.classList.add('account-widget', 'sidebar-action'); // Account button (left) const accountContainer = append(container, $('.account-widget-account')); @@ -115,7 +118,7 @@ class AccountWidget extends ActionViewItem { buttonSecondaryForeground: undefined, buttonSecondaryBorder: undefined, })); - this.accountButton.element.classList.add('account-widget-account-button'); + this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); @@ -125,9 +128,63 @@ class AccountWidget extends ActionViewItem { e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + } + + private showAccountMenu(anchor: HTMLElement): void { + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const actions: IAction[] = []; + fillInActionBarActions(menu.getActions(), actions); + menu.dispose(); + + const rect = anchor.getBoundingClientRect(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: rect.right, y: rect.top }), + getActions: () => actions, + anchorAlignment: AnchorAlignment.LEFT, + }); + } + + private async updateAccountButton(): Promise { + if (!this.accountButton) { + return; + } + this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; + this.accountButton.enabled = false; + const account = await this.defaultAccountService.getDefaultAccount(); + this.accountButton.enabled = true; + this.accountButton.label = account + ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` + : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; + } + + + override onClick(): void { + // Handled by custom click handlers + } +} + +class UpdateWidget extends ActionViewItem { - // Update button (shown for progress and restart-to-update states) - const updateContainer = append(container, $('.account-widget-update')); + private updateButton: Button | undefined; + private readonly viewItemDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('update-widget', 'sidebar-action'); + + const updateContainer = append(container, $('.update-widget-action')); this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { ...defaultButtonStyles, secondary: true, @@ -138,79 +195,39 @@ class AccountWidget extends ActionViewItem { buttonSecondaryForeground: undefined, buttonSecondaryBorder: undefined, })); - this.updateButton.element.classList.add('account-widget-update-button'); - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); this.updateUpdateButton(); this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); } - private isUpdateAvailable(): boolean { + private isUpdateReady(): boolean { return this.updateService.state.type === StateType.Ready; } - private isUpdateInProgress(): boolean { + private isUpdatePending(): boolean { const type = this.updateService.state.type; - return type === StateType.CheckingForUpdates + return type === StateType.AvailableForDownload + || type === StateType.CheckingForUpdates || type === StateType.Downloading || type === StateType.Downloaded || type === StateType.Updating || type === StateType.Overwriting; } - private showAccountMenu(anchor: HTMLElement): void { - const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const actions: IAction[] = []; - fillInActionBarActions(menu.getActions(), actions); - menu.dispose(); - - if (this.isUpdateAvailable()) { - // Update button visible: open above the button - this.contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => actions, - anchorAlignment: AnchorAlignment.LEFT, - }); - } else { - // No update button: open to the right of the button - const rect = anchor.getBoundingClientRect(); - this.contextMenuService.showContextMenu({ - getAnchor: () => ({ x: rect.right, y: rect.top }), - getActions: () => actions, - }); - } - } - - private async updateAccountButton(): Promise { - if (!this.accountButton) { - return; - } - this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; - this.accountButton.enabled = false; - const account = await this.defaultAccountService.getDefaultAccount(); - this.accountButton.enabled = true; - this.accountButton.label = account - ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` - : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; - } - private updateUpdateButton(): void { if (!this.updateButton) { return; } const state = this.updateService.state; - if (this.isUpdateInProgress()) { - this.updateButton.element.parentElement!.style.display = ''; + if (this.isUpdatePending() && !this.isUpdateReady()) { this.updateButton.enabled = false; this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - } else if (this.isUpdateAvailable()) { - this.updateButton.element.parentElement!.style.display = ''; + } else { this.updateButton.enabled = true; this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - } else { - this.updateButton.element.parentElement!.style.display = 'none'; } } @@ -257,6 +274,11 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); + const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; + this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { + return instantiationService.createInstance(UpdateWidget, action, options); + }, undefined)); + // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -275,6 +297,32 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu // Handled by the custom view item } })); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: sessionsUpdateWidgetAction, + title: localize2('sessionsUpdateWidget', 'Sessions Update'), + menu: { + id: Menus.SidebarFooter, + group: 'navigation', + order: 0, + when: ContextKeyExpr.or( + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), + ) + } + }); + } + async run(): Promise { + // Handled by the custom view item + } + })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index ad72846d5c533..01bdd2c100b03 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,19 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Account Widget */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget > .action-label { - display: none; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 8px; -} - /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -23,49 +10,9 @@ flex: 1; } -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { +.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { overflow: hidden; min-width: 0; - flex-shrink: 1; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button:hover:not(:disabled) { - background-color: var(--vscode-toolbar-hoverBackground); + flex: 1; } diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css index a0a97bea3bf1d..4954a2a3fd979 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -38,11 +38,11 @@ .ai-customization-management-editor .section-list-item { display: flex; align-items: center; - padding: 8px 16px; + padding: 4px 8px; gap: 10px; cursor: pointer; - margin: 2px 6px; - border-radius: 6px; + margin: 1px 6px; + border-radius: 4px; transition: background-color 0.1s ease, opacity 0.1s ease; } @@ -79,7 +79,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; + font-size: 12px; font-weight: 400; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 1ada74835c253..014ad6d8d4028 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -7,9 +7,12 @@ import './media/chatWidget.css'; import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { Separator, toAction } from '../../../../base/common/actions.js'; import { Radio } from '../../../../base/browser/ui/radio/radio.js'; +import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; @@ -48,14 +51,40 @@ import { WorkspaceFolderCountContext } from '../../../../workbench/common/contex import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; +import { IWorkspacesService, IRecentFolder, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; +import { isString } from '../../../../base/common/types.js'; // #region --- Target Config --- +/** + * A dropdown menu action item that shows an icon, a text label, and a chevron. + */ +class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem { + protected override renderLabel(element: HTMLElement): null { + // Render icon as a separate codicon element + const classNames = typeof this.options.classNames === 'string' + ? this.options.classNames.split(/\s+/g).filter(s => !!s) + : (this.options.classNames ?? []); + if (classNames.length > 0) { + const icon = dom.append(element, dom.$('span')); + icon.classList.add('codicon', ...classNames); + } + + // Add text label (not affected by codicon font) + const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = this._action.label; + + // Add chevron + dom.append(element, renderIcon(Codicon.chevronDown)); + + return null; + } +} + /** * Tracks which agent session targets are available and which is selected. * Targets are fixed at construction time; only the selection changes. @@ -178,6 +207,8 @@ class NewChatWidget extends Disposable { private _localModePickersContainer: HTMLElement | undefined; private _localMode: 'workspace' | 'worktree' = 'worktree'; private _selectedFolderUri: URI | undefined; + private _recentlyPickedFolders: URI[] = []; + private _cachedRecentFolders: IRecentFolder[] = []; private readonly _pickerWidgets = new Map(); private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); private readonly _optionEmitters = new Map>(); @@ -205,11 +236,24 @@ class NewChatWidget extends Disposable { this._targetConfig = this._register(new TargetConfig(options.targetConfig)); this._options = options; - // Restore last picked folder + // Restore last picked folder and recently picked folders const lastFolder = this.storageService.get('agentSessions.lastPickedFolder', StorageScope.PROFILE); if (lastFolder) { try { this._selectedFolderUri = URI.parse(lastFolder); } catch { /* ignore */ } } + try { + const stored = this.storageService.get('agentSessions.recentlyPickedFolders', StorageScope.PROFILE); + if (stored) { + this._recentlyPickedFolders = JSON.parse(stored).map((s: string) => URI.parse(s)); + } + } catch { /* ignore */ } + + // Pre-fetch recently opened folders + this.workspacesService.getRecentlyOpened().then(recent => { + this._cachedRecentFolders = recent.workspaces.filter(isRecentFolder).slice(0, 10); + }).catch(error => { + this.logService.error('Failed to fetch recently opened workspaces for agent sessions', error); + }); // When target changes, regenerate pending resource this._register(this._targetConfig.onDidChangeSelectedTarget(() => { @@ -228,10 +272,7 @@ class NewChatWidget extends Disposable { })); // Listen for option group changes to re-render pickers - this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { - this._notifyFolderSelection(); - this._renderExtensionPickers(); - })); + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => this._renderExtensionPickers())); // React to chat session option changes this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { @@ -304,17 +345,32 @@ class NewChatWidget extends Disposable { return target; } + private readonly _pendingSessionResources = new Map(); + private _generatePendingSessionResource(): void { const target = this._getEffectiveTarget(); if (!target || target === AgentSessionProviders.Local) { this._pendingSessionResource = undefined; return; } + + // Reuse existing pending resource for the same target type + const existing = this._pendingSessionResources.get(target); + if (existing) { + this._pendingSessionResource = existing; + return; + } + this._pendingSessionResource = getResourceForNewChatSession({ type: target, position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, displayName: '', }); + this._pendingSessionResources.set(target, this._pendingSessionResource); + + // Create the session in the extension so that session options can be stored + this.chatSessionsService.getOrCreateChatSession(this._pendingSessionResource, CancellationToken.None) + .catch((err) => this.logService.trace('Failed to create pending session:', err)); } // --- Editor --- @@ -514,34 +570,30 @@ class NewChatWidget extends Disposable { : localize('localMode.worktree', "Worktree"); const modeIcon = this._localMode === 'workspace' ? Codicon.folder : Codicon.worktree; - const button = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-dropdown-button')); - button.tabIndex = 0; - button.role = 'button'; - button.ariaHasPopup = 'true'; - dom.append(button, renderIcon(modeIcon)); - dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, modeLabel)); - dom.append(button, renderIcon(Codicon.chevronDown)); - - this._localModeDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { - const actions = [ - toAction({ - id: 'localMode.workspace', - label: localize('localMode.workspace', "Workspace"), - checked: this._localMode === 'workspace', - run: () => this._setLocalMode('workspace'), - }), - toAction({ - id: 'localMode.worktree', - label: localize('localMode.worktree', "Worktree"), - checked: this._localMode === 'worktree', - run: () => this._setLocalMode('worktree'), - }), - ]; - this.contextMenuService.showContextMenu({ - getAnchor: () => button, - getActions: () => actions, - }); - })); + const modeAction = toAction({ id: 'localMode', label: modeLabel, run: () => { } }); + const modeDropdown = this._localModeDisposables.add(new LabeledDropdownMenuActionViewItem( + modeAction, + { + getActions: () => [ + toAction({ + id: 'localMode.workspace', + label: localize('localMode.workspace', "Workspace"), + checked: this._localMode === 'workspace', + run: () => this._setLocalMode('workspace'), + }), + toAction({ + id: 'localMode.worktree', + label: localize('localMode.worktree', "Worktree"), + checked: this._localMode === 'worktree', + run: () => this._setLocalMode('worktree'), + }), + ], + }, + this.contextMenuService, + { classNames: [...ThemeIcon.asClassNameArray(modeIcon)] } + )); + const modeSlot = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-picker-slot')); + modeDropdown.render(modeSlot); // Render pickers in the right side this._renderLocalModePickers(); @@ -557,6 +609,7 @@ class NewChatWidget extends Disposable { } private _notifyFolderSelection(): void { + this._selectedOptions.clear(); if (!this._pendingSessionResource) { return; } @@ -569,6 +622,11 @@ class NewChatWidget extends Disposable { } } + private _addToRecentlyPickedFolders(folderUri: URI): void { + this._recentlyPickedFolders = [folderUri, ...this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri))].slice(0, 10); + this.storageService.store('agentSessions.recentlyPickedFolders', JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); + } + private _renderLocalModePickers(): void { if (!this._localModePickersContainer) { return; @@ -725,56 +783,71 @@ class NewChatWidget extends Disposable { const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; const folderName = currentFolderUri ? basename(currentFolderUri) : localize('pickFolder', "Pick Folder"); - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); - const button = dom.append(slot, dom.$('.sessions-chat-dropdown-button')); - button.tabIndex = 0; - button.role = 'button'; - button.ariaHasPopup = 'true'; - dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, folderName)); - dom.append(button, renderIcon(Codicon.chevronDown)); - const switchFolder = async (folderUri: URI) => { this._selectedFolderUri = folderUri; + this._addToRecentlyPickedFolders(folderUri); this.storageService.store('agentSessions.lastPickedFolder', folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE); this._notifyFolderSelection(); this._renderExtensionPickers(true); }; - disposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, async () => { - const recentlyOpened = await this.workspacesService.getRecentlyOpened(); - const recentFolders = recentlyOpened.workspaces - .filter(isRecentFolder) - .filter(r => !currentFolderUri || !isEqual(r.folderUri, currentFolderUri)) - .slice(0, 10); - - const actions = recentFolders.map(recent => toAction({ - id: recent.folderUri.toString(), - label: recent.label || basename(recent.folderUri), - run: () => switchFolder(recent.folderUri), - })); + const folderAction = toAction({ id: 'folderPicker', label: folderName, run: () => { } }); + const folderDropdown = disposables.add(new LabeledDropdownMenuActionViewItem( + folderAction, + { + getActions: () => this._getFolderPickerActions(currentFolderUri, switchFolder), + }, + this.contextMenuService, + { classNames: [...ThemeIcon.asClassNameArray(Codicon.folder)] } + )); + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + folderDropdown.render(slot); + } + + private _getFolderPickerActions(currentFolderUri: URI | undefined, switchFolder: (uri: URI) => Promise): (ReturnType | Separator)[] { + const seenUris = new Set(); + if (currentFolderUri) { + seenUris.add(currentFolderUri.toString()); + } + + const actions: (ReturnType | Separator)[] = []; - actions.push(new Separator()); + // Combine recently picked folders and recently opened folders (picked first, then opened) + const allFolders: { uri: URI; label?: string }[] = [ + ...this._recentlyPickedFolders.map(uri => ({ uri })), + ...this._cachedRecentFolders.map(r => ({ uri: r.folderUri, label: r.label })), + ]; + for (const folder of allFolders) { + const key = folder.uri.toString(); + if (seenUris.has(key)) { + continue; + } + seenUris.add(key); actions.push(toAction({ - id: 'browse', - label: localize('browseFolder', "Browse..."), - run: async () => { - const selected = await this.fileDialogService.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: localize('selectFolder', "Select Folder"), - }); - if (selected?.[0]) { - await switchFolder(selected[0]); - } - }, + id: key, + label: folder.label || basename(folder.uri), + run: () => switchFolder(folder.uri), })); + } - this.contextMenuService.showContextMenu({ - getAnchor: () => button, - getActions: () => actions, - }); + actions.push(new Separator()); + actions.push(toAction({ + id: 'browse', + label: localize('browseFolder', "Browse..."), + run: async () => { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectFolder', "Select Folder"), + }); + if (selected?.[0]) { + await switchFolder(selected[0]); + } + }, })); + + return actions; } private _renderExtensionPickersInContainer(container: HTMLElement, sessionType: AgentSessionProviders): void { @@ -853,7 +926,19 @@ class NewChatWidget extends Disposable { } private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { - return this._selectedOptions.get(optionGroup.id) ?? optionGroup.items.find((item) => item.default === true); + const selectedOption = this._selectedOptions.get(optionGroup.id); + if (selectedOption) { + return selectedOption; + } + + if (this._pendingSessionResource) { + const sessionOption = this.chatSessionsService.getSessionOption(this._pendingSessionResource, optionGroup.id); + if (!isString(sessionOption)) { + return sessionOption; + } + } + + return optionGroup.items.find((item) => item.default === true); } private _syncOptionsFromSession(sessionResource: URI): void { diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts new file mode 100644 index 0000000000000..dd874c3e86c71 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; + +export interface ISourceCounts { + readonly workspace: number; + readonly user: number; + readonly extension: number; +} + +export function getSourceCountsTotal(counts: ISourceCounts): number { + return counts.workspace + counts.user + counts.extension; +} + +export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { + const [workspaceItems, userItems, extensionItems] = await Promise.all([ + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + return { + workspace: workspaceItems.length, + user: userItems.length, + extension: extensionItems.length, + }; +} + +export async function getSkillSourceCounts(promptsService: IPromptsService): Promise { + const skills = await promptsService.findAgentSkills(CancellationToken.None); + if (!skills || skills.length === 0) { + return { workspace: 0, user: 0, extension: 0 }; + } + return { + workspace: skills.filter(s => s.storage === PromptsStorage.local).length, + user: skills.filter(s => s.storage === PromptsStorage.user).length, + extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + }; +} + +export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { + const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ + getPromptSourceCounts(promptsService, PromptsType.agent), + getSkillSourceCounts(promptsService), + getPromptSourceCounts(promptsService, PromptsType.instructions), + getPromptSourceCounts(promptsService, PromptsType.prompt), + getPromptSourceCounts(promptsService, PromptsType.hook), + ]); + + return getSourceCountsTotal(agentCounts) + + getSourceCountsTotal(skillCounts) + + getSourceCountsTotal(instructionCounts) + + getSourceCountsTotal(promptCounts) + + getSourceCountsTotal(hookCounts) + + mcpService.servers.get().length; +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts new file mode 100644 index 0000000000000..2d14ab2f63234 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { Menus } from '../../../browser/menus.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; + +interface ICustomizationItemConfig { + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly section: AICustomizationManagementSection; + readonly getSourceCounts?: (promptsService: IPromptsService) => Promise; + readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; +} + +const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ + { + id: 'sessions.customization.agents', + label: localize('agents', "Agents"), + icon: agentIcon, + section: AICustomizationManagementSection.Agents, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.agent), + }, + { + id: 'sessions.customization.skills', + label: localize('skills', "Skills"), + icon: skillIcon, + section: AICustomizationManagementSection.Skills, + getSourceCounts: (ps) => getSkillSourceCounts(ps), + }, + { + id: 'sessions.customization.instructions', + label: localize('instructions', "Instructions"), + icon: instructionsIcon, + section: AICustomizationManagementSection.Instructions, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.instructions), + }, + { + id: 'sessions.customization.prompts', + label: localize('prompts', "Prompts"), + icon: promptIcon, + section: AICustomizationManagementSection.Prompts, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.prompt), + }, + { + id: 'sessions.customization.hooks', + label: localize('hooks', "Hooks"), + icon: hookIcon, + section: AICustomizationManagementSection.Hooks, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.hook), + }, + { + id: 'sessions.customization.mcpServers', + label: localize('mcpServers', "MCP Servers"), + icon: Codicon.server, + section: AICustomizationManagementSection.McpServers, + getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), + }, + { + id: 'sessions.customization.models', + label: localize('models', "Models"), + icon: Codicon.vm, + section: AICustomizationManagementSection.Models, + getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), + }, +]; + +/** + * Custom ActionViewItem for each customization link in the toolbar. + * Renders icon + label + source count badges, matching the sidebar footer style. + */ +class CustomizationLinkViewItem extends ActionViewItem { + + private readonly _viewItemDisposables: DisposableStore; + private _button: Button | undefined; + private _countContainer: HTMLElement | undefined; + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _config: ICustomizationItemConfig, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IMcpService private readonly _mcpService: IMcpService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + this._viewItemDisposables = this._register(new DisposableStore()); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('customization-link-widget', 'sidebar-action'); + + // Button (left) - uses supportIcons to render codicon in label + const buttonContainer = append(container, $('.customization-link-button-container')); + this._button = this._viewItemDisposables.add(new Button(buttonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this._button.element.classList.add('customization-link-button', 'sidebar-action-button'); + this._button.label = `$(${this._config.icon.id}) ${this._config.label}`; + + this._viewItemDisposables.add(this._button.onDidClick(() => { + this._action.run(); + })); + + // Count container (inside button, floating right) + this._countContainer = append(this._button.element, $('span.customization-link-counts')); + + // Subscribe to changes + this._viewItemDisposables.add(this._promptsService.onDidChangeCustomAgents(() => this._updateCounts())); + this._viewItemDisposables.add(this._promptsService.onDidChangeSlashCommands(() => this._updateCounts())); + this._viewItemDisposables.add(this._languageModelsService.onDidChangeLanguageModels(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._mcpService.servers.read(reader); + this._updateCounts(); + })); + this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._activeSessionService.activeSession.read(reader); + this._updateCounts(); + })); + + // Initial count + this._updateCounts(); + } + + private async _updateCounts(): Promise { + if (!this._countContainer) { + return; + } + + if (this._config.getSourceCounts) { + const counts = await this._config.getSourceCounts(this._promptsService); + this._renderSourceCounts(this._countContainer, counts); + } else if (this._config.getCount) { + const count = await this._config.getCount(this._languageModelsService, this._mcpService); + this._renderSimpleCount(this._countContainer, count); + } + } + + private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { + container.textContent = ''; + const total = getSourceCountsTotal(counts); + container.classList.toggle('hidden', total === 0); + if (total === 0) { + return; + } + + const sources: { count: number; icon: ThemeIcon; title: string }[] = [ + { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, + { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, + { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, + ]; + + for (const source of sources) { + if (source.count === 0) { + continue; + } + const badge = append(container, $('span.source-count-badge')); + badge.title = source.title; + const icon = append(badge, $('span.source-count-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${source.count}`; + } + } + + private _renderSimpleCount(container: HTMLElement, count: number): void { + container.textContent = ''; + container.classList.toggle('hidden', count === 0); + if (count > 0) { + const badge = append(container, $('span.source-count-badge')); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${count}`; + } + } +} + +// --- Register actions and view items --- // + +class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + // Register the custom ActionViewItem for this action + this._register(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + }, undefined)); + + // Register the action with menu item + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: config.id, + title: localize2('customizationAction', '{0}', config.label), + menu: { + id: Menus.SidebarCustomizations, + group: 'navigation', + order: index + 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(config.section); + } + } + })); + } + } +} + +registerWorkbenchContribution2(CustomizationsToolbarContribution.ID, CustomizationsToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css new file mode 100644 index 0000000000000..d671775dbd57c --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-viewpane { + + /* AI Customization section - pinned to bottom */ + .ai-customization-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + padding: 6px; + } + + /* Make the toolbar, action bar, and items fill full width and stack vertically */ + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { + width: 100%; + } + + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; + } + + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; + } + + .ai-customization-toolbar .customization-link-widget { + width: 100%; + } + + /* Customization header - clickable for collapse */ + .ai-customization-toolbar .ai-customization-header { + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; + } + + .ai-customization-toolbar .ai-customization-header:not(.collapsed) { + margin-bottom: 4px; + } + + .ai-customization-toolbar .ai-customization-chevron { + flex-shrink: 0; + opacity: 0; + } + + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { + opacity: 0.7; + } + + .ai-customization-toolbar .ai-customization-header-total { + display: none; + opacity: 0.7; + font-size: 11px; + line-height: 1; + } + + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { + display: inline; + } + + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { + display: none; + } + + /* Button container - fills available space */ + .ai-customization-toolbar .customization-link-button-container { + overflow: hidden; + min-width: 0; + flex: 1; + } + + /* Button needs relative positioning for counts overlay */ + .ai-customization-toolbar .customization-link-button { + position: relative; + } + + /* Counts - floating right inside the button */ + .ai-customization-toolbar .customization-link-counts { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; + } + + .ai-customization-toolbar .customization-link-counts.hidden { + display: none; + } + + .ai-customization-toolbar .source-count-badge { + display: flex; + align-items: center; + gap: 2px; + } + + .ai-customization-toolbar .source-count-icon { + font-size: 12px; + opacity: 0.6; + } + + .ai-customization-toolbar .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + /* Collapsed state */ + .ai-customization-toolbar .ai-customization-toolbar-content { + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + } + + .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { + max-height: 0; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 4b37cbc4323ab..b054dfd8f0d9b 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -13,7 +13,6 @@ } /* Section headers - more prominent than time-based groupings */ - .ai-customization-header, .agent-sessions-header { font-size: 11px; font-weight: 500; @@ -23,130 +22,17 @@ letter-spacing: 0.05em; } - /* Customization header - clickable for collapse */ - .ai-customization-header { - display: flex; - align-items: center; - cursor: pointer; - user-select: none; - margin: 0 6px; - border-radius: 6px; - } - - .ai-customization-header:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-header:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-chevron, .agent-sessions-chevron { flex-shrink: 0; - margin-left: auto; - padding-right: 4px; opacity: 0; transition: opacity 0.1s ease-in-out; } - .ai-customization-header:hover .ai-customization-chevron, - .ai-customization-header:focus .ai-customization-chevron, .agent-sessions-header:hover .agent-sessions-chevron, .agent-sessions-header:focus .agent-sessions-chevron { opacity: 0.7; } - /* AI Customization section - pinned to bottom */ - .ai-customization-shortcuts { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - margin-top: 8px; - padding-top: 4px; - padding-bottom: 8px; - } - - .ai-customization-shortcuts .ai-customization-links { - display: flex; - flex-direction: column; - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-shortcuts .ai-customization-links.collapsed { - max-height: 0; - } - - .ai-customization-shortcuts .ai-customization-link { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--vscode-foreground); - cursor: pointer; - text-decoration: none; - padding: 6px 14px; - margin: 0 6px; - line-height: 22px; - border-radius: 6px; - } - - .ai-customization-shortcuts .ai-customization-link:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-shortcuts .ai-customization-link:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-shortcuts .ai-customization-link .link-icon { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.85; - } - - .ai-customization-shortcuts .ai-customization-link .link-label { - flex: 1; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - margin-left: auto; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts.hidden { - display: none; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - /* Sessions section - fills remaining space above customizations */ .agent-sessions-section { display: flex; @@ -160,8 +46,7 @@ display: flex; align-items: center; gap: 4px; - padding-top: 10px; - padding-right: 12px; + -webkit-user-select: none; user-select: none; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts index bcadacb70d563..d8618d36a1b76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts @@ -3,53 +3,106 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun } from '../../../../base/common/observable.js'; -import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { CHANGES_VIEW_ID, ChangesViewPane } from '../../changesView/browser/changesView.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; + +interface IPendingTurnState { + readonly hadChangesBeforeSend: boolean; + readonly submittedAt: number; +} export class SessionsAuxiliaryBarContribution extends Disposable { static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; - private readonly activeChangesListener = this._register(new MutableDisposable()); - private activeChangesView: ChangesViewPane | null = null; + private readonly pendingTurnStateByResource = new ResourceMap(); constructor( - @IViewsService private readonly viewsService: IViewsService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatService private readonly chatService: IChatService, ) { super(); - this.tryBindToChangesView(); + const activeSessionResourceObs = derivedOpts({ + equalsFn: isEqual, + }, (reader) => { + return this.sessionManagementService.activeSession.map(activeSession => activeSession?.resource).read(reader); + }).recomputeInitiallyAndOnChange(this._store); + + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this.pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: this.hasSessionChanges(chatSessionResource), + submittedAt: Date.now(), + }); + })); + + // When a turn is completed, check if there were changes before the turn and if there are changes after the turn. + // If there were no changes before the turn and there are changes after the turn, show the auxiliary bar. + this._register(autorun((reader) => { + const activeSessionResource = activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return; + } + + const pendingTurnState = this.pendingTurnStateByResource.get(activeSessionResource); + if (!pendingTurnState) { + return; + } + + const activeSession = this.agentSessionsService.getSession(activeSessionResource); + const turnCompleted = !!activeSession?.timing.lastRequestEnded && activeSession.timing.lastRequestEnded >= pendingTurnState.submittedAt; + if (!turnCompleted) { + return; + } + + const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource); + if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { + this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + + this.pendingTurnStateByResource.delete(activeSessionResource); + })); - this._register(this.viewsService.onDidChangeViewVisibility(e => { - if (e.id !== CHANGES_VIEW_ID) { + // When the session is switched, show the auxiliary bar if there are pending changes from the session + this._register(autorun(reader => { + const sessionResource = activeSessionResourceObs.read(reader); + if (!sessionResource) { + this.syncAuxiliaryBarVisibility(false); return; } - this.tryBindToChangesView(); + const hasChanges = this.hasSessionChanges(sessionResource); + this.syncAuxiliaryBarVisibility(hasChanges); })); } - private tryBindToChangesView(): void { - const changesView = this.viewsService.getViewWithId(CHANGES_VIEW_ID); - if (!changesView) { - this.activeChangesView = null; - this.activeChangesListener.clear(); - return; - } + private hasSessionChanges(sessionResource: URI): boolean { + const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; - if (this.activeChangesView === changesView) { - return; + let editingSessionCount = 0; + if (!isBackgroundSession) { + const sessions = this.chatEditingService.editingSessionsObs.read(undefined); + const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + editingSessionCount = editingSession ? editingSession.entries.read(undefined).length : 0; } - this.activeChangesView = changesView; - this.activeChangesListener.value = autorun(reader => { - const hasChanges = changesView.activeSessionHasChanges.read(reader); - this.syncAuxiliaryBarVisibility(hasChanges); - }); + const session = this.agentSessionsService.getSession(sessionResource); + const sessionFilesCount = session?.changes instanceof Array ? session.changes.length : 0; + + return editingSessionCount + sessionFilesCount > 0; } private syncAuxiliaryBarVisibility(hasChanges: boolean): void { @@ -61,4 +114,4 @@ export class SessionsAuxiliaryBarContribution extends Disposable { this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); } -} +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index ab3ba0b5a0809..81f0aa6849afb 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -3,14 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; import './media/sessionsViewPane.css'; import * as DOM from '../../../../base/browser/dom.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -23,50 +24,27 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; -import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; -import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; -import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; -import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); - -/** - * Per-source breakdown of item counts. - */ -interface ISourceCounts { - readonly workspace: number; - readonly user: number; - readonly extension: number; -} - -interface IShortcutItem { - readonly label: string; - readonly icon: ThemeIcon; - readonly action: () => Promise; - readonly getSourceCounts?: () => Promise; - /** For items without per-source breakdown (MCP, Models). */ - readonly getCount?: () => Promise; - countContainer?: HTMLElement; -} +const SessionsViewHeaderMenu = new MenuId('AgentSessionsViewHeaderMenu'); const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; @@ -76,7 +54,6 @@ export class AgenticSessionsViewPane extends ViewPane { private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; private aiCustomizationContainer: HTMLElement | undefined; - private readonly shortcuts: IShortcutItem[] = []; constructor( options: IViewPaneOptions, @@ -90,44 +67,13 @@ export class AgenticSessionsViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @ICommandService commandService: ICommandService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IStorageService private readonly storageService: IStorageService, @IPromptsService private readonly promptsService: IPromptsService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IMcpService private readonly mcpService: IMcpService, - @IStorageService private readonly storageService: IStorageService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - - // Initialize shortcuts - this.shortcuts = [ - { label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) }, - { label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() }, - { label: localize('instructions', "Instructions"), icon: instructionsIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Instructions), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.instructions) }, - { label: localize('prompts', "Prompts"), icon: promptIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Prompts), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.prompt) }, - { label: localize('hooks', "Hooks"), icon: hookIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Hooks), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.hook) }, - { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, action: () => this.openAICustomizationSection(AICustomizationManagementSection.McpServers), getCount: () => Promise.resolve(this.mcpService.servers.get().length) }, - { label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) }, - ]; - - // Listen to changes to update counts - this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts())); - this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - this.updateCounts(); - })); - - // Listen to workspace folder changes to update counts - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts())); - this._register(autorun(reader => { - this.activeSessionService.activeSession.read(reader); - this.updateCounts(); - })); - } protected override renderBody(parent: HTMLElement): void { @@ -142,15 +88,18 @@ export class AgenticSessionsViewPane extends ViewPane { private createControls(parent: HTMLElement): void { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); - // Sessions Filter (actions go to view title bar via menu registration) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, - groupResults: () => AgentSessionsGrouping.Date - })); - // Sessions section (top, fills available space) const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); + // Sessions header with title and toolbar actions + const sessionsHeader = DOM.append(sessionsSection, $('.agent-sessions-header')); + const headerText = DOM.append(sessionsHeader, $('span')); + headerText.textContent = localize('sessions', "SESSIONS"); + const headerToolbarContainer = DOM.append(sessionsHeader, $('.agent-sessions-header-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, headerToolbarContainer, SessionsViewHeaderMenu, { + menuOptions: { shouldForwardArgs: true }, + })); + // Sessions content container const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); @@ -167,12 +116,19 @@ export class AgenticSessionsViewPane extends ViewPane { keybindingHint.textContent = keybinding.getLabel() ?? ''; } + // Sessions filter: contributes filter actions via SessionsViewFilterSubMenu; actions are rendered in the sessions header toolbar (SessionsViewHeaderMenu) + const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + filterMenuId: SessionsViewFilterSubMenu, + groupResults: () => AgentSessionsGrouping.Date + })); + // Sessions Control this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { source: 'agentSessionsViewPane', filter: sessionsFilter, overrideStyles: this.getLocationBasedColors().listOverrideStyles, + disableHover: true, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, @@ -199,8 +155,8 @@ export class AgenticSessionsViewPane extends ViewPane { } })); - // AI Customization shortcuts (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts')); + // AI Customization toolbar (bottom, fixed height) + this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); this.createAICustomizationShortcuts(this.aiCustomizationContainer); } @@ -215,177 +171,86 @@ export class AgenticSessionsViewPane extends ViewPane { // Get initial collapsed state const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + container.classList.add('ai-customization-toolbar'); + if (isCollapsed) { + container.classList.add('collapsed'); + } + // Header (clickable to toggle) const header = DOM.append(container, $('.ai-customization-header')); - header.tabIndex = 0; - header.setAttribute('role', 'button'); - header.setAttribute('aria-expanded', String(!isCollapsed)); - - // Header text - const headerText = DOM.append(header, $('span')); - headerText.textContent = localize('customizations', "CUSTOMIZATIONS"); + header.classList.toggle('collapsed', isCollapsed); + + const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); + const headerButton = this._register(new Button(headerButtonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); + headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); + headerButton.label = localize('customizations', "CUSTOMIZATIONS"); - // Chevron icon (right-aligned, shown on hover) - const chevron = DOM.append(header, $('.ai-customization-chevron')); + const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); + const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); + const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - // Links container - const linksContainer = DOM.append(container, $('.ai-customization-links')); - if (isCollapsed) { - linksContainer.classList.add('collapsed'); - } + // Toolbar container + const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { + const requestId = ++updateCountRequestId; + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService); + if (requestId !== updateCountRequestId) { + return; + } + + headerTotalCount.classList.toggle('hidden', totalCount === 0); + headerTotalCount.textContent = `${totalCount}`; + }; + + this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); + this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + updateHeaderTotalCount(); + })); + updateHeaderTotalCount(); // Toggle collapse on header click const toggleCollapse = () => { - const collapsed = linksContainer.classList.toggle('collapsed'); + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - header.setAttribute('aria-expanded', String(!collapsed)); + headerButton.element.setAttribute('aria-expanded', String(!collapsed)); chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); // Re-layout after the transition so sessions control gets the right height const onTransitionEnd = () => { - linksContainer.removeEventListener('transitionend', onTransitionEnd); + toolbarContainer.removeEventListener('transitionend', onTransitionEnd); if (this.viewPaneContainer) { const { offsetHeight, offsetWidth } = this.viewPaneContainer; this.layoutBody(offsetHeight, offsetWidth); } }; - linksContainer.addEventListener('transitionend', onTransitionEnd); - }; - - this._register(DOM.addDisposableListener(header, 'click', toggleCollapse)); - this._register(DOM.addDisposableListener(header, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleCollapse(); - } - })); - - for (const shortcut of this.shortcuts) { - const link = DOM.append(linksContainer, $('a.ai-customization-link')); - link.tabIndex = 0; - link.setAttribute('role', 'button'); - link.setAttribute('aria-label', shortcut.label); - - // Icon - const iconElement = DOM.append(link, $('.link-icon')); - iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon)); - - // Label - const labelElement = DOM.append(link, $('.link-label')); - labelElement.textContent = shortcut.label; - - // Count container (right-aligned, shows per-source badges) - const countContainer = DOM.append(link, $('.link-counts')); - shortcut.countContainer = countContainer; - - this._register(DOM.addDisposableListener(link, 'click', (e) => { - DOM.EventHelper.stop(e); - shortcut.action(); - })); - - this._register(DOM.addDisposableListener(link, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - shortcut.action(); - } - })); - } - - // Load initial counts - this.updateCounts(); - } - - private async updateCounts(): Promise { - for (const shortcut of this.shortcuts) { - if (!shortcut.countContainer) { - continue; - } - - if (shortcut.getSourceCounts) { - const counts = await shortcut.getSourceCounts(); - this.renderSourceCounts(shortcut.countContainer, counts); - } else if (shortcut.getCount) { - const count = await shortcut.getCount(); - this.renderSimpleCount(shortcut.countContainer, count); - } - } - } - - private renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { - DOM.clearNode(container); - const total = counts.workspace + counts.user + counts.extension; - container.classList.toggle('hidden', total === 0); - if (total === 0) { - return; - } - - const sources: { count: number; icon: ThemeIcon; title: string }[] = [ - { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, - { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, - ]; - - for (const source of sources) { - if (source.count === 0) { - continue; - } - const badge = DOM.append(container, $('.source-count-badge')); - badge.title = source.title; - const icon = DOM.append(badge, $('.source-count-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${source.count}`; - } - } - - private renderSimpleCount(container: HTMLElement, count: number): void { - DOM.clearNode(container); - container.classList.toggle('hidden', count === 0); - if (count > 0) { - const badge = DOM.append(container, $('.source-count-badge')); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${count}`; - } - } - - private async getPromptSourceCounts(promptType: PromptsType): Promise { - const [workspaceItems, userItems, extensionItems] = await Promise.all([ - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), - ]); - - return { - workspace: workspaceItems.length, - user: userItems.length, - extension: extensionItems.length, + toolbarContainer.addEventListener('transitionend', onTransitionEnd); }; - } - - private async getSkillSourceCounts(): Promise { - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - if (!skills || skills.length === 0) { - return { workspace: 0, user: 0, extension: 0 }; - } - - const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local); - - return { - workspace: workspaceSkills.length, - user: skills.filter(s => s.storage === PromptsStorage.user).length, - extension: skills.filter(s => s.storage === PromptsStorage.extension).length, - }; - } - private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise { - const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); - - if (editor instanceof AICustomizationManagementEditor) { - editor.selectSectionById(sectionId); - } + this._register(headerButton.onDidClick(() => toggleCollapse())); } private getSessionHoverPosition(): HoverPosition { @@ -432,13 +297,12 @@ KeybindingsRegistry.registerKeybindingRule({ primary: KeyMod.CtrlCmd | KeyCode.KeyN, }); -MenuRegistry.appendMenuItem(MenuId.ViewTitle, { +MenuRegistry.appendMenuItem(SessionsViewHeaderMenu, { submenu: SessionsViewFilterSubMenu, title: localize2('filterAgentSessions', "Filter Agent Sessions"), group: 'navigation', order: 3, icon: Codicon.filter, - when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { @@ -448,10 +312,9 @@ registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { title: localize2('refresh', "Refresh Agent Sessions"), icon: Codicon.refresh, menu: [{ - id: MenuId.ViewTitle, + id: SessionsViewHeaderMenu, group: 'navigation', order: 1, - when: ContextKeyExpr.equals('view', SessionsViewId), }], }); } @@ -470,10 +333,9 @@ registerAction2(class FindAgentSessionInViewerAction extends Action2 { title: localize2('find', "Find Agent Session"), icon: Codicon.search, menu: [{ - id: MenuId.ViewTitle, + id: SessionsViewHeaderMenu, group: 'navigation', order: 2, - when: ContextKeyExpr.equals('view', SessionsViewId), }] }); } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index b9a4d26199cf9..b81b0de984e9c 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -191,6 +191,7 @@ import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contri import './contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 74800fefdee70..994c8c8010ffc 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -197,9 +197,22 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const contributedSession = chatSession?.contributedChatSession; let chatSessionContext: IChatSessionContextDto | undefined; if (contributedSession) { + let chatSessionResource = contributedSession.chatSessionResource; + let isUntitled = contributedSession.isUntitled; + + // For new untitled sessions, invoke the controller's newChatSessionItemHandler + // to let the extension create a proper session item before the first request. + if (isUntitled) { + const newItem = await this._chatSessionService.createNewChatSessionItem(contributedSession.chatSessionType, request, token); + if (newItem) { + chatSessionResource = newItem.resource; + isUntitled = false; + } + } + chatSessionContext = { - chatSessionResource: contributedSession.chatSessionResource, - isUntitled: contributedSession.isUntitled, + chatSessionResource, + isUntitled, initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ optionId: o.optionId, value: typeof o.value === 'string' ? o.value : o.value.id, diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 87399cad640c6..cd45ead9ff57d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -347,6 +347,21 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes return this._proxy.$refreshChatSessionItems(this._handle, token); } + async newChatSessionItem(request: IChatAgentRequest, token: CancellationToken): Promise { + const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, request, token), token); + if (!dto) { + return undefined; + } + const item: IChatSessionItem = { + ...dto, + resource: URI.revive(dto.resource), + changes: revive(dto.changes), + }; + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); + return item; + } + acceptChange(change: { readonly addedOrUpdated: readonly IChatSessionItem[]; readonly removed: readonly URI[] }): void { for (const item of change.addedOrUpdated) { this._items.set(item.resource, item); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2025e31c43636..14685130cb2db 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3442,6 +3442,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { export interface ExtHostChatSessionsShape { $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; + $newChatSessionItem(controllerHandle: number, request: Dto, token: CancellationToken): Promise | undefined>; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index e62fe6439249e..da032619287c2 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -379,6 +379,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('Not implemented for providers'); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, + newChatSessionItemHandler: undefined, dispose: () => { disposables.dispose(); }, @@ -422,6 +423,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const disposables = new DisposableStore(); let isDisposed = false; + let newChatSessionItemHandler: vscode.ChatSessionItemController['newChatSessionItemHandler']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); @@ -451,6 +453,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); return item; }, + get newChatSessionItemHandler() { return newChatSessionItemHandler; }, + set newChatSessionItemHandler(handler: vscode.ChatSessionItemController['newChatSessionItemHandler']) { newChatSessionItemHandler = handler; }, dispose: () => { isDisposed = true; disposables.dispose(); @@ -768,6 +772,29 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio await controllerData.controller.refreshHandler(token); } + async $newChatSessionItem(handle: number, request: IChatAgentRequest, token: CancellationToken): Promise | undefined> { + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData) { + this._logService.warn(`No controller found for handle ${handle}`); + return undefined; + } + + const handler = controllerData.controller.newChatSessionItemHandler; + if (!handler) { + return undefined; + } + + const model = await this.getModelForRequest(request, controllerData.extension); + const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, model, [], new Map(), controllerData.extension, this._logService); + + const item = await handler({ request: chatRequest }, token); + if (!item) { + return undefined; + } + + return typeConvert.ChatSessionItem.from(item); + } + $onDidChangeChatSessionItemState(controllerHandle: number, sessionResourceComponents: UriComponents, archived: boolean): void { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 2f846d3c7fb27..78018645d7354 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -62,6 +62,7 @@ suite('ObservableChatSession', function () { $disposeChatSessionContent: sinon.stub(), $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), + $newChatSessionItem: sinon.stub().resolves(undefined), }; }); @@ -359,6 +360,7 @@ suite('MainThreadChatSessions', function () { $disposeChatSessionContent: sinon.stub(), $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), + $newChatSessionItem: sinon.stub().resolves(undefined), }; const extHostContext = new class implements IExtHostContext { diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 0cd00519f0eb1..6cad0431d35bf 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -13,6 +13,7 @@ import { localize } from '../../../../../nls.js'; import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -29,6 +30,7 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation readonly when = ChatContextKeys.inChatSession; getProvider(accessor: ServicesAccessor) { const widgetService = accessor.get(IChatWidgetService); + const storageService = accessor.get(IStorageService); const widget = widgetService.lastFocusedWidget; if (!widget) { return; @@ -53,13 +55,20 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation return; } - return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused); + return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused, storageService); } } type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData; type ResultDetails = Array | IToolResultInputOutputDetails | IToolResultOutputDetails | IToolResultOutputDetailsSerialized; +export const CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY = 'chat.accessibleView.includeThinking'; +const CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_DEFAULT = true; + +export function isThinkingContentIncludedInAccessibleView(storageService: IStorageService): boolean { + return storageService.getBoolean(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, StorageScope.PROFILE, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_DEFAULT); +} + function isOutputDetailsSerialized(obj: unknown): obj is IToolResultOutputDetailsSerialized { return typeof obj === 'object' && obj !== null && 'output' in obj && typeof (obj as IToolResultOutputDetailsSerialized).output === 'object' && @@ -212,14 +221,19 @@ export function getToolInvocationA11yDescription( class ChatResponseAccessibleProvider extends Disposable implements IAccessibleViewContentProvider { private _focusedItem!: ChatTreeItem; private readonly _focusedItemDisposables = this._register(new DisposableStore()); + private readonly _storageDisposables = this._register(new DisposableStore()); private readonly _onDidChangeContent = this._register(new Emitter()); readonly onDidChangeContent: Event = this._onDidChangeContent.event; constructor( private readonly _widget: IChatWidget, item: ChatTreeItem, - private readonly _wasOpenedFromInput: boolean + private readonly _wasOpenedFromInput: boolean, + private readonly _storageService: IStorageService ) { super(); + this._storageDisposables.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, this._storageDisposables)(() => { + this._onDidChangeContent.fire(); + })); this._setFocusedItem(item); } @@ -258,6 +272,9 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi for (const part of item.response.value) { switch (part.kind) { case 'thinking': { + if (!this._shouldIncludeThinkingContent()) { + break; + } const thinkingValue = Array.isArray(part.value) ? part.value.join('') : (part.value || ''); const trimmed = thinkingValue.trim(); if (trimmed) { @@ -360,6 +377,10 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi return normalized.join('\n'); } + private _shouldIncludeThinkingContent(): boolean { + return isThinkingContentIncludedInAccessibleView(this._storageService); + } + onClose(): void { this._widget.reveal(this._focusedItem); if (this._wasOpenedFromInput) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index 7be04e1760c8d..99bad32ce49b2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -9,13 +9,18 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; +import { AccessibleViewProviderId } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; +import { accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../../contrib/accessibility/browser/accessibilityConfiguration.js'; +import { CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, isThinkingContentIncludedInAccessibleView } from '../accessibility/chatResponseAccessibleView.js'; export const ACTION_ID_FOCUS_CHAT_CONFIRMATION = 'workbench.action.chat.focusConfirmation'; +export const ACTION_ID_TOGGLE_THINKING_CONTENT_ACCESSIBLE_VIEW = 'workbench.action.chat.toggleThinkingContentAccessibleView'; class AnnounceChatConfirmationAction extends Action2 { constructor() { @@ -73,6 +78,35 @@ class AnnounceChatConfirmationAction extends Action2 { } } +class ToggleThinkingContentAccessibleViewAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_TOGGLE_THINKING_CONTENT_ACCESSIBLE_VIEW, + title: { value: localize('toggleThinkingContentAccessibleView', 'Toggle Thinking Content in Accessible View'), original: 'Toggle Thinking Content in Accessible View' }, + category: { value: localize('chat.category', 'Chat'), original: 'Chat' }, + precondition: ChatContextKeys.enabled, + f1: true, + keybinding: { + primary: KeyMod.Alt | KeyCode.KeyT, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(accessibleViewIsShown, ContextKeyExpr.equals(accessibleViewCurrentProviderId.key, AccessibleViewProviderId.PanelChat)) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const storageService = accessor.get(IStorageService); + const includeThinking = isThinkingContentIncludedInAccessibleView(storageService); + const updatedValue = !includeThinking; + storageService.store(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, updatedValue, StorageScope.PROFILE, StorageTarget.USER); + alert(updatedValue + ? localize('thinkingContentShown', 'Thinking content will be included in the accessible view.') + : localize('thinkingContentHidden', 'Thinking content will be hidden from the accessible view.') + ); + } +} + export function registerChatAccessibilityActions(): void { registerAction2(AnnounceChatConfirmationAction); + registerAction2(ToggleThinkingContentAccessibleViewAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 3c23b729b2a5d..bfff1a5f0309f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -74,7 +74,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.attachments.removal', 'To remove attached contexts, focus an attachment and press Delete or Backspace.')); - content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order.', '')); + content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); + content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); content.push(localize('workbench.action.chat.focusLastFocusedItem', 'To return to the last chat response you focused, invoke the Focus Last Focused Chat Response command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'lastFocused'))); content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'input'))); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 9ec8cb095ebb3..1fac629812d06 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -20,12 +20,13 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; @@ -874,14 +875,31 @@ export class CancelAction extends Action2 { run(accessor: ServicesAccessor, ...args: unknown[]) { const context = args[0] as IChatExecuteActionContext | undefined; const widgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + const telemetryService = accessor.get(ITelemetryService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { + telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'cancelAction', + reason: 'noWidget', + requestInProgress: 'unknown', + pendingRequests: 0, + }); + logService.info('ChatCancelAction#run: No focused chat widget was found'); return; } const chatService = accessor.get(IChatService); if (widget.viewModel) { chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource); + } else { + telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'cancelAction', + reason: 'noViewModel', + requestInProgress: 'unknown', + pendingRequests: 0, + }); + logService.info('ChatCancelAction#run: Canceled chat widget has no view model'); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index d74a3f6a63253..1ae1bf5989fb6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -40,6 +40,7 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption readonly overrideStyles: IStyleOverride; readonly filter: IAgentSessionsFilter; readonly source: string; + readonly disableHover?: boolean; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index daabea59fff01..562d91aeabcef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -76,6 +76,7 @@ interface IAgentSessionItemTemplate { } export interface IAgentSessionRendererOptions { + readonly disableHover?: boolean; getHoverPosition(): HoverPosition; } @@ -364,6 +365,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } private renderHover(session: ITreeNode, template: IAgentSessionItemTemplate): void { + if (this.options.disableHover) { + return; + } + if (!isSessionInProgressStatus(session.element.status) && session.element.isRead()) { return; // the hover is complex and large, for now limit it to in-progress sessions only } @@ -758,19 +763,13 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map ({ - section, - label: localize('agentSessions.sectionWithCount', "{0} ({1})", AgentSessionSectionLabels[section], sessions.length), - sessions - }); - return new Map([ - [AgentSessionSection.InProgress, sectionWithCount(AgentSessionSection.InProgress, inProgressSessions)], - [AgentSessionSection.Today, sectionWithCount(AgentSessionSection.Today, todaySessions)], - [AgentSessionSection.Yesterday, sectionWithCount(AgentSessionSection.Yesterday, yesterdaySessions)], - [AgentSessionSection.Week, sectionWithCount(AgentSessionSection.Week, weekSessions)], - [AgentSessionSection.Older, sectionWithCount(AgentSessionSection.Older, olderSessions)], - [AgentSessionSection.Archived, sectionWithCount(AgentSessionSection.Archived, archivedSessions)], + [AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }], + [AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }], + [AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }], + [AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }], + [AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }], + [AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }], ]); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts index cf41a1821b44c..566242366618b 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts @@ -294,6 +294,7 @@ export class ChatImplicitContexts extends Disposable { private _values: DisposableMap = this._register(new DisposableMap()); private readonly _valuesDisposables: DisposableStore = this._register(new DisposableStore()); + private _enabled = false; setValues(values: ImplicitContextWithSelection[]): void { this._valuesDisposables.clear(); @@ -308,6 +309,7 @@ export class ChatImplicitContexts extends Disposable { for (const value of definedValues) { const implicitContext = new ChatImplicitContext(); implicitContext.setValue(value.value, value.isSelection); + implicitContext.enabled = this._enabled; const disposableStore = new DisposableStore(); disposableStore.add(implicitContext.onDidChangeValue(() => { this._onDidChangeValue.fire(); @@ -327,6 +329,7 @@ export class ChatImplicitContexts extends Disposable { } setEnabled(enabled: boolean): void { + this._enabled = enabled; this.values.forEach((v) => v.enabled = enabled); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index cd8b77b0a0c85..5f8f72a64e3c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -284,7 +284,7 @@ configurationRegistry.registerConfiguration({ 'chat.tips.enabled': { type: 'boolean', scope: ConfigurationScope.APPLICATION, - description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. This is an experimental feature."), + description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. New tips are added frequently, so this is a helpful way to stay up to date with the latest features."), default: false, tags: ['experimental'], experiment: { @@ -1098,6 +1098,30 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.ThinkingPhrases]: { + type: 'object', + default: { + mode: 'append', + phrases: [] + }, + properties: { + mode: { + type: 'string', + enum: ['replace', 'append'], + default: 'append', + description: nls.localize('chat.agent.thinking.phrases.mode', "'replace' replaces all default phrases entirely; 'append' adds your phrases to all default categories.") + }, + phrases: { + type: 'array', + items: { type: 'string' }, + default: [], + description: nls.localize('chat.agent.thinking.phrases.phrases', "Custom loading messages to show during thinking, terminal, and tool operations.") + } + }, + additionalProperties: false, + markdownDescription: nls.localize('chat.agent.thinking.phrases', "Customize the loading messages shown during agent operations. Use `\"mode\": \"replace\"` to use only your phrases, or `\"mode\": \"append\"` to add them to the defaults."), + tags: ['experimental'], + }, [ChatConfiguration.AutoExpandToolFailures]: { type: 'boolean', default: true, diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index bb70941b5ac4d..aab01ee1d492a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -29,7 +29,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { IExtensionService, isProposedApiEnabled } from '../../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../../services/extensions/common/extensionsRegistry.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; -import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentRequest, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; @@ -992,6 +992,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return description ? renderAsPlaintext(description, { useLinkFormatter: true }) : ''; } + async createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise { + const controllerData = this._itemControllers.get(chatSessionType); + if (!controllerData) { + return undefined; + } + + await controllerData.initialRefresh; + return controllerData.controller.newChatSessionItem?.(request, token); + } + public async getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise { { const existingSessionData = this._sessions.get(sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index ed6bad1e99315..14541ee5f4b1d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; -import { timeout } from '../../../../../base/common/async.js'; +import { raceTimeout, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; @@ -50,15 +50,17 @@ import { ChatSetupController } from './chatSetupController.js'; import { ChatSetupAnonymous, ChatSetupStep, IChatSetupResult } from './chatSetup.js'; import { ChatSetup } from './chatSetupRunner.js'; import { chatViewsWelcomeRegistry } from '../viewsWelcome/chatViewsWelcome.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } }, outputChannelId: product.defaultChatAgent?.chatExtensionOutputId ?? '', + outputExtensionStateCommand: product.defaultChatAgent?.chatExtensionOutputExtensionStateCommand ?? '', }; const ToolsAgentContextKey = ContextKeyExpr.and( @@ -175,6 +177,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private static readonly TRUST_NEEDED_MESSAGE = new MarkdownString(localize('trustNeeded', "You need to trust this workspace to use Chat.")); private static readonly CHAT_RETRY_COMMAND_ID = 'workbench.action.chat.retrySetup'; + private static readonly CHAT_SHOW_OUTPUT_COMMAND_ID = 'workbench.action.chat.showOutput'; private readonly _onUnresolvableError = this._register(new Emitter()); readonly onUnresolvableError = this._onUnresolvableError.event; @@ -193,6 +196,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @IViewsService private readonly viewsService: IViewsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IOutputService private readonly outputService: IOutputService, ) { super(); @@ -211,6 +215,27 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { hostService.reload(); })); + + // Show output command: execute extension state command if available, then show output channel + this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_SHOW_OUTPUT_COMMAND_ID, async (accessor) => { + const commandService = accessor.get(ICommandService); + + if (defaultChat.outputExtensionStateCommand) { + // Command invocation may fail or is blocked by the extension activating + // so we just don't wait and timeout after a certain time, logging the error if it fails or times out. + raceTimeout( + commandService.executeCommand(defaultChat.outputExtensionStateCommand), + 5000, + () => this.logService.info('[chat setup] Timed out executing extension state command') + ).then(undefined, error => { + this.logService.info('[chat setup] Failed to execute extension state command', error); + }); + } + + if (defaultChat.outputChannelId) { + await commandService.executeCommand(`workbench.action.output.show.${defaultChat.outputChannelId}`); + } + })); } async invoke(request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void): Promise { @@ -465,14 +490,24 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { content: new MarkdownString(warningMessage) }); - progress({ - kind: 'command', - command: { - id: SetupAgent.CHAT_RETRY_COMMAND_ID, - title: localize('retryChat', "Restart"), - arguments: [requestModel.session.sessionResource] - } - }); + if (defaultChat.outputChannelId && this.outputService.getChannelDescriptor(defaultChat.outputChannelId)) { + progress({ + kind: 'command', + command: { + id: SetupAgent.CHAT_SHOW_OUTPUT_COMMAND_ID, + title: localize('showCopilotChatDetails', "Show Details") + } + }); + } else { + progress({ + kind: 'command', + command: { + id: SetupAgent.CHAT_RETRY_COMMAND_ID, + title: localize('retryChat', "Restart"), + arguments: [requestModel.session.sessionResource] + } + }); + } // This means Chat is unhealthy and we cannot retry the // request. Signal this to the outside via an event. diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 3a5d17812a83c..2e6c540a88fcb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -21,6 +21,9 @@ import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -67,7 +70,7 @@ export interface IChatTipService { /** * Dismisses the current tip and allows a new one to be picked for the same request. - * The dismissed tip will not be shown again in this profile. + * The dismissed tip will not be shown again for this user on this application installation. */ dismissTip(): void; @@ -84,15 +87,13 @@ export interface IChatTipService { /** * Navigates to the next tip in the catalog without permanently dismissing the current one. - * @param contextKeyService The context key service to evaluate tip eligibility. */ - navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined; + navigateToNextTip(): IChatTip | undefined; /** * Navigates to the previous tip in the catalog without permanently dismissing the current one. - * @param contextKeyService The context key service to evaluate tip eligibility. */ - navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; + navigateToPreviousTip(): IChatTip | undefined; /** * Clears all dismissed tips so they can be shown again. @@ -155,6 +156,26 @@ const TIP_CATALOG: ITipDefinition[] = [ enabledCommands: ['workbench.action.chat.openModelPicker'], onlyWhenModelIds: ['gpt-4.1'], }, + { + id: 'tip.createSlashCommands', + message: localize( + 'tip.createSlashCommands', + "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction), [/create-prompt](command:workbench.action.chat.generatePrompt), [/create-agent](command:workbench.action.chat.generateAgent), or [/create-skill](command:workbench.action.chat.generateSkill) to generate reusable agent customization files." + ), + when: ChatContextKeys.hasUsedCreateSlashCommands.negate(), + enabledCommands: [ + 'workbench.action.chat.generateInstruction', + 'workbench.action.chat.generatePrompt', + 'workbench.action.chat.generateAgent', + 'workbench.action.chat.generateSkill', + ], + excludeWhenCommandsExecuted: [ + 'workbench.action.chat.generateInstruction', + 'workbench.action.chat.generatePrompt', + 'workbench.action.chat.generateAgent', + 'workbench.action.chat.generateSkill', + ], + }, { id: 'tip.agentMode', message: localize('tip.agentMode', "Tip: Try [Agents](command:workbench.action.chat.openEditSession) to make edits across your project and run commands."), @@ -240,17 +261,6 @@ const TIP_CATALOG: ITipDefinition[] = [ when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['runSubagent'], }, - { - id: 'tip.contextUsage', - message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are used and what's consuming them."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ChatContextKeys.contextUsageHasBeenOpened.negate(), - ChatContextKeys.chatSessionIsEmpty.negate(), - ), - enabledCommands: ['workbench.action.chat.showContextUsage'], - excludeWhenCommandsExecuted: ['workbench.action.chat.showContextUsage'], - }, { id: 'tip.sendToNewChat', message: localize('tip.sendToNewChat', "Tip: Use [Send to New Chat](command:workbench.action.chat.sendToNewChat) to start a new conversation with a clean context window."), @@ -261,8 +271,8 @@ const TIP_CATALOG: ITipDefinition[] = [ ]; /** - * Tracks workspace-level signals that determine whether certain tips should be - * excluded. Persists state to workspace storage and disposes listeners once all + * Tracks user-level signals that determine whether certain tips should be + * excluded. Persists state to application storage and disposes listeners once all * signals of interest have been observed. */ export class TipEligibilityTracker extends Disposable { @@ -306,13 +316,13 @@ export class TipEligibilityTracker extends Disposable { // --- Restore persisted state ------------------------------------------- - const storedCmds = this._storageService.get(TipEligibilityTracker._COMMANDS_STORAGE_KEY, StorageScope.WORKSPACE); + const storedCmds = this._readApplicationWithProfileFallback(TipEligibilityTracker._COMMANDS_STORAGE_KEY); this._executedCommands = new Set(storedCmds ? JSON.parse(storedCmds) : []); - const storedModes = this._storageService.get(TipEligibilityTracker._MODES_STORAGE_KEY, StorageScope.WORKSPACE); + const storedModes = this._readApplicationWithProfileFallback(TipEligibilityTracker._MODES_STORAGE_KEY); this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); - const storedTools = this._storageService.get(TipEligibilityTracker._TOOLS_STORAGE_KEY, StorageScope.WORKSPACE); + const storedTools = this._readApplicationWithProfileFallback(TipEligibilityTracker._TOOLS_STORAGE_KEY); this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); // --- Derive what still needs tracking ---------------------------------- @@ -498,7 +508,21 @@ export class TipEligibilityTracker extends Disposable { } private _persistSet(key: string, set: Set): void { - this._storageService.store(key, JSON.stringify([...set]), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._storageService.store(key, JSON.stringify([...set]), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private _readApplicationWithProfileFallback(key: string): string | undefined { + const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); + if (applicationValue) { + return applicationValue; + } + + const profileValue = this._storageService.get(key, StorageScope.PROFILE); + if (profileValue) { + this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return profileValue; } } @@ -527,46 +551,66 @@ export class ChatTipService extends Disposable implements IChatTipService { */ private _shownTip: ITipDefinition | undefined; + /** + * The scoped context key service from the chat widget, stored when + * {@link getWelcomeTip} is first called so that navigation methods + * can evaluate when-clause eligibility against the correct context. + */ + private _contextKeyService: IContextKeyService | undefined; + private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; private readonly _tracker: TipEligibilityTracker; + private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; constructor( @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, + @IChatService private readonly _chatService: IChatService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, + @IChatEntitlementService chatEntitlementService: IChatEntitlementService, ) { super(); this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); + this._createSlashCommandsUsageTracker = this._register(new CreateSlashCommandsUsageTracker(this._chatService, this._storageService, () => this._contextKeyService)); + this._register(chatEntitlementService.onDidChangeQuotaExceeded(() => { + if (chatEntitlementService.quotas.chat?.percentRemaining === 0 && this._shownTip) { + this.hideTip(); + } + })); } resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; + this._contextKeyService = undefined; } dismissTip(): void { if (this._shownTip) { - const dismissed = this._getDismissedTipIds(); - dismissed.push(this._shownTip.id); - this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); + const dismissed = new Set(this._getDismissedTipIds()); + dismissed.add(this._shownTip.id); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify([...dismissed]), StorageScope.APPLICATION, StorageTarget.MACHINE); } - this._shownTip = undefined; + // Keep the current tip reference so callers can navigate relative to it + // (for example, dismiss -> next should mirror next/previous behavior). this._tipRequestId = undefined; this._onDidDismissTip.fire(); } clearDismissedTips(): void { + this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.APPLICATION); this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; + this._contextKeyService = undefined; this._onDidDismissTip.fire(); } private _getDismissedTipIds(): string[] { - const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); + const raw = this._readApplicationWithProfileFallback(ChatTipService._DISMISSED_TIP_KEY); if (!raw) { return []; } @@ -577,14 +621,15 @@ export class ChatTipService extends Disposable implements IChatTipService { return []; } - // Safety valve: if every known tip has been dismissed (for example, due to a - // past bug that dismissed the current tip on every new session), treat this - // as "no tips dismissed" so the feature can recover. - if (parsed.length >= TIP_CATALOG.length) { - return []; + const knownTipIds = new Set(TIP_CATALOG.map(tip => tip.id)); + const dismissed = new Set(); + for (const value of parsed) { + if (typeof value === 'string' && knownTipIds.has(value)) { + dismissed.add(value); + } } - return parsed; + return [...dismissed]; } catch { return []; } @@ -604,11 +649,15 @@ export class ChatTipService extends Disposable implements IChatTipService { } getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Check if tips are enabled if (!this._configurationService.getValue('chat.tips.enabled')) { return undefined; } + // Store the scoped context key service for later navigation calls + this._contextKeyService = contextKeyService; + // Only show tips for Copilot if (!this._isCopilotEnabled()) { return undefined; @@ -630,7 +679,7 @@ export class ChatTipService extends Disposable implements IChatTipService { const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { this._shownTip = nextTip; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.PROFILE, StorageTarget.USER); + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.APPLICATION, StorageTarget.USER); const tip = this._createTip(nextTip); this._onDidNavigateTip.fire(tip); return tip; @@ -645,6 +694,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _findNextEligibleTip(currentTipId: string, contextKeyService: IContextKeyService): ITipDefinition | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); const currentIndex = TIP_CATALOG.findIndex(tip => tip.id === currentTipId); if (currentIndex === -1) { return undefined; @@ -663,6 +713,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Record the current mode for future eligibility decisions. this._tracker.recordCurrentMode(contextKeyService); @@ -670,7 +721,7 @@ export class ChatTipService extends Disposable implements IChatTipService { let selectedTip: ITipDefinition | undefined; // Determine where to start in the catalog based on the last-shown tip. - const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.PROFILE); + const lastTipId = this._readApplicationWithProfileFallback(ChatTipService._LAST_TIP_ID_KEY); const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; @@ -685,28 +736,12 @@ export class ChatTipService extends Disposable implements IChatTipService { } } - // Pass 2: if everything was ineligible (e.g., user has already done all - // the suggested actions), still advance through the catalog but only skip - // tips that were explicitly dismissed. - if (!selectedTip) { - for (let i = 0; i < TIP_CATALOG.length; i++) { - const idx = (startIndex + i) % TIP_CATALOG.length; - const candidate = TIP_CATALOG[idx]; - if (!dismissedIds.has(candidate.id)) { - selectedTip = candidate; - break; - } - } - } - - // Final fallback: if even that fails (all tips dismissed), stick with the - // catalog order so rotation still progresses. if (!selectedTip) { - selectedTip = TIP_CATALOG[startIndex]; + return undefined; } // Persist the selected tip id so the next use advances to the following one. - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.APPLICATION, StorageTarget.USER); // Record that we've shown a tip this session this._tipRequestId = sourceId; @@ -715,15 +750,22 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(selectedTip); } - navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined { - return this._navigateTip(1, contextKeyService); + navigateToNextTip(): IChatTip | undefined { + if (!this._contextKeyService) { + return undefined; + } + return this._navigateTip(1, this._contextKeyService); } - navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined { - return this._navigateTip(-1, contextKeyService); + navigateToPreviousTip(): IChatTip | undefined { + if (!this._contextKeyService) { + return undefined; + } + return this._navigateTip(-1, this._contextKeyService); } private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); if (!this._shownTip) { return undefined; } @@ -739,7 +781,8 @@ export class ChatTipService extends Disposable implements IChatTipService { const candidate = TIP_CATALOG[idx]; if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { this._shownTip = candidate; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER); + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); const tip = this._createTip(candidate); this._onDidNavigateTip.fire(tip); return tip; @@ -828,4 +871,18 @@ export class ChatTipService extends Disposable implements IChatTipService { enabledCommands: tipDef.enabledCommands, }; } + + private _readApplicationWithProfileFallback(key: string): string | undefined { + const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); + if (applicationValue) { + return applicationValue; + } + + const profileValue = this._storageService.get(key, StorageScope.PROFILE); + if (profileValue) { + this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return profileValue; + } } diff --git a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts new file mode 100644 index 0000000000000..0ad8f07e43c24 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { ChatRequestSlashCommandPart } from '../common/requestParser/chatParserTypes.js'; + +export class CreateSlashCommandsUsageTracker extends Disposable { + private static readonly _USED_CREATE_SLASH_COMMANDS_KEY = 'chat.tips.usedCreateSlashCommands'; + + constructor( + private readonly _chatService: IChatService, + private readonly _storageService: IStorageService, + private readonly _getActiveContextKeyService: () => IContextKeyService | undefined, + ) { + super(); + + this._register(this._chatService.onDidSubmitRequest(e => { + const model = this._chatService.getSession(e.chatSessionResource); + const lastRequest = model?.lastRequest; + if (!lastRequest) { + return; + } + + for (const part of lastRequest.message.parts) { + if (part.kind === ChatRequestSlashCommandPart.Kind) { + const slash = part as ChatRequestSlashCommandPart; + if (CreateSlashCommandsUsageTracker._isCreateSlashCommand(slash.slashCommand.command)) { + this._markUsed(); + return; + } + } + } + + // Fallback when parsing doesn't produce a slash command part. + const trimmed = lastRequest.message.text.trimStart(); + const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); + if (match && CreateSlashCommandsUsageTracker._isCreateSlashCommand(match[1])) { + this._markUsed(); + } + })); + } + + syncContextKey(contextKeyService: IContextKeyService): void { + const used = this._storageService.getBoolean(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, StorageScope.APPLICATION, false); + ChatContextKeys.hasUsedCreateSlashCommands.bindTo(contextKeyService).set(used); + } + + private _markUsed(): void { + if (this._storageService.getBoolean(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, StorageScope.APPLICATION, false)) { + return; + } + + this._storageService.store(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const contextKeyService = this._getActiveContextKeyService(); + if (contextKeyService) { + ChatContextKeys.hasUsedCreateSlashCommands.bindTo(contextKeyService).set(true); + } + } + + private static _isCreateSlashCommand(command: string): boolean { + switch (command) { + case 'create-instruction': + case 'create-prompt': + case 'create-agent': + case 'create-skill': + return true; + default: + return false; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index e59763850e62e..5de04eb38b471 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -116,7 +116,7 @@ const enum WorkingMessageCategory { Tool = 'tool' } -const thinkingMessages = [ +const defaultThinkingMessages = [ localize('chat.thinking.thinking.1', 'Thinking'), localize('chat.thinking.thinking.2', 'Reasoning'), localize('chat.thinking.thinking.3', 'Considering'), @@ -181,18 +181,42 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { let pool = this.availableMessagesByCategory.get(category); if (!pool || pool.length === 0) { + let defaults: string[]; switch (category) { case WorkingMessageCategory.Thinking: - pool = [...thinkingMessages]; + defaults = [...defaultThinkingMessages]; break; case WorkingMessageCategory.Terminal: - pool = [...terminalMessages]; + defaults = [...terminalMessages]; break; case WorkingMessageCategory.Tool: default: - pool = [...toolMessages]; + defaults = [...toolMessages]; break; } + + // Read configured phrases from the single setting + const config = this.configurationService.getValue<{ mode?: 'replace' | 'append'; phrases?: string[] }>(ChatConfiguration.ThinkingPhrases); + const customPhrases = Array.isArray(config?.phrases) + ? config.phrases + .filter((phrase): phrase is string => typeof phrase === 'string') + .map(phrase => phrase.trim()) + .filter(phrase => phrase.length > 0) + : []; + const mode = config?.mode === 'replace' ? 'replace' : 'append'; + + if (customPhrases.length > 0) { + if (mode === 'replace') { + // Replace mode: use only custom phrases for all categories + pool = [...customPhrases]; + } else { + // Append mode: add custom phrases to defaults for this category + pool = [...defaults, ...customPhrases]; + } + } else { + pool = defaults; + } + this.availableMessagesByCategory.set(category, pool); } const index = Math.floor(Math.random() * pool.length); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 256999f36bd9b..f8be5ed5ebbee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -10,7 +10,6 @@ import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabe import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -18,7 +17,6 @@ import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../. import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; @@ -40,7 +38,6 @@ export class ChatTipContentPart extends Disposable { constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, - private readonly _getNextTip: () => IChatTip | undefined, @IChatTipService private readonly _chatTipService: IChatTipService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IMenuService private readonly _menuService: IMenuService, @@ -63,7 +60,7 @@ export class ChatTipContentPart extends Disposable { this._renderTip(tip); this._register(this._chatTipService.onDidDismissTip(() => { - const nextTip = this._getNextTip(); + const nextTip = this._chatTipService.navigateToNextTip(); if (nextTip) { this._renderTip(nextTip); dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); @@ -152,8 +149,7 @@ registerAction2(class PreviousTipAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const chatTipService = accessor.get(IChatTipService); - const contextKeyService = accessor.get(IContextKeyService); - chatTipService.navigateToPreviousTip(contextKeyService); + chatTipService.navigateToPreviousTip(); } }); @@ -174,8 +170,7 @@ registerAction2(class NextTipAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const chatTipService = accessor.get(IChatTipService); - const contextKeyService = accessor.get(IContextKeyService); - chatTipService.navigateToNextTip(contextKeyService); + chatTipService.navigateToNextTip(); } }); @@ -242,36 +237,26 @@ registerAction2(class DisableTipsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const dialogService = accessor.get(IDialogService); const chatTipService = accessor.get(IChatTipService); const commandService = accessor.get(ICommandService); - const { result } = await dialogService.prompt({ - message: localize('chatTip.disableConfirmTitle', "Disable tips?"), - custom: { - markdownDetails: [{ - markdown: new MarkdownString(localize('chatTip.disableConfirmDetail', "New tips are added frequently to help you get the most out of Copilot. You can re-enable tips anytime from the `chat.tips.enabled` setting.")), - }], - }, - buttons: [ - { - label: localize('chatTip.disableConfirmButton', "Disable tips"), - run: () => true, - }, - { - label: localize('chatTip.openSettingButton', "Open Setting"), - run: () => { - commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); - return false; - }, - }, - ], - cancelButton: true, + await chatTipService.disableTips(); + await commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); + } +}); + +registerAction2(class ResetDismissedTipsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.resetDismissedTips', + title: localize2('chatTip.resetDismissedTips', "Reset Dismissed Tips"), + f1: true, + precondition: ChatContextKeys.enabled, }); + } - if (result) { - await chatTipService.disableTips(); - } + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).clearDismissedTips(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 37c8ecfc743e9..b3040e59317d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -464,8 +464,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -483,7 +482,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { if (action instanceof MenuItemAction) { @@ -537,7 +538,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(this, undefined); + private readonly _viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); private parsedChatRequest: IParsedChatRequest | undefined; get parsedInput() { @@ -405,7 +406,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewContext = viewContext ?? {}; - const viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); + const viewModelObs = this._viewModelObs; if (typeof location === 'object') { this._location = location; @@ -1006,7 +1007,6 @@ export class ChatWidget extends Disposable implements IChatWidget { const tipPart = store.add(this.instantiationService.createInstance(ChatTipContentPart, tip, renderer, - () => this.chatTipService.getWelcomeTip(this.contextKeyService), )); tipContainer.appendChild(tipPart.domNode); this._gettingStartedTipPartRef = tipPart; @@ -2511,9 +2511,26 @@ export class ChatWidget extends Disposable implements IChatWidget { } getModeRequestOptions(): Partial { + const sessionResource = this.viewModel?.sessionResource; + const userSelectedTools = this.input.selectedToolsModel.userSelectedTools; + + let lastToolsSnapshot = userSelectedTools.get(); + + // When the widget has loaded a new session, return a snapshot of the tools for this session. + // Only sync with the tools model when this session is shown. + const scopedTools = derived(reader => { + const activeSession = this._viewModelObs.read(reader)?.sessionResource; + if (isEqual(activeSession, sessionResource)) { + const tools = userSelectedTools.read(reader); + lastToolsSnapshot = tools; + return tools; + } + return lastToolsSnapshot; + }); + return { modeInfo: this.input.currentModeInfo, - userSelectedTools: this.input.selectedToolsModel.userSelectedTools, + userSelectedTools: scopedTools, }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index cfe1d784a8ba5..a8e16ae9913b1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -126,7 +126,6 @@ import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js' import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/service/promptsService.js'; -import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; const $ = dom.$; @@ -679,7 +678,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private setImplicitContextEnablement() { if (this.implicitContext && this.configurationService.getValue('chat.implicitContext.suggestedContext')) { - this.implicitContext.setEnabled(this._currentModeObservable.get().kind !== ChatMode.Agent.kind); + this.implicitContext.setEnabled(this._currentModeObservable.get().name.get().toLowerCase() === 'ask'); } } @@ -1000,7 +999,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._currentLanguageModel.set(model, undefined); // Record usage for the recently used models list - this.languageModelsService.recordModelUsage(model.identifier); + this.languageModelsService.recordModelUsage(model); if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name @@ -2074,7 +2073,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -2288,7 +2287,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { - inputModel = this.modelService.createModel('', null, this.inputUri, false); + inputModel = this.modelService.createModel('', null, this.inputUri, true); } this.textModelResolverService.createModelReference(this.inputUri).then(ref => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 8e7831378c1b0..8f3607618d88d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -10,12 +10,10 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; -import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IActionListDropdownOptions, IActionListDropdownEntry, IActionListDropdownItem, ActionListDropdown, ActionListDropdownItemKind } from '../../../../../../platform/actionWidget/browser/actionListDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -54,16 +52,11 @@ type ChatModelChangeEvent = { }; function createModelItem( - action: IActionWidgetDropdownAction & { section?: string }, -): IActionListItem { + action: IActionListDropdownItem, +): IActionListDropdownEntry { return { item: action, - kind: ActionListItemKind.Action, - label: action.label, - description: action.description, - group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, - hideIcon: false, - section: action.section, + kind: ActionListDropdownItemKind.Action, }; } @@ -72,13 +65,11 @@ function createModelAction( selectedModelId: string | undefined, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, section?: string, -): IActionWidgetDropdownAction & { section?: string } { +): IActionListDropdownItem { return { id: model.identifier, - enabled: true, icon: model.metadata.statusIcon, checked: model.identifier === selectedModelId, - class: undefined, description: model.metadata.multiplier ?? model.metadata.detail, tooltip: model.metadata.name, label: model.metadata.name, @@ -107,8 +98,8 @@ function buildModelPickerItems( commandService: ICommandService, openerService: IOpenerService, upgradePlanUrl: string | undefined, -): IActionListItem[] { - const items: IActionListItem[] = []; +): IActionListDropdownEntry[] { + const items: IActionListDropdownEntry[] = []; // Collect all available models const allModelsMap = new Map(); @@ -131,9 +122,7 @@ function buildModelPickerItems( const autoDescription = defaultModel?.metadata.multiplier ?? defaultModel?.metadata.detail; items.push(createModelItem({ id: 'auto', - enabled: true, checked: isAutoSelected, - class: undefined, tooltip: localize('chat.modelPicker.auto', "Auto"), label: localize('chat.modelPicker.auto', "Auto"), description: autoDescription, @@ -154,15 +143,17 @@ function buildModelPickerItems( if (model && !placed.has(model.identifier) && model !== defaultModel) { promotedModels.push(model); placed.add(model.identifier); + placed.add(model.metadata.id); } } // Add curated - available ones become promoted, unavailable ones become disabled entries for (const curated of curatedModels) { const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); - if (model && !placed.has(model.identifier)) { + if (model && !placed.has(model.identifier) && !placed.has(model.metadata.id)) { promotedModels.push(model); placed.add(model.identifier); + placed.add(model.metadata.id); } else if (!model) { // Model is not available - determine reason if (!isProUser) { @@ -180,7 +171,7 @@ function buildModelPickerItems( if (promotedModels.length > 0 || unavailableCurated.length > 0) { items.push({ - kind: ActionListItemKind.Separator, + kind: ActionListDropdownItemKind.Separator, }); for (const model of promotedModels) { const action = createModelAction(model, selectedModelId, onSelect); @@ -202,21 +193,14 @@ function buildModelPickerItems( items.push({ item: { id: curated.id, - enabled: false, - checked: false, - class: undefined, tooltip: label, label: curated.id, - description: label, + disabled: true, + descriptionButton: { label, onDidClick: onButtonClick }, + className: 'unavailable-model', run: () => { } }, - kind: ActionListItemKind.Action, - label: curated.id, - descriptionButton: { label, onDidClick: onButtonClick }, - disabled: true, - group: { title: '', icon: Codicon.blank }, - hideIcon: false, - className: 'unavailable-model', + kind: ActionListDropdownItemKind.Action, }); } } @@ -224,7 +208,7 @@ function buildModelPickerItems( // --- 3. Other Models (collapsible) --- const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; for (const model of models) { - if (!placed.has(model.identifier)) { + if (!placed.has(model.identifier) && !placed.has(model.metadata.id)) { // Skip the default model - it's already represented by the top-level "Auto" entry const isDefault = Object.values(model.metadata.isDefaultForLocation).some(v => v); if (isDefault) { @@ -236,24 +220,18 @@ function buildModelPickerItems( if (otherModels.length > 0) { items.push({ - kind: ActionListItemKind.Separator, + kind: ActionListDropdownItemKind.Separator, }); items.push({ item: { id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), label: localize('chat.modelPicker.otherModels', "Other Models"), + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + section: ModelPickerSection.Other, + isSectionToggle: true, run: () => { /* toggle handled by isSectionToggle */ } }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, + kind: ActionListDropdownItemKind.Action, }); for (const model of otherModels) { const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); @@ -264,34 +242,24 @@ function buildModelPickerItems( items.push({ item: { id: 'manageModels', - enabled: true, - checked: false, - class: 'manage-models-action', - tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), label: localize('chat.manageModels', "Manage Models..."), + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), icon: Codicon.settingsGear, + section: ModelPickerSection.Other, + className: 'manage-models-link', run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } }, - kind: ActionListItemKind.Action, - label: localize('chat.manageModels', "Manage Models..."), - group: { title: '', icon: Codicon.settingsGear }, - hideIcon: false, - section: ModelPickerSection.Other, - className: 'manage-models-link', + kind: ActionListDropdownItemKind.Action, }); } return items; } -/** - * Returns the ActionList options for the model picker (filter + collapsed sections). - */ -function getModelPickerListOptions(): IActionListOptions { +function getActionListDropdownOptions(): IActionListDropdownOptions { return { - showFilter: true, collapsedByDefault: new Set([ModelPickerSection.Other]), minWidth: 300, }; @@ -320,6 +288,7 @@ export class ModelPickerWidget extends Disposable { private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; + private readonly _dropdown: ActionListDropdown; get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { return this._selectedModel; @@ -330,7 +299,7 @@ export class ModelPickerWidget extends Disposable { } constructor( - @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -339,6 +308,7 @@ export class ModelPickerWidget extends Disposable { @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, ) { super(); + this._dropdown = this._register(this._instantiationService.createInstance(ActionListDropdown)); } setModels(models: ILanguageModelChatMetadataAndIdentifier[]): void { @@ -393,9 +363,6 @@ export class ModelPickerWidget extends Disposable { return; } - // Mark new models as seen immediately when the picker is opened - this._languageModelsService.markNewModelsAsSeen(); - const previousModel = this._selectedModel; const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { @@ -415,7 +382,7 @@ export class ModelPickerWidget extends Disposable { const items = buildModelPickerItems( this._models, this._selectedModel?.identifier, - this._languageModelsService.getRecentlyUsedModelIds(7), + this._languageModelsService.getRecentlyUsedModelIds(), curatedForTier, isPro, this._productService.version, @@ -425,47 +392,21 @@ export class ModelPickerWidget extends Disposable { this._productService.defaultChatAgent?.upgradePlanUrl, ); - const listOptions = getModelPickerListOptions(); - const previouslyFocusedElement = dom.getActiveElement(); + const dropdownOptions = getActionListDropdownOptions(); const delegate = { - onSelect: (action: IActionWidgetDropdownAction) => { - this._actionWidgetService.hide(); - action.run(); + onSelect: (item: IActionListDropdownItem) => { + this._dropdown.hide(); + item.run(); }, onHide: () => { this._domNode?.setAttribute('aria-expanded', 'false'); - if (dom.isHTMLElement(previouslyFocusedElement)) { - previouslyFocusedElement.focus(); - } } }; this._domNode?.setAttribute('aria-expanded', 'true'); - this._actionWidgetService.show( - 'ChatModelPicker', - false, - items, - delegate, - anchorElement, - undefined, - [], - { - isChecked(element) { - return element.kind === 'action' && !!element?.item?.checked; - }, - getRole: (e) => { - switch (e.kind) { - case 'action': return 'menuitemcheckbox'; - case 'separator': return 'separator'; - default: return 'separator'; - } - }, - getWidgetRole: () => 'menu', - }, - listOptions - ); + this._dropdown.show(items, delegate, anchorElement, dropdownOptions); } private _updateBadge(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts index c19279bee0abe..03b20e871faa6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -44,7 +44,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget)); this._pickerWidget.setModels(delegate.getModels()); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); - this._updateBadge(); // Sync delegate → widget when model list or selection changes externally this._register(autorun(t => { @@ -64,7 +63,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { })); // Update badge when new models appear - this._register(this.languageModelsService.onDidChangeNewModelIds(() => this._updateBadge())); } override render(container: HTMLElement): void { @@ -93,11 +91,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { this._pickerWidget.show(this._getAnchorElement()); } - private _updateBadge(): void { - const hasNew = this.languageModelsService.getNewModelIds().length > 0; - this._pickerWidget.setBadge(hasNew ? 'info' : undefined); - } - private _updateTooltip(): void { if (!this.element) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 29d2a72833f9e..095c128b58c26 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2638,65 +2638,80 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; width: 100%; position: relative; + align-items: center; + gap: 4px; + margin-top: 0; + margin-bottom: 8px; - .checkpoint-divider { - border-top: 1px dashed var(--vscode-chat-checkpointSeparator); - margin: 15px 0; - width: 100%; - height: 0; + .checkpoint-line-left, + .checkpoint-line-right { + flex: 1; + height: 1px; + background-color: var(--vscode-chat-requestBorder, var(--vscode-input-background)); } - .codicon-container { - color: var(--vscode-descriptionForeground); - padding-right: 4px; - display: flex; - align-items: center; - gap: 4px; + .checkpoint-line-left { + mask-image: linear-gradient(to right, transparent, black); + -webkit-mask-image: linear-gradient(to right, transparent, black); } - .codicon-container .codicon { - font-size: 14px; - color: var(--vscode-chat-checkpointSeparator); + .checkpoint-line-right { + mask-image: linear-gradient(to left, transparent, black); + -webkit-mask-image: linear-gradient(to left, transparent, black); } .monaco-toolbar { - opacity: 0; height: fit-content; width: fit-content; user-select: none; - position: absolute; - top: 4px; - margin-left: 25px; - background: var(--vscode-sideBar-background); + flex-shrink: 0; } .monaco-toolbar .action-label { - border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background)); + font-size: 12px; + line-height: 18px; + color: var(--vscode-descriptionForeground); + border: 1px solid transparent; + background-color: transparent; padding: 1px 5px; + } + + .monaco-toolbar .action-label:hover { + color: var(--vscode-foreground); + border-color: var(--vscode-chat-requestBorder, var(--vscode-input-background)); background-color: var(--vscode-sideBar-background); } } + .checkpoint-container { + opacity: 0; + transition: opacity 0.1s ease-in-out; + } + .checkpoint-restore-container { margin-top: 10px; .checkpoint-label-text { font-size: 12px; + line-height: 18px; color: var(--vscode-descriptionForeground); - background-color: var(--vscode-sideBar-background); - padding: 4px; display: flex; align-items: center; - gap: 4px; - position: absolute; - margin-left: 71px; - margin-top: 2px; + flex-shrink: 0; + padding: 1px 5px; + border: 1px solid transparent; + } + + .checkpoint-dot-separator { + font-size: 12px; + line-height: 18px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; } } - .checkpoint-container .monaco-toolbar:focus-within, - .checkpoint-restore-container .monaco-toolbar, - .interactive-item-container.interactive-request:not(.editing):hover .checkpoint-container .monaco-toolbar { + .checkpoint-container:focus-within, + .interactive-item-container.interactive-request:not(.editing):hover .checkpoint-container { opacity: 1; } @@ -2736,6 +2751,10 @@ have to be updated for changes to the rules above, or to support more deeply nes .request-hover:not(.has-no-actions) { display: block; } + + .checkpoint-container { + opacity: 1; + } } .interactive-request.editing .rendered-markdown, diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index b97209bce1587..a4c7f5316aac2 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -130,6 +130,12 @@ export namespace ChatContextKeys { export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + /** + * True when the user has submitted a chat request using any of the `/create-*` slash commands. + * This is persisted in application storage and used to suppress onboarding tips once discovered. + */ + export const hasUsedCreateSlashCommands = new RawContextKey('chatHasUsedCreateSlashCommands', false, { type: 'boolean', description: localize('chatHasUsedCreateSlashCommands', "True when the user has used any of the /create-* slash commands.") }); + export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 610e43616bf93..a6604f07fba4d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1455,3 +1455,21 @@ export interface IChatSessionStartOptions { canUseTools?: boolean; disableBackgroundKeepAlive?: boolean; } + +export const ChatStopCancellationNoopEventName = 'chat.stopCancellationNoop'; + +export type ChatStopCancellationNoopEvent = { + source: 'cancelAction' | 'chatService'; + reason: 'noWidget' | 'noViewModel' | 'noPendingRequest' | 'requestAlreadyCanceled' | 'requestIdUnavailable'; + requestInProgress: 'true' | 'false' | 'unknown'; + pendingRequests: number; +}; + +export type ChatStopCancellationNoopClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The layer where stop cancellation no-op occurred.' }; + reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The no-op reason when stop cancellation did not dispatch fully.' }; + requestInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether request-in-progress was true, false, or unknown at no-op time.' }; + pendingRequests: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of queued pending requests at no-op time when known.'; isMeasurement: true }; + owner: 'roblourens'; + comment: 'Tracks possible no-op stop cancellation paths.'; +}; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f295461bad9a9..490703770745a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -26,6 +26,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { Progress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -38,7 +39,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; @@ -146,6 +147,7 @@ export class ChatService extends Disposable implements IChatService { constructor( @IStorageService private readonly storageService: IStorageService, @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IExtensionService private readonly extensionService: IExtensionService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @@ -1395,7 +1397,22 @@ export class ChatService extends Disposable implements IChatService { cancelCurrentRequestForSession(sessionResource: URI): void { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); - this._pendingRequests.get(sessionResource)?.cancel(); + const pendingRequest = this._pendingRequests.get(sessionResource); + if (!pendingRequest) { + const model = this._sessionModels.get(sessionResource); + const requestInProgress = model?.requestInProgress.get(); + const pendingRequestsCount = model?.getPendingRequests().length ?? 0; + this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'chatService', + reason: 'noPendingRequest', + requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', + pendingRequests: pendingRequestsCount, + }); + this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + return; + } + + pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 8ccd21ac64711..4c8f12a339f35 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -198,6 +198,8 @@ export interface IChatSessionItemController { get items(): readonly IChatSessionItem[]; refresh(token: CancellationToken): Promise; + + newChatSessionItem?(request: IChatAgentRequest, token: CancellationToken): Promise; } /** @@ -293,6 +295,12 @@ export interface IChatSessionsService { registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; + + /** + * Creates a new chat session item using the controller's newChatSessionItemHandler. + * Returns undefined if the controller doesn't have a handler or if no controller is registered. + */ + createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise; } export function isSessionInProgressStatus(state: ChatSessionStatus): boolean { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8066635dc7477..693f7c1f9da74 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -35,6 +35,7 @@ export enum ChatConfiguration { ThinkingStyle = 'chat.agent.thinkingStyle', ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles', TerminalToolsInThinking = 'chat.agent.thinking.terminalTools', + ThinkingPhrases = 'chat.agent.thinking.phrases', AutoExpandToolFailures = 'chat.tools.autoExpandFailures', TodosShowWidget = 'chat.tools.todos.showWidget', NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e452196a385e0..8e5a16a4496bb 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -371,12 +371,12 @@ export interface ILanguageModelsService { * Returns the most recently used model identifiers, ordered by most-recent-first. * @param maxCount Maximum number of entries to return (default 7). */ - getRecentlyUsedModelIds(maxCount?: number): string[]; + getRecentlyUsedModelIds(): string[]; /** * Records that a model was used, updating the recently used list. */ - recordModelUsage(modelIdentifier: string): void; + recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void; /** * Returns the curated models from the models control manifest, @@ -384,21 +384,6 @@ export interface ILanguageModelsService { */ getCuratedModels(): ICuratedModels; - /** - * Returns the IDs of curated models that are marked as new and have not been seen yet. - */ - getNewModelIds(): string[]; - - /** - * Fires when the set of new (unseen) model IDs changes. - */ - readonly onDidChangeNewModelIds: Event; - - /** - * Marks all new models as seen, clearing the new badge. - */ - markNewModelsAsSeen(): void; - /** * Observable map of restricted chat participant names to allowed extension publisher/IDs. * Fetched from the chat control manifest. @@ -504,7 +489,6 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed'; -const CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY = 'chatModelSeenNewModels'; const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; @@ -549,8 +533,6 @@ export class LanguageModelsService implements ILanguageModelsService { private _recentlyUsedModelIds: string[] = []; private _curatedModels: ICuratedModels = { free: [], paid: [] }; - private _newModelIds: Set = new Set(); - private _seenNewModelIds: Set = new Set(); private _chatControlUrl: string | undefined; private _chatControlDisposed = false; @@ -558,9 +540,6 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _restrictedChatParticipants = observableValue<{ [name: string]: string[] }>(this, Object.create(null)); readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }> = this._restrictedChatParticipants; - private readonly _onDidChangeNewModelIds = this._store.add(new Emitter()); - readonly onDidChangeNewModelIds: Event = this._onDidChangeNewModelIds.event; - constructor( @IExtensionService private readonly _extensionService: IExtensionService, @ILogService private readonly _logService: ILogService, @@ -575,7 +554,6 @@ export class LanguageModelsService implements ILanguageModelsService { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); this._modelPickerUserPreferences = this._readModelPickerPreferences(); this._recentlyUsedModelIds = this._readRecentlyUsedModels(); - this._seenNewModelIds = this._readSeenNewModels(); this._initChatControlData(); this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); @@ -1393,21 +1371,25 @@ export class LanguageModelsService implements ILanguageModelsService { this._storageService.store(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, this._recentlyUsedModelIds, StorageScope.PROFILE, StorageTarget.USER); } - getRecentlyUsedModelIds(maxCount: number = 7): string[] { + getRecentlyUsedModelIds(): string[] { // Filter to only include models that still exist in the cache return this._recentlyUsedModelIds .filter(id => this._modelCache.has(id)) - .slice(0, maxCount); + .slice(0, 5); } - recordModelUsage(modelIdentifier: string): void { + recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void { + if (model.metadata.id === 'auto' && this._vendors.get(model.metadata.vendor)?.isDefault) { + return; + } + // Remove if already present (to move to front) - const index = this._recentlyUsedModelIds.indexOf(modelIdentifier); + const index = this._recentlyUsedModelIds.indexOf(model.identifier); if (index !== -1) { this._recentlyUsedModelIds.splice(index, 1); } // Add to front - this._recentlyUsedModelIds.unshift(modelIdentifier); + this._recentlyUsedModelIds.unshift(model.identifier); // Cap at a reasonable max to avoid unbounded growth if (this._recentlyUsedModelIds.length > 20) { this._recentlyUsedModelIds.length = 20; @@ -1442,45 +1424,8 @@ export class LanguageModelsService implements ILanguageModelsService { newIds.add(model.id); } } - - this._newModelIds = newIds; - this._onDidChangeNewModelIds.fire(); } - getNewModelIds(): string[] { - const result: string[] = []; - for (const id of this._newModelIds) { - if (!this._seenNewModelIds.has(id)) { - result.push(id); - } - } - return result; - } - - markNewModelsAsSeen(): void { - let changed = false; - for (const id of this._newModelIds) { - if (!this._seenNewModelIds.has(id)) { - this._seenNewModelIds.add(id); - changed = true; - } - } - if (changed) { - this._saveSeenNewModels(); - this._onDidChangeNewModelIds.fire(); - } - } - - private _readSeenNewModels(): Set { - return new Set(this._storageService.getObject(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, StorageScope.PROFILE, [])); - } - - private _saveSeenNewModels(): void { - this._storageService.store(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, [...this._seenNewModelIds], StorageScope.PROFILE, StorageTarget.USER); - } - - //#endregion - //#region Chat control data private _initChatControlData(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index d756c7020876a..ba7821dd3585a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -11,9 +11,11 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ChatResponseAccessibleView, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; suite('ChatResponseAccessibleView', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -394,10 +396,57 @@ suite('ChatResponseAccessibleView', () => { }); suite('getProvider', () => { + test('omits thinking content when disabled in storage', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + storageService.store(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, false, StorageScope.PROFILE, StorageTarget.USER); + + const responseItem = { + response: { value: [{ kind: 'thinking', value: 'Hidden reasoning' }, { kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('Response content')); + assert.ok(!content.includes('Thinking: Hidden reasoning')); + }); + test('prefers the latest response when focus is on a queued request', () => { const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); const responseItem = { - response: { value: [{ kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + response: { value: [{ kind: 'thinking', value: 'Reasoning' }, { kind: 'markdownContent', content: new MarkdownString('Response content') }] }, model: { onDidChange: Event.None }, setVote: () => undefined }; @@ -427,12 +476,15 @@ suite('ChatResponseAccessibleView', () => { } as unknown as IChatWidgetService; instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); const accessibleView = new ChatResponseAccessibleView(); const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); assert.ok(provider); store.add(provider); - assert.ok(provider.provideContent().includes('Response content')); + const content = provider.provideContent(); + assert.ok(content.includes('Response content')); + assert.ok(content.includes('Thinking: Reasoning')); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 0439386a34154..f38cca659ae72 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -136,12 +136,9 @@ class MockLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } - getRecentlyUsedModelIds(_maxCount?: number): string[] { return []; } - recordModelUsage(_modelIdentifier: string): void { } + getRecentlyUsedModelIds(): string[] { return []; } + recordModelUsage(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } - getNewModelIds(): string[] { return []; } - onDidChangeNewModelIds = Event.None; - markNewModelsAsSeen(): void { } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5ee2250a37324..d950b9bfbd1bc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; @@ -23,10 +23,18 @@ import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { MockChatService } from '../common/chatService/mockChatService.js'; +import { CreateSlashCommandsUsageTracker } from '../../browser/createSlashCommandsUsageTracker.js'; +import { ChatRequestSlashCommandPart } from '../../common/requestParser/chatParserTypes.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { Range } from '../../../../../editor/common/core/range.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { - override contextMatchesRules(): boolean { - return true; + override contextMatchesRules(rules: ContextKeyExpression): boolean { + return rules.evaluate({ getValue: (key: string) => this.getContextKeyValue(key) }); } } @@ -89,6 +97,8 @@ suite('ChatTipService', () => { onDidChangeCustomAgents: Event.None, } as Partial as IPromptsService); instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); + instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); + instantiationService.stub(IChatService, new MockChatService()); }); test('returns a welcome tip', () => { @@ -227,6 +237,20 @@ suite('ChatTipService', () => { } }); + test('dismissTip keeps navigation context for next tip traversal', () => { + const service = createService(); + + const tip1 = service.getWelcomeTip(contextKeyService); + assert.ok(tip1); + + service.dismissTip(); + + const tip2 = service.navigateToNextTip(); + if (tip2) { + assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be returned by next navigation'); + } + }); + test('dismissTip fires onDidDismissTip event', () => { const service = createService(); @@ -279,6 +303,62 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); + test('dismissed tips stay dismissed after disabling and re-enabling tips', async () => { + const service = createService(); + + // Flush microtask queue so async file-check exclusions resolve before + // we start dismissing tips (otherwise excludeUntilChecked tips are + // temporarily excluded and never get dismissed in the loop below). + await new Promise(r => queueMicrotask(r)); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + + service.dismissTip(); + } + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'No tip should remain once all tips are dismissed'); + + await service.disableTips(); + configurationService.setUserConfiguration('chat.tips.enabled', true); + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'Dismissed tips should remain dismissed after re-enabling tips'); + }); + + test('clearDismissedTips restores tip visibility', () => { + const service = createService(); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + + service.dismissTip(); + } + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'No tip should remain once all tips are dismissed'); + + service.clearDismissedTips(); + + assert.ok(service.getWelcomeTip(contextKeyService), 'A tip should be visible again after clearing dismissed tips'); + }); + + test('migrates dismissed tips from profile to application storage', () => { + storageService.store('chat.tip.dismissed', JSON.stringify(['tip.switchToAuto']), StorageScope.PROFILE, StorageTarget.MACHINE); + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto', 'Should honor profile-stored dismissed tip id'); + assert.ok(storageService.get('chat.tip.dismissed', StorageScope.APPLICATION), 'Expected dismissed tips to migrate to application storage'); + }); + function createMockPromptsService( agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = [], @@ -318,6 +398,51 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); }); + test('persists executed command exclusions in application storage', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); + + assert.ok(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION), 'Expected executed command exclusions in application storage'); + assert.strictEqual(storageService.get('chat.tips.executedCommands', StorageScope.PROFILE), undefined, 'Did not expect executed command exclusions in profile storage'); + assert.strictEqual(storageService.get('chat.tips.executedCommands', StorageScope.WORKSPACE), undefined, 'Did not expect executed command exclusions in workspace storage'); + }); + + test('migrates executed command exclusions from profile to application storage', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + storageService.store('chat.tips.executedCommands', JSON.stringify(['workbench.action.chat.restoreCheckpoint']), StorageScope.PROFILE, StorageTarget.MACHINE); + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should honor profile-stored exclusions'); + assert.ok(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION), 'Expected migrated exclusion data in application storage'); + }); + test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { const tip: ITipDefinition = { id: 'tip.customInstructions', @@ -638,6 +763,41 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); + test('shows tip.createSlashCommands when context key is false', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); + + // Dismiss tips until we find createSlashCommands or run out + let found = false; + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + if (tip.id === 'tip.createSlashCommands') { + found = true; + break; + } + service.dismissTip(); + } + + assert.ok(found, 'Should eventually show tip.createSlashCommands when context key is false'); + }); + + test('does not show tip.createSlashCommands when context key is true', () => { + storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + const service = createService(); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands when context key is true'); + service.dismissTip(); + } + }); + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; @@ -672,3 +832,189 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after onDidChangeCustomAgents fires and agent files exist'); }); }); + +suite('CreateSlashCommandsUsageTracker', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let storageService: InMemoryStorageService; + let contextKeyService: MockContextKeyService; + let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI }>; + let sessions: Map; + + setup(() => { + storageService = testDisposables.add(new InMemoryStorageService()); + contextKeyService = new MockContextKeyService(); + submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI }>()); + sessions = new Map(); + }); + + function createMockChatServiceForTracker(): IChatService { + return { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: (resource: URI) => sessions.get(resource.toString()), + } as Partial as IChatService; + } + + function createTracker(chatService?: IChatService): CreateSlashCommandsUsageTracker { + return testDisposables.add(new CreateSlashCommandsUsageTracker( + chatService ?? createMockChatServiceForTracker(), + storageService, + () => contextKeyService, + )); + } + + test('syncContextKey sets context key to false when storage is empty', () => { + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, false, 'Context key should be false when no create commands have been used'); + }); + + test('syncContextKey sets context key to true when storage has recorded usage', () => { + storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, true, 'Context key should be true when create commands have been used'); + }); + + test('detects create-instruction slash command via text fallback', () => { + const sessionResource = URI.parse('chat:session1'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-instruction test', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, true, 'Context key should be true after /create-instruction is used'); + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist the create slash command usage', + ); + }); + + test('detects create-prompt slash command via text fallback', () => { + const sessionResource = URI.parse('chat:session2'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-prompt my-prompt', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist the create-prompt usage', + ); + }); + + test('detects create-agent slash command via parsed part', () => { + const sessionResource = URI.parse('chat:session3'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-agent test', + parts: [ + new ChatRequestSlashCommandPart( + new OffsetRange(0, 13), + new Range(1, 1, 1, 14), + { command: 'create-agent', detail: '', locations: [] }, + ), + ], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist when create-agent slash command part is detected', + ); + }); + + test('does not mark used for non-create slash commands', () => { + const sessionResource = URI.parse('chat:session4'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/help test', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, false, 'Context key should remain false for non-create slash commands'); + }); + + test('does not mark used when session has no last request', () => { + const sessionResource = URI.parse('chat:session5'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { lastRequest: undefined }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + false, + 'Should not mark used when there is no last request', + ); + }); + + test('only marks used once even with multiple create commands', () => { + const sessionResource = URI.parse('chat:session6'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { text: '/create-skill test', parts: [] }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + assert.strictEqual(storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), true); + + // Fire again — should be a no-op + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { text: '/create-prompt test', parts: [] }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + assert.strictEqual(storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), true); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index ee3375bdef594..33da5451517c5 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -87,23 +87,15 @@ export class NullLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } - getRecentlyUsedModelIds(_maxCount?: number): string[] { + getRecentlyUsedModelIds(): string[] { return []; } - recordModelUsage(_modelIdentifier: string): void { } + recordModelUsage(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } - getNewModelIds(): string[] { - return []; - } - - onDidChangeNewModelIds = Event.None; - - markNewModelsAsSeen(): void { } - restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 932cbb036b06b..bbeaa0b4c058a 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,7 +9,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from '../../common/participants/chatAgents.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; @@ -217,6 +217,10 @@ export class MockChatSessionsService implements IChatSessionsService { return undefined; } + async createNewChatSessionItem(_chatSessionType: string, _request: IChatAgentRequest, _token: CancellationToken): Promise { + return undefined; + } + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { // Store the emitter so tests can trigger it this.onChange = onChange; diff --git a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts index 6ef9584b413bd..f8e3072a4743e 100644 --- a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts +++ b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts @@ -11,35 +11,43 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { arch, platform } from '../../../../base/common/process.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/arrays.js'; +import { IntervalTimer } from '../../../../base/common/async.js'; +import { mainWindow } from '../../../../base/browser/window.js'; interface IEmergencyAlert { readonly commit: string; readonly platform?: string; readonly arch?: string; readonly message: string; - readonly actions?: [{ + readonly actions?: ReadonlyArray<{ readonly label: string; readonly href: string; - }]; + }>; } interface IEmergencyAlerts { readonly alerts: IEmergencyAlert[]; } -export class EmergencyAlert implements IWorkbenchContribution { +const POLLING_INTERVAL = 60 * 60 * 1000; // 1 hour +const BANNER_ID = 'emergencyAlert.banner'; + +export class EmergencyAlert extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.emergencyAlert'; + private currentAlertMessage: string | undefined; + private currentAlertActions: IEmergencyAlert['actions'] | undefined; + constructor( @IBannerService private readonly bannerService: IBannerService, @IRequestService private readonly requestService: IRequestService, @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService ) { - if (productService.quality !== 'insider') { - return; // only enabled in insiders for now - } + super(); const emergencyAlertUrl = productService.emergencyAlertUrl; if (!emergencyAlertUrl) { @@ -47,6 +55,9 @@ export class EmergencyAlert implements IWorkbenchContribution { } this.fetchAlerts(emergencyAlertUrl); + + const pollingTimer = this._register(new IntervalTimer()); + pollingTimer.cancelAndSet(() => this.fetchAlerts(emergencyAlertUrl), POLLING_INTERVAL, mainWindow); } private async fetchAlerts(url: string): Promise { @@ -58,36 +69,56 @@ export class EmergencyAlert implements IWorkbenchContribution { } private async doFetchAlerts(url: string): Promise { - const requestResult = await this.requestService.request({ type: 'GET', url, disableCache: true }, CancellationToken.None); + const requestResult = await this.requestService.request({ type: 'GET', url, disableCache: true, timeout: 20000 }, CancellationToken.None); if (requestResult.res.statusCode !== 200) { throw new Error(`Failed to fetch emergency alerts: HTTP ${requestResult.res.statusCode}`); } const emergencyAlerts = await asJson(requestResult); - if (!emergencyAlerts) { + if (!emergencyAlerts || !Array.isArray(emergencyAlerts.alerts)) { + this.dismissAlert(); return; } - for (const emergencyAlert of emergencyAlerts.alerts) { - if ( - (emergencyAlert.commit !== this.productService.commit) || // version mismatch - (emergencyAlert.platform && emergencyAlert.platform !== platform) || // platform mismatch - (emergencyAlert.arch && emergencyAlert.arch !== arch) // arch mismatch - ) { - return; - } - - this.bannerService.show({ - id: 'emergencyAlert.banner', - icon: Codicon.warning, - message: emergencyAlert.message, - actions: emergencyAlert.actions - }); - - break; + // Find the first matching alert + const matchingAlert = emergencyAlerts.alerts.find(alert => + alert.commit === this.productService.commit && + (!alert.platform || alert.platform === platform) && + (!alert.arch || alert.arch === arch) + ); + + if (!matchingAlert) { + // No matching alert, dismiss the banner if it was shown + this.dismissAlert(); + return; + } + + // Don't update the banner if message and actions didn't change + if ( + this.currentAlertMessage === matchingAlert.message && + equals(this.currentAlertActions ?? [], matchingAlert.actions ?? [], (a, b) => a.label === b.label && a.href === b.href) + ) { + return; + } + + this.currentAlertMessage = matchingAlert.message; + this.currentAlertActions = matchingAlert.actions; + this.bannerService.show({ + id: BANNER_ID, + icon: Codicon.warning, + message: matchingAlert.message, + actions: matchingAlert.actions + }); + } + + private dismissAlert(): void { + if (this.currentAlertMessage !== undefined) { + this.currentAlertMessage = undefined; + this.currentAlertActions = undefined; + this.bannerService.hide(BANNER_ID); } } } -registerWorkbenchContribution2('workbench.emergencyAlert', EmergencyAlert, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(EmergencyAlert.ID, EmergencyAlert, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/markers/browser/markersModel.ts b/src/vs/workbench/contrib/markers/browser/markersModel.ts index 91c033d4e187a..5dbf26eea826c 100644 --- a/src/vs/workbench/contrib/markers/browser/markersModel.ts +++ b/src/vs/workbench/contrib/markers/browser/markersModel.ts @@ -205,21 +205,27 @@ export class MarkersModel { } else { change.updated.add(resourceMarkers); } - const markersCountByKey = new Map(); - const markers = rawMarkers.map((rawMarker) => { - const key = IMarkerData.makeKey(rawMarker); - const index = markersCountByKey.get(key) || 0; - markersCountByKey.set(key, index + 1); + // Deduplicate markers with identical source, code, severity, message + // and range so that a diagnostic reported by both a task problem + // matcher and a language extension is only shown once (#244424). + const processedMarkerKeys = new Set(); + const markers: Marker[] = []; + for (const rawMarker of rawMarkers) { + const markerKey = IMarkerData.makeKey(rawMarker) + rawMarker.resource.toString(); + if (processedMarkerKeys.has(markerKey)) { + continue; + } + processedMarkerKeys.add(markerKey); - const markerId = this.id(resourceMarkers!.id, key, index, rawMarker.resource.toString()); + const markerId = this.id(resourceMarkers!.id, markerKey, 0, rawMarker.resource.toString()); let relatedInformation: RelatedInformation[] | undefined = undefined; if (rawMarker.relatedInformation) { relatedInformation = rawMarker.relatedInformation.map((r, index) => new RelatedInformation(this.id(markerId, r.resource.toString(), r.startLineNumber, r.startColumn, r.endLineNumber, r.endColumn, index), rawMarker, r)); } - return new Marker(markerId, rawMarker, relatedInformation); - }); + markers.push(new Marker(markerId, rawMarker, relatedInformation)); + } this._total -= resourceMarkers.total; resourceMarkers.set(resource, markers); diff --git a/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts b/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts index 48596e960b4db..b0eb8c858dcfb 100644 --- a/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts +++ b/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts @@ -34,13 +34,14 @@ suite('MarkersModel Test', () => { test('marker ids are unique', function () { const marker1 = anErrorWithRange(3); - const marker2 = anErrorWithRange(3); + const marker2 = anErrorWithRange(3, 5, 4, 10, 'different message'); const marker3 = aWarningWithRange(3); - const marker4 = aWarningWithRange(3); + const marker4 = aWarningWithRange(5); const testObject = new TestMarkersModel([marker1, marker2, marker3, marker4]); const actuals = testObject.resourceMarkers[0].markers; + assert.strictEqual(actuals.length, 4); assert.notStrictEqual(actuals[0].id, actuals[1].id); assert.notStrictEqual(actuals[0].id, actuals[2].id); assert.notStrictEqual(actuals[0].id, actuals[3].id); @@ -49,6 +50,31 @@ suite('MarkersModel Test', () => { assert.notStrictEqual(actuals[2].id, actuals[3].id); }); + test('duplicate markers from different owners are deduplicated', function () { + // Simulate a task problem matcher and language extension both reporting + // the same diagnostic for the same file (#244424). + const taskMarker = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'some message', 'eslint'); + taskMarker.owner = 'taskOwner'; + const extensionMarker = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'some message', 'eslint'); + extensionMarker.owner = 'extensionOwner'; + + const testObject = new TestMarkersModel([taskMarker, extensionMarker]); + const actuals = testObject.resourceMarkers[0].markers; + + assert.strictEqual(actuals.length, 1, 'identical markers from different owners should be deduplicated'); + assert.strictEqual(actuals[0].marker.message, 'some message'); + }); + + test('markers with different messages are not deduplicated', function () { + const marker1 = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'message without period', 'eslint'); + const marker2 = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'message with period.', 'eslint'); + + const testObject = new TestMarkersModel([marker1, marker2]); + const actuals = testObject.resourceMarkers[0].markers; + + assert.strictEqual(actuals.length, 2, 'markers with different messages should not be deduplicated'); + }); + test('sort palces resources with no errors at the end', function () { const marker1 = aMarker('a/res1', MarkerSeverity.Warning); const marker2 = aMarker('a/res2'); diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 82bc7998af4c0..2cf3ac03c1e76 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -880,7 +880,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { let promise: Promise | undefined = undefined; if (task.configurationProperties.isBackground) { const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService); + const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService, this._logService); if ((problemMatchers.length > 0) && !watchingProblemMatcher.isWatching()) { this._appendOutput(nls.localize('TerminalTaskSystem.nonWatchingMatcher', 'Task {0} is a background task but uses a problem matcher without a background pattern', task._label)); this._showOutput(); @@ -1047,7 +1047,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._fireTaskEvent(TaskEvent.general(TaskEventKind.Active, task, terminal.instanceId)); const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService); + const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService, this._logService); this._terminalStatusManager.addTerminal(task, terminal, startStopProblemMatcher); this._taskProblemMonitor.addTerminal(terminal, startStopProblemMatcher); const problemMatcherListener = startStopProblemMatcher.onDidStateChange((event) => { diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index 892a948cb65d9..8ede878cacaa4 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -15,6 +15,7 @@ import { IMarkerService, IMarkerData, MarkerSeverity, IMarker } from '../../../. import { generateUuid } from '../../../../base/common/uuid.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { isWindows } from '../../../../base/common/platform.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export const enum ProblemCollectorEventKind { BackgroundProcessingBegins = 'backgroundProcessingBegins', @@ -67,11 +68,11 @@ export abstract class AbstractProblemCollector extends Disposable implements IDi protected readonly _onDidRequestInvalidateLastMarker = this._register(new Emitter()); readonly onDidRequestInvalidateLastMarker = this._onDidRequestInvalidateLastMarker.event; - constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService) { + constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService, protected readonly logService?: ILogService) { super(); this.matchers = Object.create(null); this.bufferLength = 1; - problemMatchers.map(elem => createLineMatcher(elem, fileService)).forEach((matcher) => { + problemMatchers.map(elem => createLineMatcher(elem, fileService, logService)).forEach((matcher) => { const length = matcher.matchLength; if (length > this.bufferLength) { this.bufferLength = length; @@ -364,8 +365,8 @@ export class StartStopProblemCollector extends AbstractProblemCollector implemen private _hasStarted: boolean = false; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); const ownerSet: { [key: string]: boolean } = Object.create(null); problemMatchers.forEach(description => ownerSet[description.owner] = true); this.owners = Object.keys(ownerSet); @@ -422,8 +423,8 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private lines: string[] = []; public beginPatterns: RegExp[] = []; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); this.resetCurrentResource(); this.backgroundPatterns = []; this._activeBackgroundMatchers = new Set(); @@ -514,7 +515,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private async tryBegin(line: string): Promise { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.begin.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow begin regexp took ${elapsed}ms to execute`, background.begin.regexp.source); + } if (matches) { if (this._activeBackgroundMatchers.has(background.key)) { continue; @@ -543,7 +549,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private tryFinish(line: string): boolean { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.end.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow end regexp took ${elapsed}ms to execute`, background.end.regexp.source); + } if (matches) { if (this._numberOfMatches > 0) { this._onDidFindErrors.fire(this.markerService.read({ owner: background.matcher.owner })); diff --git a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts index fad04dd91bea1..479c93d6ce1da 100644 --- a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts +++ b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts @@ -24,6 +24,7 @@ import { IMarkerData, MarkerSeverity } from '../../../../platform/markers/common import { ExtensionsRegistry, ExtensionMessageCollector } from '../../../services/extensions/common/extensionsRegistry.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { FileType, IFileService, IFileStatWithPartialMetadata, IFileSystemProvider } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export enum FileLocationKind { Default, @@ -301,12 +302,12 @@ export interface ILineMatcher { handle(lines: string[], start?: number): IHandleResult; } -export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService): ILineMatcher { +export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService): ILineMatcher { const pattern = matcher.pattern; if (Array.isArray(pattern)) { - return new MultiLineMatcher(matcher, fileService); + return new MultiLineMatcher(matcher, fileService, logService); } else { - return new SingleLineMatcher(matcher, fileService); + return new SingleLineMatcher(matcher, fileService, logService); } } @@ -315,10 +316,12 @@ const endOfLine: string = Platform.OS === Platform.OperatingSystem.Windows ? '\r abstract class AbstractLineMatcher implements ILineMatcher { private matcher: ProblemMatcher; private fileService?: IFileService; + private logService?: ILogService; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { this.matcher = matcher; this.fileService = fileService; + this.logService = logService; } public handle(lines: string[], start: number = 0): IHandleResult { @@ -331,6 +334,16 @@ abstract class AbstractLineMatcher implements ILineMatcher { public abstract get matchLength(): number; + protected regexpExec(regexp: RegExp, line: string): RegExpExecArray | null { + const start = Date.now(); + const result = regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow regexp took ${elapsed}ms to execute`, regexp.source); + } + return result; + } + protected fillProblemData(data: IProblemData | undefined, pattern: IProblemPattern, matches: RegExpExecArray): data is IProblemData { if (data) { this.fillProperty(data, 'file', pattern, matches, true); @@ -482,8 +495,8 @@ class SingleLineMatcher extends AbstractLineMatcher { private pattern: IProblemPattern; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.pattern = matcher.pattern; } @@ -497,7 +510,7 @@ class SingleLineMatcher extends AbstractLineMatcher { if (this.pattern.kind !== undefined) { data.kind = this.pattern.kind; } - const matches = this.pattern.regexp.exec(lines[start]); + const matches = this.regexpExec(this.pattern.regexp, lines[start]); if (matches) { this.fillProblemData(data, this.pattern, matches); if (data.kind === ProblemLocationKind.Location && !data.location && !data.line && data.file) { @@ -521,8 +534,8 @@ class MultiLineMatcher extends AbstractLineMatcher { private patterns: IProblemPattern[]; private data: IProblemData | undefined; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.patterns = matcher.pattern; } @@ -537,7 +550,7 @@ class MultiLineMatcher extends AbstractLineMatcher { data.kind = this.patterns[0].kind; for (let i = 0; i < this.patterns.length; i++) { const pattern = this.patterns[i]; - const matches = pattern.regexp.exec(lines[i + start]); + const matches = this.regexpExec(pattern.regexp, lines[i + start]); if (!matches) { return { match: null, continue: false }; } else { @@ -559,7 +572,7 @@ class MultiLineMatcher extends AbstractLineMatcher { public override next(line: string): IProblemMatch | null { const pattern = this.patterns[this.patterns.length - 1]; Assert.ok(pattern.loop === true && this.data !== null); - const matches = pattern.regexp.exec(line); + const matches = this.regexpExec(pattern.regexp, line); if (!matches) { this.data = undefined; return null; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index b13ee6aded3f4..a730051c4e7b9 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -164,13 +164,6 @@ export interface ITerminalChatService { * @returns The chat session resource if found, undefined otherwise */ getChatSessionResourceForInstance(instance: ITerminalInstance): URI | undefined; - /** - * @deprecated Use getChatSessionResourceForInstance instead - * Returns the chat session ID for a given terminal instance, if it has been registered. - * @param instance The terminal instance to look up - * @returns The chat session ID if found, undefined otherwise - */ - getChatSessionIdForInstance(instance: ITerminalInstance): string | undefined; /** * Check if a terminal is a background terminal (tool-driven terminal that may be hidden from diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 89dc39f4b9637..9a378d9ff826c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -14,7 +14,6 @@ import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/k import { ChatViewId, IChatWidgetService } from '../../../chat/browser/chat.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; -import { LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../chat/common/constants.js'; import { isDetachedTerminalInstance, ITerminalChatService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../terminal/browser/terminal.js'; @@ -392,10 +391,11 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { const lastCommand = instance.capabilities.get(TerminalCapability.CommandDetection)?.commands.at(-1)?.command; // Get the chat session title - const chatSessionId = terminalChatService.getChatSessionIdForInstance(instance); + const chatSessionResource = terminalChatService.getChatSessionResourceForInstance(instance); let chatSessionTitle: string | undefined; - if (chatSessionId) { - chatSessionTitle = chatService.getSessionTitle(LocalChatSessionUri.forSession(chatSessionId)); + if (chatSessionResource) { + const liveTitle = chatService.getSession(chatSessionResource)?.title; + chatSessionTitle = liveTitle ?? chatService.getSessionTitle(chatSessionResource); } const description = chatSessionTitle; diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index e815d4e93cb81..acb2ca13eaa27 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -13,7 +13,7 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { TerminalChatContextKeys } from './terminalChat.js'; -import { chatSessionResourceToId, LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; +import { LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; import { isNumber, isString } from '../../../../../base/common/types.js'; const enum StorageKeys { @@ -180,11 +180,6 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ return this._chatSessionResourceByTerminalInstance.get(instance); } - getChatSessionIdForInstance(instance: ITerminalInstance): string | undefined { - const resource = this._chatSessionResourceByTerminalInstance.get(instance); - return resource ? chatSessionResourceToId(resource) : undefined; - } - isBackgroundTerminal(terminalToolSessionId?: string): boolean { if (!terminalToolSessionId) { return false; diff --git a/src/vs/workbench/services/banner/browser/bannerService.ts b/src/vs/workbench/services/banner/browser/bannerService.ts index 2db0fa42104e3..9c0e9b566540d 100644 --- a/src/vs/workbench/services/banner/browser/bannerService.ts +++ b/src/vs/workbench/services/banner/browser/bannerService.ts @@ -13,7 +13,7 @@ export interface IBannerItem { readonly id: string; readonly icon: ThemeIcon | URI | undefined; readonly message: string | MarkdownString; - readonly actions?: ILinkDescriptor[]; + readonly actions?: ReadonlyArray; readonly ariaLabel?: string; readonly onClose?: () => void; readonly closeLabel?: string; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 04df7ca03d790..5f93a7fe90990 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -92,6 +92,15 @@ declare module 'vscode' { */ export type ChatSessionItemControllerRefreshHandler = (token: CancellationToken) => Thenable; + export interface ChatSessionItemControllerNewItemHandlerContext { + readonly request: ChatRequest; + } + + /** + * Extension callback invoked when a new chat session is started. + */ + export type ChatSessionItemControllerNewItemHandler = (context: ChatSessionItemControllerNewItemHandlerContext, token: CancellationToken) => Thenable; + /** * Manages chat sessions for a specific chat session type */ @@ -120,6 +129,15 @@ declare module 'vscode' { */ readonly refreshHandler: ChatSessionItemControllerRefreshHandler; + /** + * Invoked when a new chat session is started. + * + * This allows the controller to initialize the chat session item with information from the initial request. + * + * The returned chat session is added to the collection and shown in the UI. + */ + newChatSessionItemHandler?: ChatSessionItemControllerNewItemHandler; + /** * Fired when an item's archived state changes. */ @@ -363,6 +381,7 @@ declare module 'vscode' { */ // TODO: Should we introduce our own type for `ChatRequestHandler` since not all field apply to chat sessions? // TODO: Revisit this to align with code. + // TODO: pass in options? readonly requestHandler: ChatRequestHandler | undefined; }