diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 2775b1d4c70045..422569f364d78b 100644 --- a/.agents/skills/launch/SKILL.md +++ b/.agents/skills/launch/SKILL.md @@ -37,7 +37,7 @@ The clone is **slim**: workspace storage, browser caches, file history, cached V > The launcher always sets `files.simpleDialog.enable: true` in the launched profile's `User/settings.json`. This is required for automation: VS Code's native OS file dialogs cannot be driven via `@playwright/cli` over CDP and are completely unreachable over SSH on headless macOS. The simple (quick-input) dialog can be navigated with `press` and clipboard paste. The override is per-launch and only affects throwaway profiles. -> When launching a regular editor window from an agent session, first call `get_current_session` and pass its `title` as `--session-title`. The launcher writes that title into the throwaway profile's `window.title` setting so each editor window can be mapped back to its originating session. This never modifies the source profile. Do not combine `--session-title` with `--agents`: `window.title` is read-only in the Agents window. +> Before launching from an agent session, call `get_current_session` and pass its `title` as `--session-title`. For a regular editor window, the launcher writes that title into the throwaway profile's `window.title` setting. For an Agents window, it passes the title to the Command Center. This never modifies the source profile. > For unattended automation, pass `--disable-workspace-trust` so a trust dialog cannot block the flow or extension-host startup. The override is process-scoped and does not modify the source profile. Only use it with content you trust. @@ -49,7 +49,7 @@ The launcher script lives next to this SKILL.md at `scripts/launch.sh` (macOS/Li # LAUNCH=/scripts/launch.sh SESSION_TITLE= "$LAUNCH" --session-title "$SESSION_TITLE" # default: workbench -"$LAUNCH" --agents # Agents window (no custom title) +"$LAUNCH" --agents --session-title "$SESSION_TITLE" "$LAUNCH" -- # forward extra args to code.sh "$LAUNCH" --source-user-data-dir # pick a specific authed profile "$LAUNCH" --repo # if not run from the repo @@ -66,7 +66,7 @@ $skillDir = '' $launch = Join-Path $skillDir 'scripts\launch.ps1' $sessionTitle = '' & $launch --session-title $sessionTitle # default: workbench -& $launch --agents # Agents window (no custom title) +& $launch --agents --session-title $sessionTitle & $launch -- --use-mock-keychain # forward extra args to code.bat & $launch --source-user-data-dir C:\path\to\profile & $launch --repo C:\path\to\vscode diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index 51fd602aa72ffd..2ee956c7139c73 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -400,10 +400,6 @@ for ($index = 0; $index -lt $cliArgs.Count; $index++) { } } -if ($agents -and -not [string]::IsNullOrWhiteSpace($sessionTitle)) { - Exit-Usage '--session-title is only supported for regular editor windows; window.title is read-only in the Agents window.' -} - try { $launchStopwatch = [Diagnostics.Stopwatch]::StartNew() if ([string]::IsNullOrWhiteSpace($repo)) { @@ -491,19 +487,27 @@ try { $settingsFile = Join-Path $destinationUdd 'User\settings.json' $sourceSettingsFile = Join-Path $sourceUserDataDir 'User\settings.json' $settingsScript = Join-Path $PSScriptRoot 'updateSettings.ts' - & $node $settingsScript $settingsFile $sessionTitle $sourceSettingsFile + $settingsSessionTitle = if ($agents) { '' } else { $sessionTitle } + & $node $settingsScript $settingsFile $settingsSessionTitle $sourceSettingsFile if ($LASTEXITCODE -ne 0) { throw "Failed to update launch settings in $settingsFile" } Write-LaunchError "[launch.ps1] ensured files.simpleDialog.enable=true in $settingsFile" if (-not [string]::IsNullOrWhiteSpace($sessionTitle)) { - Write-LaunchError "[launch.ps1] set window.title for session: $sessionTitle" + if ($agents) { + Write-LaunchError "[launch.ps1] set Agents command center title for session: $sessionTitle" + } else { + Write-LaunchError "[launch.ps1] set window.title for session: $sessionTitle" + } } $profileReadyMs = $launchStopwatch.ElapsedMilliseconds $launchArgs = [System.Collections.Generic.List[string]]::new() if ($agents) { $launchArgs.Add('--agents') + if (-not [string]::IsNullOrWhiteSpace($sessionTitle)) { + $launchArgs.Add("--session-title=$sessionTitle") + } } $launchArgs.Add("--user-data-dir=$destinationUdd") $launchArgs.Add("--extensions-dir=$extensionsDir") diff --git a/.agents/skills/launch/scripts/launch.sh b/.agents/skills/launch/scripts/launch.sh index 0b25696c90c98e..ad7a1f4be96f2c 100755 --- a/.agents/skills/launch/scripts/launch.sh +++ b/.agents/skills/launch/scripts/launch.sh @@ -67,11 +67,6 @@ while [[ $# -gt 0 ]]; do esac done -if [[ "$AGENTS" == "1" && -n "$SESSION_TITLE" ]]; then - echo "--session-title is only supported for regular editor windows; window.title is read-only in the Agents window." >&2 - exit 2 -fi - monotonic_ms() { node -e 'process.stdout.write(String(process.hrtime.bigint() / 1_000_000n))' } @@ -183,13 +178,21 @@ SETTINGS_FILE="$DEST_UDD/User/settings.json" SOURCE_SETTINGS_FILE="$SOURCE_UDD/User/settings.json" mkdir -p "$(dirname "$SETTINGS_FILE")" SETTINGS_SCRIPT="$(cd "$(dirname "$0")" && pwd)/updateSettings.ts" -if ! node "$SETTINGS_SCRIPT" "$SETTINGS_FILE" "$SESSION_TITLE" "$SOURCE_SETTINGS_FILE"; then +SETTINGS_SESSION_TITLE="$SESSION_TITLE" +if [[ "$AGENTS" == "1" ]]; then + SETTINGS_SESSION_TITLE="" +fi +if ! node "$SETTINGS_SCRIPT" "$SETTINGS_FILE" "$SETTINGS_SESSION_TITLE" "$SOURCE_SETTINGS_FILE"; then echo "[launch.sh] failed to update launch settings in $SETTINGS_FILE" >&2 exit 1 fi echo "[launch.sh] ensured files.simpleDialog.enable=true in $SETTINGS_FILE" >&2 if [[ -n "$SESSION_TITLE" ]]; then - echo "[launch.sh] set window.title for session: $SESSION_TITLE" >&2 + if [[ "$AGENTS" == "1" ]]; then + echo "[launch.sh] set Agents command center title for session: $SESSION_TITLE" >&2 + else + echo "[launch.sh] set window.title for session: $SESSION_TITLE" >&2 + fi fi PROFILE_READY_MS=$(monotonic_ms) @@ -217,6 +220,9 @@ if [[ "$DISABLE_WORKSPACE_TRUST" == "1" ]]; then fi if [[ "$AGENTS" == "1" ]]; then ARGS=("--agents" "${ARGS[@]}") + if [[ -n "$SESSION_TITLE" ]]; then + ARGS+=("--session-title=$SESSION_TITLE") + fi fi if (( ${#EXTRA_ARGS[@]} )); then ARGS+=("${EXTRA_ARGS[@]}") diff --git a/.github/skills/tool-rename-deprecation/SKILL.md b/.github/skills/tool-rename-deprecation/SKILL.md deleted file mode 100644 index 0d1fa0c663fb22..00000000000000 --- a/.github/skills/tool-rename-deprecation/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: tool-rename-deprecation -description: 'Ensure renamed built-in tool references preserve backward compatibility. Use when renaming a toolReferenceName, tool set referenceName, or any tool identifier. Run on ANY change to tool registration code. Covers legacyToolReferenceFullNames for tools and legacyFullNames for tool sets.' ---- - -# Tool Rename Deprecation - -When a tool or tool set reference name is changed, the **old name must always be added to the deprecated/legacy array** so that existing prompt files, tool configurations, and saved references continue to resolve correctly. - -## When to Use - -Run this skill on **any change to built-in tool or tool set registration code** to catch regressions: - -- Renaming a tool's `toolReferenceName` -- Renaming a tool set's `referenceName` -- Moving a tool from one tool set to another (the old `toolSet/toolName` path becomes a legacy name) -- Reviewing a PR that modifies tool registration — verify no legacy names were dropped - -## Procedure - -### Step 1 — Identify What Changed - -Determine whether you are renaming a **tool** or a **tool set**, and where it is registered: - -| Entity | Registration | Name field to rename | Legacy array | Stable ID (NEVER change) | -|--------|-------------|---------------------|-------------|-------------------------| -| Tool (`IToolData`) | TypeScript | `toolReferenceName` | `legacyToolReferenceFullNames` | `id` | -| Tool (extension) | `package.json` `languageModelTools` | `toolReferenceName` | `legacyToolReferenceFullNames` | `name` (becomes `id`) | -| Tool set (`IToolSet`) | TypeScript | `referenceName` | `legacyFullNames` | `id` | -| Tool set (extension) | `package.json` `languageModelToolSets` | `name` or `referenceName` | `legacyFullNames` | — | - -**Critical:** For extension-contributed tools, the `name` field in `package.json` is mapped to `id` on `IToolData` (see `languageModelToolsContribution.ts` line `id: rawTool.name`). It is also used for activation events (`onLanguageModelTool:`). **Never rename the `name` field** — only rename `toolReferenceName`. - -### Step 2 — Add the Old Name to the Legacy Array - -**Verify the old `toolReferenceName` value appears in `legacyToolReferenceFullNames`.** Don't assume it's already there — check the actual array contents. If the old name is already listed (e.g., from a previous rename), confirm it wasn't removed. If it's not there, add it. - -**For internal/built-in tools** (TypeScript `IToolData`): - -```typescript -// Before rename -export const MyToolData: IToolData = { - id: 'myExtension.myTool', - toolReferenceName: 'oldName', - // ... -}; - -// After rename — old name preserved -export const MyToolData: IToolData = { - id: 'myExtension.myTool', - toolReferenceName: 'newName', - legacyToolReferenceFullNames: ['oldName'], - // ... -}; -``` - -If the tool previously lived inside a tool set, use the full `toolSet/toolName` form: - -```typescript -legacyToolReferenceFullNames: ['oldToolSet/oldToolName'], -``` - -If renaming multiple times, **accumulate** all prior names — never remove existing entries: - -```typescript -legacyToolReferenceFullNames: ['firstOldName', 'secondOldName'], -``` - -**For tool sets**, add the old name to the `legacyFullNames` option when calling `createToolSet`: - -```typescript -toolsService.createToolSet(source, id, 'newSetName', { - legacyFullNames: ['oldSetName'], -}); -``` - -**For extension-contributed tools** (`package.json`), rename only `toolReferenceName` and add the old value to `legacyToolReferenceFullNames`. **Do NOT rename the `name` field:** - -```jsonc -// CORRECT — only toolReferenceName changes, name stays stable -{ - "name": "copilot_myTool", // ← KEEP this unchanged - "toolReferenceName": "newName", // ← renamed - "legacyToolReferenceFullNames": [ - "oldName" // ← old toolReferenceName preserved - ] -} -``` - -### Step 3 — Check All Consumers of Tool Names - -Legacy names must be respected **everywhere** a tool is looked up by reference name, not just in prompt resolution. Key consumers: - -- **Prompt files** — `getDeprecatedFullReferenceNames()` maps old → current names for `.prompt.md` validation and code actions -- **Tool enablement** — `getToolAliases()` / `getToolSetAliases()` yield legacy names so tool picker and enablement maps resolve them -- **Auto-approval config** — `isToolEligibleForAutoApproval()` checks `legacyToolReferenceFullNames` (including the segment after `/` for namespaced legacy names) against `chat.tools.eligibleForAutoApproval` settings -- **RunInTerminalTool** — has its own local auto-approval check that also iterates `LEGACY_TOOL_REFERENCE_FULL_NAMES` - -After renaming, confirm: -1. `#oldName` in a `.prompt.md` file still resolves (shows no validation error) -2. Tool configurations referencing the old name still activate the tool -3. A user who had `"chat.tools.eligibleForAutoApproval": { "oldName": false }` still has that restriction honored - -### Step 4 — Update References (Optional) - -While legacy names ensure backward compatibility, update first-party references to use the new name: -- System prompts and built-in `.prompt.md` files -- Documentation and model descriptions that mention the tool by reference name -- Test files that reference the old name directly - -## Key Files - -| File | What it contains | -|------|-----------------| -| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts` | `IToolData` and `IToolSet` interfaces with legacy name fields | -| `src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts` | Resolution logic: `getToolAliases`, `getToolSetAliases`, `getDeprecatedFullReferenceNames`, `isToolEligibleForAutoApproval` | -| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts` | Extension point schema, validation, and the critical `id: rawTool.name` mapping (line ~274) | -| `src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts` | Example of a tool with its own local auto-approval check against legacy names | - -## Real Examples - -- `runInTerminal` tool: renamed from `runCommands/runInTerminal` → `legacyToolReferenceFullNames: ['runCommands/runInTerminal']` -- `todo` tool: renamed from `todos` → `legacyToolReferenceFullNames: ['todos']` -- `getTaskOutput` tool: renamed from `runTasks/getTaskOutput` → `legacyToolReferenceFullNames: ['runTasks/getTaskOutput']` - -## Reference PRs - -- [#277047](https://github.com/microsoft/vscode/pull/277047) — **Design PR**: Introduced `legacyToolReferenceFullNames` and `legacyFullNames`, built the resolution infrastructure, and performed the first batch of tool renames. Use as a template for how to properly rename with legacy names. -- [#278506](https://github.com/microsoft/vscode/pull/278506) — **Consumer-side fix**: After the renames in #277047, the `eligibleForAutoApproval` setting wasn't checking legacy names — users who had restricted the old name lost that restriction. Shows why all consumers of tool reference names must account for legacy names. -- [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) — **Example of a miss**: Renamed `openSimpleBrowser` → `openIntegratedBrowser` but also changed the `name` field (stable id) from `copilot_openSimpleBrowser` → `copilot_openIntegratedBrowser`. The `toolReferenceName` backward compat only worked by coincidence (the old name happened to already be in the legacy array from a prior change — it was not intentionally added as part of this rename). - -## Regression Check - -Run this check on any PR that touches tool registration (TypeScript `IToolData`, `createToolSet`, or `package.json` `languageModelTools`/`languageModelToolSets`): - -1. **Search the diff for changed `toolReferenceName` or `referenceName` values.** For each change, confirm the **previous value** now appears in `legacyToolReferenceFullNames` or `legacyFullNames`. Don't assume it was already there — read the actual array. -2. **Search the diff for changed `name` fields** on extension-contributed tools. The `name` field is the tool's stable `id` — it must **never** change. If it changed, flag it as a bug. (This breaks activation events, tool invocations by id, and any code referencing the tool by its `name`.) -3. **Verify no entries were removed** from existing legacy arrays. -4. **If a tool moved between tool sets**, confirm the old `toolSet/toolName` full path is in the legacy array. -5. **Check tool set membership lists** (the `tools` array in `languageModelToolSets` contributions). If a tool's `toolReferenceName` changed, any tool set `tools` array referencing the old name should be updated — but the legacy resolution system handles this, so the old name still works. - -## Anti-patterns - -- **Changing the `name` field on extension-contributed tools** — the `name` in `package.json` becomes the `id` on `IToolData` (via `id: rawTool.name` in `languageModelToolsContribution.ts`). Changing it breaks activation events (`onLanguageModelTool:`), any code referencing the tool by id, and tool invocations. Only rename `toolReferenceName`, never `name`. (See [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) where both `name` and `toolReferenceName` were changed.) -- **Changing the `id` field on TypeScript-registered tools** — same principle as above. The `id` is a stable internal identifier and must never change. -- **Assuming the old name is already in the legacy array** — always verify by reading the actual `legacyToolReferenceFullNames` contents, not just checking that the field exists. A legacy array might list names from an even older rename but not the current one being changed. -- **Removing an old name from the legacy array** — breaks existing saved prompts and user configurations. -- **Forgetting to add the legacy name entirely** — prompt files and tool configs silently stop resolving. -- **Only updating prompt resolution but not other consumers** — auto-approval settings, tool enablement maps, and individual tool checks (like `RunInTerminalTool`) all need to respect legacy names (see [#278506](https://github.com/microsoft/vscode/pull/278506)). diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index abd33957465c49..dd215360b80333 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1092,6 +1092,7 @@ "--modern-ui-editor-tab-unfocused-hover-foreground", "--modern-ui-editor-tab-unfocused-inactive-background", "--modern-ui-editor-tab-unfocused-inactive-foreground", + "--modern-ui-editor-tabs-border", "--modern-ui-floating-card-border-color", "--modern-ui-floating-card-corner-bottom-left-border-image", "--modern-ui-floating-card-corner-bottom-right-border-image", @@ -1114,6 +1115,7 @@ "--modern-ui-shell-background", "--modern-ui-tab-active-background", "--modern-ui-tab-hover-background", + "--search-editor-query-layout-offset", "--scroll-shadow-surface", "--vscode-chat-list-background", "--vscode-chat-persistent-content-height", diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 363800cf662222..06ce024474ab10 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -193,29 +193,29 @@ export class ReadFileTool implements ICopilotTool { if (grepResultMatches !== undefined && grepResultMatches.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { const regions = await this.regionContextProvider.getRegions(documentSnapshot.uri, documentSnapshot.languageId, grepResultMatches, { start: startLine, end: endLine}); if (regions !== undefined && regions.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end); + this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end, documentSnapshot); // const saving = (ranges.end - ranges.start) - (regions[0].range.end - regions[0].range.start); // this.logService.info(`Saving ${saving} lines reading ${documentSnapshot.uri.fsPath}. Requests [${ranges.start}-${ranges.end}], Grep matches: [${grepResultMatches.map(m => m.start.line + 1).join(',')}], region [${regions[0].range.start + 1}-${regions[0].range.end + 1}]`); } else { if (documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions', documentSnapshot); // this.logService.info(`No regions found for grep result match in file ${documentSnapshot.uri.fsPath} at lines [${grepResultMatches.map(m => m.start.line + 1).join(',')}]`); } else { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged', documentSnapshot); // this.logService.info(`Document version changed for requestId ${options.chatRequestId}`); } } } else { if (documentSnapshot.version === documentSnapshot.document.version) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrep'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrep', documentSnapshot); // this.logService.info(`No grep result match found for requestId ${options.chatRequestId}`); } else { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'documentVersionChanged', documentSnapshot); // this.logService.info(`Document version changed for requestId ${options.chatRequestId}`); } } } catch (err) { - this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'exception'); + this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'exception', documentSnapshot); // this.logService.error(`Error processing grep result for requestId ${options.chatRequestId}: ${err}`); } } @@ -388,6 +388,7 @@ export class ReadFileTool implements ICopilotTool { const skillInfo = extensionSkillInfo || (uri && this.customInstructionsService.getSkillInfo(uri)); const fileType = skillInfo ? 'skill' : ''; const nameField = extensionSkillInfo ? extensionSkillInfo.skillName : skillInfo ? getCachedSha256Hash(skillInfo.skillName) : ''; + const languageId = documentSnapshot?.languageId; /* __GDPR__ "readFileToolInvoked" : { @@ -402,7 +403,8 @@ export class ReadFileTool implements ICopilotTool { "isV2": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the tool is a v2 version" }, "isEntireFile": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the entire file was read with v2 params" }, "fileType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The type of file being read" }, - "nameField": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the agent customization. Plain text for extension sources, otherwise hashed." } + "nameField": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the agent customization. Plain text for extension sources, otherwise hashed." }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileToolInvoked', @@ -414,6 +416,7 @@ export class ReadFileTool implements ICopilotTool { isEntireFile: isParamsV2(options.input) && options.input.offset === undefined && options.input.limit === undefined ? 'true' : 'false', fileType, nameField, + languageId, model, }, { @@ -430,7 +433,8 @@ export class ReadFileTool implements ICopilotTool { } } - private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number) { + private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { + const languageId = documentSnapshot.languageId; /* __GDPR__ "readFileRegionAdjusted" : { "owner": "dbaeumer", @@ -439,12 +443,14 @@ export class ReadFileTool implements ICopilotTool { "originalLines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of original lines of the requested region", "isMeasurement": true }, "adjustedLines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of lines after the requested region has been adjusted", "isMeasurement": true }, "deltaStart": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original start line and the adjusted start line", "isMeasurement": true }, - "deltaEnd": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original end line and the adjusted end line", "isMeasurement": true } + "deltaEnd": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The difference between the original end line and the adjusted end line", "isMeasurement": true }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjusted', { requestId: options.chatRequestId, + languageId, }, { originalLines: originalEnd - originalStart + 1, @@ -455,20 +461,22 @@ export class ReadFileTool implements ICopilotTool { ); } - private async sendAdjustingFailedTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, startLine: number, endLine: number, reason: 'noGrep' | 'noGrepRegions' | 'documentVersionChanged' | 'exception') { + private async sendAdjustingFailedTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, startLine: number, endLine: number, reason: 'noGrep' | 'noGrepRegions' | 'documentVersionChanged' | 'exception', documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { /* __GDPR__ "readFileRegionAdjustingFailed" : { "owner": "dbaeumer", "comment": "Information about the failure to adjust the requested region to read", "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, "lines": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of line to read", "isMeasurement": true }, - "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason why adjusting the requested region failed" } + "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason why adjusting the requested region failed" }, + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjustingFailed', { requestId: options.chatRequestId, reason, + languageId: documentSnapshot.languageId, }, { lines: endLine - startLine + 1 } diff --git a/package-lock.json b/package-lock.json index f931ad324db933..64d963b211f625 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@microsoft/mxc-sdk": "0.8.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-38", + "@vscode/codicons": "^0.0.46-39", "@vscode/copilot-api": "^0.5.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -4254,9 +4254,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-38", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-38.tgz", - "integrity": "sha512-7TSR976zFUFwREYjfpY5+dUiAl9LXBDtHAAh2+D+eN5pVgPMoOtIdrddTPLoZ3wcgvWRwRnN41hzWw4Mf2VbAA==", + "version": "0.0.46-39", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-39.tgz", + "integrity": "sha512-AU2AtvsL2AeDwEMYrVsL0ptcTPx6n6uDrlL98Q5LLjJSIqBWq+nzMi8vgpcvSYxHJGWJkZXh4u05DEUp6hR2oA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index 50d2e019695738..a55c68603dc801 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "@microsoft/mxc-sdk": "0.8.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-38", + "@vscode/codicons": "^0.0.46-39", "@vscode/copilot-api": "^0.5.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 92bd7bacbaa711..d002d036726c8a 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-38", + "@vscode/codicons": "^0.0.46-39", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-38", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-38.tgz", - "integrity": "sha512-7TSR976zFUFwREYjfpY5+dUiAl9LXBDtHAAh2+D+eN5pVgPMoOtIdrddTPLoZ3wcgvWRwRnN41hzWw4Mf2VbAA==", + "version": "0.0.46-39", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-39.tgz", + "integrity": "sha512-AU2AtvsL2AeDwEMYrVsL0ptcTPx6n6uDrlL98Q5LLjJSIqBWq+nzMi8vgpcvSYxHJGWJkZXh4u05DEUp6hR2oA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index ca0b95f2ef6265..a54bdbd4ede817 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-38", + "@vscode/codicons": "^0.0.46-39", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 2ace0d1f89647f..ce304ae89468aa 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -767,4 +767,6 @@ export const codiconsLibrary = { micOffCompact: register('mic-off-compact', 0xecf1), copilotDot: register('copilot-dot', 0xecf2), copilotDotCompact: register('copilot-dot-compact', 0xecf3), + layoutDensityCompact: register('layout-density-compact', 0xecf4), + layoutDensityDefault: register('layout-density-default', 0xecf5), } as const; diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index 0c018c2a75be20..2f185f48f093a3 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -1442,6 +1442,12 @@ export interface IDiffEditor extends editorCommon.IEditor { */ updateOptions(newOptions: IDiffEditorOptions): void; + /** + * Restores automatic width-based layout after a temporary inline layout. + * @internal + */ + resetWidthBasedLayout(): void; + /** * @internal */ diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index 1453d666dfa9f1..6672d9647b851c 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -16,7 +16,13 @@ import { URI } from '../../../base/common/uri.js'; import { ICodeEditorService } from './codeEditorService.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { EditorOpenSource } from '../../../platform/editor/common/editor.js'; -import { extractSelection, IExternalOpener, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, OpenOptions, ResolveExternalUriOptions } from '../../../platform/opener/common/opener.js'; +import { defaultExternalUriOpenerId, extractSelection, IExternalOpener, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, OpenOptions, ResolveExternalUriOptions } from '../../../platform/opener/common/opener.js'; + +interface IExternalUriOpenTarget { + readonly sourceUri: URI; + readonly href: string; + readonly validationTarget: URI | string; +} class CommandOpener implements IOpener { @@ -98,6 +104,16 @@ class EditorOpener implements IOpener { } } +function shouldOpenExternal(target: URI | string, options: OpenOptions | undefined): boolean { + return !!options?.openExternal || matchesSomeScheme(target, Schemas.mailto, Schemas.http, Schemas.https, Schemas.vsls); +} + +function shouldUseContributedExternalOpeners(target: URI | string, options: OpenOptions | undefined): boolean { + return !!options?.allowContributedOpeners + && options.allowContributedOpeners !== defaultExternalUriOpenerId + && shouldOpenExternal(target, options); +} + export class OpenerService implements IOpenerService { declare readonly _serviceBrand: undefined; @@ -109,6 +125,7 @@ export class OpenerService implements IOpenerService { private _defaultExternalOpener: IExternalOpener; private readonly _externalOpeners = new LinkedList(); + private readonly _externalResourceOpener: IOpener; constructor( @ICodeEditorService editorService: ICodeEditorService, @@ -131,16 +148,17 @@ export class OpenerService implements IOpenerService { }; // Default opener: any external, maito, http(s), command, and catch-all-editors - this._openers.push({ + this._externalResourceOpener = { open: async (target: URI | string, options?: OpenOptions) => { - if (options?.openExternal || matchesSomeScheme(target, Schemas.mailto, Schemas.http, Schemas.https, Schemas.vsls)) { + if (shouldOpenExternal(target, options)) { // open externally await this._doOpenExternal(target, options); return true; } return false; } - }); + }; + this._openers.push(this._externalResourceOpener); this._openers.push(new CommandOpener(commandService)); this._openers.push(new EditorOpener(editorService)); } @@ -177,18 +195,26 @@ export class OpenerService implements IOpenerService { return false; } - // check with contributed validators + let externalUriOpenTarget: IExternalUriOpenTarget | undefined; + if (shouldUseContributedExternalOpeners(target, options)) { + externalUriOpenTarget = await this._resolveExternalUriOpenTarget(target, options); + if (await this._openWithContributedExternalOpeners(externalUriOpenTarget, options)) { + return true; + } + } + if (!options?.skipValidation) { - const validationTarget = this._resolvedUriTargets.get(targetURI) ?? target; // validate against the original URI that this URI resolves to, if one exists - for (const validator of this._validators) { - if (!(await validator.shouldOpen(validationTarget, options))) { - return false; - } + const validationTarget = externalUriOpenTarget?.validationTarget ?? this._resolvedUriTargets.get(targetURI) ?? target; + if (!(await this._validate(validationTarget, options))) { + return false; } } // check with contributed openers for (const opener of this._openers) { + if (externalUriOpenTarget && opener === this._externalResourceOpener) { + return this._openDefaultExternal(externalUriOpenTarget); + } const handled = await opener.open(target, options); if (handled) { return true; @@ -216,8 +242,7 @@ export class OpenerService implements IOpenerService { throw new Error('Could not resolve external URI: ' + resource.toString()); } - private async _doOpenExternal(resource: URI | string, options: OpenOptions | undefined): Promise { - + private async _resolveExternalUriOpenTarget(resource: URI | string, options: OpenOptions | undefined): Promise { //todo@jrieken IExternalUriResolver should support `uri: URI | string` const uri = typeof resource === 'string' ? URI.parse(resource) : resource; let externalUri: URI; @@ -228,8 +253,9 @@ export class OpenerService implements IOpenerService { externalUri = uri; } + const preserveOriginalString = typeof resource === 'string' && uri.toString() === externalUri.toString(); let href: string; - if (typeof resource === 'string' && uri.toString() === externalUri.toString()) { + if (preserveOriginalString) { // open the url-string AS IS href = resource; } else { @@ -237,20 +263,44 @@ export class OpenerService implements IOpenerService { href = encodeURI(externalUri.toString(true)); } - if (options?.allowContributedOpeners) { - const preferredOpenerId = typeof options?.allowContributedOpeners === 'string' ? options?.allowContributedOpeners : undefined; - for (const opener of this._externalOpeners) { - const didOpen = await opener.openExternal(href, { - sourceUri: uri, - preferredOpenerId, - }, CancellationToken.None); - if (didOpen) { - return true; - } + return { + sourceUri: uri, + href, + validationTarget: preserveOriginalString ? resource : externalUri, + }; + } + + private async _openWithContributedExternalOpeners(target: IExternalUriOpenTarget, options: OpenOptions | undefined): Promise { + const preferredOpenerId = typeof options?.allowContributedOpeners === 'string' ? options.allowContributedOpeners : undefined; + for (const opener of this._externalOpeners) { + const didOpen = await opener.openExternal(target.href, { + sourceUri: target.sourceUri, + preferredOpenerId, + }, CancellationToken.None); + if (didOpen) { + return true; } } - return this._defaultExternalOpener.openExternal(href, { sourceUri: uri }, CancellationToken.None); + return false; + } + + private _openDefaultExternal(target: IExternalUriOpenTarget): Promise { + return this._defaultExternalOpener.openExternal(target.href, { sourceUri: target.sourceUri }, CancellationToken.None); + } + + private async _doOpenExternal(resource: URI | string, options: OpenOptions | undefined): Promise { + const target = await this._resolveExternalUriOpenTarget(resource, options); + return this._openDefaultExternal(target); + } + + private async _validate(resource: URI | string, options: OpenOptions | undefined): Promise { + for (const validator of this._validators) { + if (!(await validator.shouldOpen(resource, options))) { + return false; + } + } + return true; } dispose() { diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts index d264028a749501..604f6cfb23a06c 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts @@ -16,21 +16,6 @@ registerAction2(ToggleCollapseUnchangedRegions); registerAction2(ToggleShowMovedCodeBlocks); registerAction2(ToggleUseInlineViewWhenSpaceIsLimited); -MenuRegistry.appendMenuItem(MenuId.EditorTitle, { - command: { - id: new ToggleUseInlineViewWhenSpaceIsLimited().desc.id, - title: localize('useInlineViewWhenSpaceIsLimited', "Use Inline View When Space Is Limited"), - toggled: ContextKeyExpr.has('config.diffEditor.useInlineViewWhenSpaceIsLimited'), - precondition: ContextKeyExpr.has('isInDiffEditor'), - }, - order: 11, - group: '1_diff', - when: ContextKeyExpr.and( - EditorContextKeys.diffEditorRenderSideBySideInlineBreakpointReached, - ContextKeyExpr.has('isInDiffEditor'), - ), -}); - MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: new ToggleShowMovedCodeBlocks().desc.id, diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts index 7e5f79aacc56a2..fbb679d8d265a3 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable, IObservableWithChange, ISettableObservable, derived, derivedConstOnceDefined, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, IObservableWithChange, ISettableObservable, ITransaction, derived, derivedConstOnceDefined, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; import { Constants } from '../../../../base/common/uint.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { diffEditorDefaultOptions } from '../../../common/config/diffEditor.js'; @@ -18,6 +18,7 @@ export class DiffEditorOptions { public get editorOptions(): IObservableWithChange { return this._options; } private readonly _diffEditorWidth; + private readonly _widthBasedLayout = observableValue<'auto' | 'inline'>(this, 'auto'); private readonly _screenReaderMode; @@ -31,6 +32,13 @@ export class DiffEditorOptions { this._options.read(reader).renderSideBySide && this._diffEditorWidth.read(reader) <= this._options.read(reader).renderSideBySideInlineBreakpoint ); this.renderOverviewRuler = derived(this, reader => this._options.read(reader).renderOverviewRuler); + this.renderSideBySideInAutomaticMode = derived(this, reader => { + if (this.compactMode.read(reader) && this.shouldRenderInlineViewInSmartMode.read(reader)) { + return false; + } + return this._diffEditorWidth.read(reader) > this._options.read(reader).renderSideBySideInlineBreakpoint + || this._screenReaderMode.read(reader); + }); this.renderSideBySide = derived(this, reader => { if (this.compactMode.read(reader)) { if (this.shouldRenderInlineViewInSmartMode.read(reader)) { @@ -39,6 +47,7 @@ export class DiffEditorOptions { } return this._options.read(reader).renderSideBySide + && this._widthBasedLayout.read(reader) === 'auto' && !(this._options.read(reader).useInlineViewWhenSpaceIsLimited && this.couldShowInlineViewBecauseOfSize.read(reader) && !this._screenReaderMode.read(reader)); }); this.readOnly = derived(this, reader => this._options.read(reader).readOnly); @@ -92,7 +101,9 @@ export class DiffEditorOptions { public readonly couldShowInlineViewBecauseOfSize; public readonly renderOverviewRuler; + public readonly renderSideBySideInAutomaticMode; public readonly renderSideBySide; + public readonly temporaryInlineMode = this._widthBasedLayout.map(this, layout => layout === 'inline'); public readonly readOnly; public readonly shouldRenderOldRevertArrows; @@ -123,13 +134,44 @@ export class DiffEditorOptions { public readonly hideUnchangedRegionsMinimumLineCount; public updateOptions(changedOptions: IDiffEditorOptions): void { + const currentOptions = this._options.get(); const newDiffEditorOptions = validateDiffEditorOptions(changedOptions, this._options.get()); - const newOptions = { ...this._options.get(), ...changedOptions, ...newDiffEditorOptions }; - this._options.set(newOptions, undefined, { changedOptions: changedOptions }); + const newOptions = { ...currentOptions, ...changedOptions, ...newDiffEditorOptions }; + transaction(tx => { + if ( + currentOptions.renderSideBySide !== newOptions.renderSideBySide + || currentOptions.useInlineViewWhenSpaceIsLimited !== newOptions.useInlineViewWhenSpaceIsLimited + || currentOptions.renderSideBySideInlineBreakpoint !== newOptions.renderSideBySideInlineBreakpoint + ) { + this._widthBasedLayout.set('auto', tx); + } + this._options.set(newOptions, tx, { changedOptions: changedOptions }); + }); + } + + public setWidth(width: number, smoothResizeStartWidth?: number): void { + const options = this._options.get(); + transaction(tx => { + this._diffEditorWidth.set(width, tx); + if (width <= options.renderSideBySideInlineBreakpoint) { + this._widthBasedLayout.set('auto', tx); + } else if ( + smoothResizeStartWidth !== undefined + && this._widthBasedLayout.get() === 'auto' + && options.renderSideBySide + && options.useInlineViewWhenSpaceIsLimited + && smoothResizeStartWidth <= options.renderSideBySideInlineBreakpoint + && width > options.renderSideBySideInlineBreakpoint + && !this._screenReaderMode.get() + && !(this.compactMode.get() && this.shouldRenderInlineViewInSmartMode.get()) + ) { + this._widthBasedLayout.set('inline', tx); + } + }); } - public setWidth(width: number): void { - this._diffEditorWidth.set(width, undefined); + public resetWidthBasedLayout(tx?: ITransaction): void { + this._widthBasedLayout.set('auto', tx); } private readonly _model; diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index b7c03c8ba4c9b9..f252b68138ccce 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -2,8 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { getWindow, h } from '../../../../base/browser/dom.js'; +import { addDisposableListener, getWindow, h } from '../../../../base/browser/dom.js'; import { IBoundarySashes } from '../../../../base/browser/ui/sash/sash.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; import { findLast } from '../../../../base/common/arraysFind.js'; import { BugIndicatingError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; @@ -206,8 +207,55 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._rootSizeObserver.setAutomaticLayout(options.automaticLayout ?? false); this._options = this._instantiationService.createInstance(DiffEditorOptions, options); + let lastWidth: number | undefined; + let resizeStartWidth: number | undefined; + let windowResizeEventCount = 0; + let resizeWasPointerDriven = false; + let pointerDown = false; + const finishSmoothResize = () => { + if (pointerDown) { + smoothResizeScheduler.schedule(); + return; + } + resizeStartWidth = undefined; + windowResizeEventCount = 0; + resizeWasPointerDriven = false; + }; + const smoothResizeScheduler = this._register(new RunOnceScheduler(finishSmoothResize, 200)); + const targetWindow = getWindow(this._domElement); + this._register(addDisposableListener(targetWindow, 'resize', () => { + windowResizeEventCount++; + smoothResizeScheduler.schedule(); + })); + const onPointerDown = () => pointerDown = true; + const onPointerUp = () => { + if (!pointerDown) { + return; + } + pointerDown = false; + smoothResizeScheduler.cancel(); + finishSmoothResize(); + }; + this._register(addDisposableListener(targetWindow, 'mousedown', onPointerDown, true)); + this._register(addDisposableListener(targetWindow, 'mouseup', onPointerUp, true)); + this._register(addDisposableListener(targetWindow, 'touchstart', onPointerDown, true)); + this._register(addDisposableListener(targetWindow, 'touchend', onPointerUp, true)); + this._register(addDisposableListener(targetWindow, 'touchcancel', onPointerUp, true)); this._register(autorun(reader => { - this._options.setWidth(this._rootSizeObserver.width.read(reader)); + const width = this._rootSizeObserver.width.read(reader); + let smoothResizeStartWidth: number | undefined; + if (lastWidth !== undefined && width !== lastWidth) { + if (resizeStartWidth === undefined) { + resizeStartWidth = lastWidth; + } + resizeWasPointerDriven ||= pointerDown; + if (resizeStartWidth !== undefined && (resizeWasPointerDriven || windowResizeEventCount > 1)) { + smoothResizeStartWidth = resizeStartWidth; + } + smoothResizeScheduler.schedule(); + } + this._options.setWidth(width, smoothResizeStartWidth); + lastWidth = width; })); this._contextKeyService.createKey(EditorContextKeys.isEmbeddedDiffEditor.key, false); @@ -223,6 +271,12 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._register(bindContextKey(EditorContextKeys.diffEditorInlineMode, this._contextKeyService, reader => !this._options.renderSideBySide.read(reader) )); + this._register(bindContextKey(EditorContextKeys.diffEditorTemporaryInlineMode, this._contextKeyService, + reader => this._options.temporaryInlineMode.read(reader) + )); + this._register(bindContextKey(EditorContextKeys.diffEditorAutomaticRenderSideBySide, this._contextKeyService, + reader => this._options.renderSideBySideInAutomaticMode.read(reader) + )); this._register(bindContextKey(EditorContextKeys.hasChanges, this._contextKeyService, reader => (this._diffModel.read(reader)?.diff.read(reader)?.mappings.length ?? 0) > 0 @@ -529,6 +583,12 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { if (this._diffModel.get() !== viewModel?.object) { subtransaction(tx, tx => { const vm = viewModel?.object; + if ( + currentModel?.model.original !== vm?.model.original + || currentModel?.model.modified !== vm?.model.modified + ) { + this._options.resetWidthBasedLayout(tx); + } /** @description DiffEditorWidget.setModel */ observableFromEvent.batchEventsGlobally(tx, () => { this._editors.original.setModel(vm ? vm.model.original : null); @@ -573,6 +633,10 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { get renderSideBySide(): boolean { return this._options.renderSideBySide.get(); } + resetWidthBasedLayout(): void { + this._options.resetWidthBasedLayout(); + } + /** * @deprecated Use `this.getDiffComputationResult().changes2` instead. */ diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index 08a7507a3f5047..51779dfb0d0281 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -14,13 +14,14 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { Range } from '../../../common/core/range.js'; import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; +import { IMultiDiffResourceId } from '../../../common/multiDiffEditor.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { DiffEditorWidget } from '../diffEditor/diffEditorWidget.js'; import './colors.js'; import { DiffEditorItemTemplate } from './diffEditorItemTemplate.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; import { MultiDiffEditorViewModel } from './multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, IMultiDiffResourceId, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState, MultiDiffEditorWidgetImpl } from './multiDiffEditorWidgetImpl.js'; import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; export class MultiDiffEditorWidget extends Disposable { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 89e7b9cad70b1f..3da1bfe43a3393 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -10,7 +10,6 @@ import { IObservable, IReader, ITransaction, autorun, autorunWithStore, constObs import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ContextKeyValue, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -19,6 +18,7 @@ import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IRange } from '../../../common/core/range.js'; import { ISelection, Selection } from '../../../common/core/selection.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; +import { IMultiDiffResourceId } from '../../../common/multiDiffEditor.js'; import { EditorContextKeys } from '../../../common/editorContextKeys.js'; import { ICodeEditor } from '../../editorBrowser.js'; import { CompressedVirtualizedScrollView, ICompressedVirtualizedScrollItem, ICompressedVirtualizedScrollItemContext } from './compressedVirtualizedScrollView.js'; @@ -664,19 +664,6 @@ interface IMultiDiffDocState { selections?: ISelection[]; } -export interface IMultiDiffEditorOptions extends ITextEditorOptions { - viewState?: IMultiDiffEditorOptionsViewState; -} - -export interface IMultiDiffEditorOptionsViewState { - revealData?: { - resource: IMultiDiffResourceId; - range?: IRange; - }; -} - -export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; - export interface IMultiDiffEditorLayoutDebugState { readonly scrollLeft: number; readonly scrollDimensions: { diff --git a/src/vs/editor/common/editorContextKeys.ts b/src/vs/editor/common/editorContextKeys.ts index b5a84585738bbe..64dad4cf807c7c 100644 --- a/src/vs/editor/common/editorContextKeys.ts +++ b/src/vs/editor/common/editorContextKeys.ts @@ -36,6 +36,8 @@ export namespace EditorContextKeys { export const accessibleDiffViewerVisible = new RawContextKey('accessibleDiffViewerVisible', false, nls.localize('accessibleDiffViewerVisible', "Whether the accessible diff viewer is visible")); export const diffEditorRenderSideBySideInlineBreakpointReached = new RawContextKey('diffEditorRenderSideBySideInlineBreakpointReached', false, nls.localize('diffEditorRenderSideBySideInlineBreakpointReached', "Whether the diff editor render side by side inline breakpoint is reached")); export const diffEditorInlineMode = new RawContextKey('diffEditorInlineMode', false, nls.localize('diffEditorInlineMode', "Whether inline mode is active")); + export const diffEditorTemporaryInlineMode = new RawContextKey('diffEditorTemporaryInlineMode', false, nls.localize('diffEditorTemporaryInlineMode', "Whether inline mode is temporarily active after manually resizing the diff editor")); + export const diffEditorAutomaticRenderSideBySide = new RawContextKey('diffEditorAutomaticRenderSideBySide', true, nls.localize('diffEditorAutomaticRenderSideBySide', "Whether automatic diff editor layout would render side by side")); export const diffEditorOriginalWritable = new RawContextKey('diffEditorOriginalWritable', false, nls.localize('diffEditorOriginalWritable', "Whether modified is writable in the diff editor")); export const diffEditorModifiedWritable = new RawContextKey('diffEditorModifiedWritable', false, nls.localize('diffEditorModifiedWritable', "Whether modified is writable in the diff editor")); diff --git a/src/vs/editor/common/multiDiffEditor.ts b/src/vs/editor/common/multiDiffEditor.ts new file mode 100644 index 00000000000000..4a61a9da6c956e --- /dev/null +++ b/src/vs/editor/common/multiDiffEditor.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../base/common/uri.js'; +import type { ITextEditorOptions } from '../../platform/editor/common/editor.js'; +import type { IRange } from './core/range.js'; + +export type IMultiDiffResourceId = { original: URI | undefined; modified: URI | undefined }; + +export interface IMultiDiffEditorOptions extends ITextEditorOptions { + viewState?: IMultiDiffEditorOptionsViewState; +} + +export interface IMultiDiffEditorOptionsViewState { + revealData?: { + resource: IMultiDiffResourceId; + range?: IRange; + }; +} diff --git a/src/vs/editor/test/browser/services/openerService.test.ts b/src/vs/editor/test/browser/services/openerService.test.ts index 2046fc54d4e746..f41628496d5dec 100644 --- a/src/vs/editor/test/browser/services/openerService.test.ts +++ b/src/vs/editor/test/browser/services/openerService.test.ts @@ -13,6 +13,7 @@ import { NullCommandService } from '../../../../platform/commands/test/common/nu import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { matchesScheme, matchesSomeScheme } from '../../../../base/common/network.js'; import { TestThemeService } from '../../../../platform/theme/test/common/testThemeService.js'; +import { defaultExternalUriOpenerId } from '../../../../platform/opener/common/opener.js'; suite('OpenerService', function () { const themeService = new TestThemeService(); @@ -151,6 +152,222 @@ suite('OpenerService', function () { assert.strictEqual(openCount, 2); }); + test('contributed external URI openers run before validators', async function () { + const openerService = new OpenerService(editorService, commandService); + const sourceUri = URI.parse('https://source.example.com'); + const resolvedUri = URI.parse('https://resolved.example.com'); + const calls: string[] = []; + + store.add(openerService.registerExternalUriResolver({ + async resolveExternalUri() { + calls.push('resolve'); + return { resolved: resolvedUri, dispose() { } }; + } + })); + store.add(openerService.registerOpener({ + async open() { + calls.push('opener'); + return false; + } + })); + store.add(openerService.registerValidator({ + shouldOpen() { + calls.push('validate'); + return Promise.resolve(false); + } + })); + store.add(openerService.registerExternalOpener({ + async openExternal(href, context) { + calls.push(`contributed:${href}:${context.sourceUri.toString()}`); + return true; + } + })); + + const didOpen = await openerService.open(sourceUri, { openExternal: true, allowContributedOpeners: true }); + + assert.deepStrictEqual({ + didOpen, + calls, + }, { + didOpen: true, + calls: [ + 'resolve', + `contributed:${resolvedUri.toString()}:${sourceUri.toString()}`, + ], + }); + }); + + test('external URI fallback validates the resolved URI', async function () { + const openerService = new OpenerService(editorService, commandService); + const sourceUri = URI.parse('https://source.example.com'); + const resolvedUri = URI.parse('https://resolved.example.com'); + const calls: string[] = []; + + store.add(openerService.registerExternalUriResolver({ + async resolveExternalUri() { + calls.push('resolve'); + return { resolved: resolvedUri, dispose() { } }; + } + })); + store.add(openerService.registerExternalOpener({ + async openExternal(href, context) { + calls.push(`contributed:${href}:${context.sourceUri.toString()}`); + return false; + } + })); + store.add(openerService.registerValidator({ + shouldOpen(resource) { + calls.push(`validate:${resource.toString()}`); + return Promise.resolve(false); + } + })); + store.add(openerService.registerOpener({ + async open() { + calls.push('opener'); + return true; + } + })); + openerService.setDefaultExternalOpener({ + async openExternal(href) { + calls.push(`default:${href}`); + return true; + } + }); + + const didOpen = await openerService.open(sourceUri, { openExternal: true, allowContributedOpeners: true }); + + assert.deepStrictEqual({ + didOpen, + calls, + }, { + didOpen: false, + calls: [ + 'resolve', + `contributed:${resolvedUri.toString()}:${sourceUri.toString()}`, + `validate:${resolvedUri.toString()}`, + ], + }); + }); + + test('default external URI opener validates before regular openers', async function () { + const openerService = new OpenerService(editorService, commandService); + const sourceUri = URI.parse('https://source.example.com'); + const calls: string[] = []; + + store.add(openerService.registerExternalOpener({ + async openExternal() { + calls.push('contributed'); + return true; + } + })); + store.add(openerService.registerValidator({ + shouldOpen(resource) { + calls.push(`validate:${resource.toString()}`); + return Promise.resolve(false); + } + })); + store.add(openerService.registerOpener({ + async open() { + calls.push('opener'); + return true; + } + })); + + const didOpen = await openerService.open(sourceUri, { openExternal: true, allowContributedOpeners: defaultExternalUriOpenerId }); + + assert.deepStrictEqual({ + didOpen, + calls, + }, { + didOpen: false, + calls: [`validate:${sourceUri.toString()}`], + }); + }); + + test('default external URI opener skips contributed openers', async function () { + const openerService = new OpenerService(editorService, commandService); + const sourceUri = URI.parse('https://source.example.com'); + const calls: string[] = []; + + store.add(openerService.registerExternalOpener({ + async openExternal() { + calls.push('contributed'); + return true; + } + })); + store.add(openerService.registerValidator({ + shouldOpen(resource) { + calls.push(`validate:${resource.toString()}`); + return Promise.resolve(true); + } + })); + store.add(openerService.registerOpener({ + async open() { + calls.push('opener'); + return false; + } + })); + openerService.setDefaultExternalOpener({ + async openExternal(href) { + calls.push(`default:${href}`); + return true; + } + }); + + const didOpen = await openerService.open(sourceUri, { openExternal: true, allowContributedOpeners: defaultExternalUriOpenerId }); + + assert.deepStrictEqual({ + didOpen, + calls, + }, { + didOpen: true, + calls: [ + `validate:${sourceUri.toString()}`, + 'opener', + `default:${sourceUri.toString()}`, + ], + }); + }); + + test('external URI fallback preserves strings for validation and opening', async function () { + const openerService = new OpenerService(editorService, commandService); + const source = 'https://source.example.com/path?value=%2B'; + const calls: string[] = []; + + store.add(openerService.registerExternalOpener({ + async openExternal() { + calls.push('contributed'); + return false; + } + })); + store.add(openerService.registerValidator({ + shouldOpen(resource) { + calls.push(`validate:${resource.toString()}`); + return Promise.resolve(true); + } + })); + openerService.setDefaultExternalOpener({ + async openExternal(href) { + calls.push(`default:${href}`); + return true; + } + }); + + const didOpen = await openerService.open(source, { openExternal: true, allowContributedOpeners: true }); + + assert.deepStrictEqual({ + didOpen, + calls, + }, { + didOpen: true, + calls: [ + 'contributed', + `validate:${source}`, + `default:${source}`, + ], + }); + }); + test('links aren\'t manipulated before being passed to validator: PR #118226', async function () { const openerService = new OpenerService(editorService, commandService); diff --git a/src/vs/editor/test/browser/widget/diffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/diffEditorWidget.test.ts index 19fdb45b585d64..ed01d7d23441b6 100644 --- a/src/vs/editor/test/browser/widget/diffEditorWidget.test.ts +++ b/src/vs/editor/test/browser/widget/diffEditorWidget.test.ts @@ -5,6 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestAccessibilityService } from '../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { DiffEditorOptions } from '../../../browser/widget/diffEditor/diffEditorOptions.js'; import { UnchangedRegion } from '../../../browser/widget/diffEditor/diffEditorViewModel.js'; import { LineRange } from '../../../common/core/ranges/lineRange.js'; import { DetailedLineRangeMapping } from '../../../common/diff/rangeMapping.js'; @@ -13,6 +15,88 @@ suite('DiffEditorWidget2', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('width based layout', () => { + test('commits temporary inline when smoothly enlarging from automatic inline', () => { + const options = new DiffEditorOptions({ + renderSideBySide: true, + renderSideBySideInlineBreakpoint: 900, + useInlineViewWhenSpaceIsLimited: true, + }, new TestAccessibilityService()); + + options.setWidth(1000); + const initiallySideBySide = options.renderSideBySide.get(); + options.setWidth(800, 1000); + const inlineDuringResize = options.renderSideBySide.get(); + const temporaryInlineAfterShrinking = options.temporaryInlineMode.get(); + options.setWidth(1000, 1000); + const restoredDuringResize = options.renderSideBySide.get(); + options.setWidth(800, 1000); + const temporaryInlineAfterEndingNarrow = options.temporaryInlineMode.get(); + options.setWidth(1000, 800); + const wideAfterInlineWasCommitted = options.renderSideBySide.get(); + const temporaryInlineMode = options.temporaryInlineMode.get(); + options.setWidth(800); + const temporaryInlineAfterBecomingNarrow = options.temporaryInlineMode.get(); + options.setWidth(1000, 800); + options.resetWidthBasedLayout(); + const wideAfterResettingAutomatic = options.renderSideBySide.get(); + options.setWidth(800); + const automaticInlineResult = options.renderSideBySideInAutomaticMode.get(); + options.setWidth(1000); + const automaticSideBySideResult = options.renderSideBySideInAutomaticMode.get(); + options.updateOptions({ renderSideBySide: false }); + options.updateOptions({ renderSideBySide: true }); + + assert.deepStrictEqual({ + initiallySideBySide, + inlineDuringResize, + temporaryInlineAfterShrinking, + restoredDuringResize, + temporaryInlineAfterEndingNarrow, + wideAfterInlineWasCommitted, + temporaryInlineMode, + temporaryInlineAfterBecomingNarrow, + wideAfterResettingAutomatic, + automaticInlineResult, + automaticSideBySideResult, + wideAfterExplicitlyRestoringAuto: options.renderSideBySide.get(), + }, { + initiallySideBySide: true, + inlineDuringResize: false, + temporaryInlineAfterShrinking: false, + restoredDuringResize: true, + temporaryInlineAfterEndingNarrow: false, + wideAfterInlineWasCommitted: false, + temporaryInlineMode: true, + temporaryInlineAfterBecomingNarrow: false, + wideAfterResettingAutomatic: true, + automaticInlineResult: false, + automaticSideBySideResult: true, + wideAfterExplicitlyRestoringAuto: true, + }); + }); + + test('keeps auto layout after a non-resize layout change', () => { + const options = new DiffEditorOptions({ + renderSideBySide: true, + renderSideBySideInlineBreakpoint: 900, + useInlineViewWhenSpaceIsLimited: true, + }, new TestAccessibilityService()); + + options.setWidth(800); + const narrow = options.renderSideBySide.get(); + options.setWidth(1000); + + assert.deepStrictEqual({ + narrow, + wideAfterLayoutChange: options.renderSideBySide.get(), + }, { + narrow: false, + wideAfterLayoutChange: true, + }); + }); + }); + suite('UnchangedRegion', () => { function serialize(regions: UnchangedRegion[]): unknown { return regions.map(r => `${r.originalUnchangedRange} - ${r.modifiedUnchangedRange}`); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 2f4046000935f7..68080fa1ac4ef3 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -108,6 +108,7 @@ export class MenuId { static readonly EditorTabsBarShowTabsSubmenu = new MenuId('EditorTabsBarShowTabsSubmenu'); static readonly EditorTabsBarShowTabsZenModeSubmenu = new MenuId('EditorTabsBarShowTabsZenModeSubmenu'); static readonly EditorActionsPositionSubmenu = new MenuId('EditorActionsPositionSubmenu'); + static readonly DiffEditorViewSubmenu = new MenuId('DiffEditorViewSubmenu'); static readonly EditorRenderWhitespaceSubmenu = new MenuId('EditorRenderWhitespaceSubmenu'); static readonly EditorSplitMoveSubmenu = new MenuId('EditorSplitMoveSubmenu'); static readonly ExplorerContext = new MenuId('ExplorerContext'); diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 0fd4811a053b43..b7c44861ec506c 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -220,7 +220,7 @@ a chat URI. New provider code must consume the seams. `register` takes the resolved provenance and whether to check tombstones. Explicit `AgentService.createSession` calls skip the tombstone check and clear any tombstone for that session URI; restore and discovery calls atomically decline to register if the session is or concurrently becomes tombstoned. An explicit row is never rewritten by catalog discovery. A migration-time host-owned marker can correct a previously discovered row back to internal provenance. -Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification when it additively registers the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. +Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification unless the existing host-owned `agentHost.workspaceless` marker proves the session was created by Agent Host; it reads that evidence together with the chat-backing marker before additively registering the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. Discovery is registry-first: Agent Service hands each provider an optional `setKnownSessionsFilter` seam that answers, for a whole candidate set in one registry query, which sessions the host already owns. A provider drops those candidates before any per-session database open, and Copilot additionally skips adoptable legacy classification work (project/Git resolution) while migrate-legacy is off, since those candidates would not be emitted. Agent Service in turn rejects an already-registered candidate before `_isChatBacking()` or any other per-session I/O; provenance of a registered row stays owned by the explicit create/restore paths. Tombstoned sessions are absent from the registry and therefore never reported as known, so an explicitly deleted session still reaches `register`, whose atomic tombstone check declines it. diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationUri.ts b/src/vs/platform/agentHost/common/agentHostCustomizationUri.ts new file mode 100644 index 00000000000000..e3e927df1ffef3 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostCustomizationUri.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { fromAgentHostUri } from './agentHostUri.js'; + +/** + * URI scheme for synthetic built-in customizations that carry discovery and invocation metadata, but no readable file content. + */ +export const AGENT_BUILTIN_CUSTOMIZATION_SCHEME = 'agent-builtin'; + +/** + * Checks raw host URIs and client-wrapped Agent Host URIs for the built-in customization scheme. + */ +export function isAgentBuiltinCustomizationUri(resource: URI): boolean { + return fromAgentHostUri(resource).scheme === AGENT_BUILTIN_CUSTOMIZATION_SCHEME; +} + +export function hasReadableCustomizationContent(resource: URI): boolean { + return !isAgentBuiltinCustomizationUri(resource); +} diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index bcb870a235ec85..0a5b97e59bb5de 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -341,8 +341,18 @@ export function agentMergeEnabledNotice(target: Pick `- ${line}`)].join('\n'); } -/** The transcript notice shown when effective Agent Merge behavior changes. */ -export function agentMergeConfigurationChangedNotice(previous: AgentMergeConfiguration, current: AgentMergeConfiguration): string | undefined { +/** + * Whether a configuration change was made for one session alone, or to the + * defaults every session follows. + */ +export type AgentMergeConfigurationChangeScope = 'session' | 'global'; + +/** + * The transcript notice shown when effective Agent Merge behavior changes. The + * scope is named up front because the same change reads very differently + * depending on whether it was made for this session or for all of them. + */ +export function agentMergeConfigurationChangedNotice(previous: AgentMergeConfiguration, current: AgentMergeConfiguration, scope: AgentMergeConfigurationChangeScope): string | undefined { const changes: string[] = []; if (previous.addressReviews !== current.addressReviews) { changes.push(current.addressReviews @@ -372,10 +382,19 @@ export function agentMergeConfigurationChangedNotice(previous: AgentMergeConfigu : localize('agentMerge.notice.configuration.replyAttribution.disabled', "Replies it posts will no longer identify Agent Merge as the source.")); } return changes.length > 0 - ? [localize('agentMerge.notice.configuration.changed', "Agent Merge settings changed."), '', ...changes.map(change => `- ${change}`)].join('\n') + ? [agentMergeConfigurationChangedHeading(scope), '', ...changes.map(change => `- ${change}`)].join('\n') : undefined; } +function agentMergeConfigurationChangedHeading(scope: AgentMergeConfigurationChangeScope): string { + switch (scope) { + case 'session': + return localize('agentMerge.notice.configuration.changed.session', "Agent Merge settings changed for this session."); + case 'global': + return localize('agentMerge.notice.configuration.changed.global', "Agent Merge default settings changed for all sessions."); + } +} + function agentMergeMergeBehaviorNotice(mergePullRequest: AgentMergeMergePullRequest): string { switch (mergePullRequest) { case 'always': diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 73db21fa94ae78..a2737973c1bcda 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -419,6 +419,7 @@ export interface ISessionDataService { * already exists on disk**. Returns `undefined` when no database has * been created yet, avoiding the side effect of materializing empty * database files during read-only operations like listing sessions. + * Errors other than file-not-found are propagated. */ tryOpenDatabase(session: URI): Promise | undefined>; diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 8021c5efc0cacb..78750049032eed 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -15,7 +15,7 @@ import { IGitHubService } from '../../github/common/githubService.js'; import { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; import { GitHubRequestError } from '../../github/common/githubTransport.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeDisableReason, AgentMergeSessionState, AgentMergeTarget, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice, agentMergeGateFragments, agentMergeMergePullRequestDemotedNotice, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration, resolveMergeMethod, shouldStopMergingAfterAgentChanges } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, AgentMergeConfiguration, AgentMergeConfigurationChangeScope, AgentMergeDisableReason, AgentMergeSessionOverrides, AgentMergeSessionState, AgentMergeTarget, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice, agentMergeGateFragments, agentMergeMergePullRequestDemotedNotice, agentMergeRootConfigSchema, defaultAgentMergeConfiguration, evaluateAgentMerge, readAgentMergeSessionState, resolveAgentMergeConfiguration, resolveMergeMethod, shouldStopMergingAfterAgentChanges } from '../common/agentMerge.js'; import { buildAgentMergePrompt } from '../common/agentMergePrompt.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; @@ -50,6 +50,12 @@ export interface IAgentMergeControllerOptions { readonly getAutonomousSessionConfig: (session: string, config: Readonly>) => Record | undefined; } +/** The configuration a session was last told about, and the overrides it was resolved from. */ +interface IAnnouncedAgentMergeConfiguration { + readonly configuration: AgentMergeConfiguration; + readonly overrides: AgentMergeSessionOverrides | undefined; +} + class AgentMergeRuntime extends Disposable { readonly subscription = this._register(new MutableDisposable()); @@ -103,7 +109,7 @@ export class AgentMergeController extends Disposable { * sync that {@link _disable} triggers cannot post a second, reasonless one. */ private readonly _monitoredSessions = new Set(); - private readonly _announcedConfigurations = new Map(); + private readonly _announcedConfigurations = new Map(); constructor( private readonly _options: IAgentMergeControllerOptions, @@ -304,7 +310,7 @@ export class AgentMergeController extends Disposable { if (announced) { this._postConfigurationChangedNotice(session, agentMerge); } else { - this._announcedConfigurations.set(session, this._getConfiguration(agentMerge)); + this._setAnnouncedConfiguration(session, agentMerge); } } } @@ -705,10 +711,19 @@ export class AgentMergeController extends Disposable { return; } const configuration = this._getConfiguration(agentMerge); - this._announcedConfigurations.set(session, configuration); + this._setAnnouncedConfiguration(session, agentMerge, configuration); this._postNotice(session, AgentSystemNotificationKind.AgentMergeEnabled, agentMergeEnabledNotice(agentMerge.target, configuration)); } + /** + * Records what was last announced for a session, together with the session + * overrides it was resolved from, so the next notice can name the scope of + * the change that produced it. + */ + private _setAnnouncedConfiguration(session: string, agentMerge: AgentMergeSessionState, configuration = this._getConfiguration(agentMerge)): void { + this._announcedConfigurations.set(session, { configuration, overrides: agentMerge.overrides }); + } + private _postConfigurationChangedNotice(session: string, current: AgentMergeSessionState | undefined): void { if (!current?.enabled || !current.target @@ -716,14 +731,16 @@ export class AgentMergeController extends Disposable { || !this._runtimes.has(session)) { return; } - const previousConfiguration = this._announcedConfigurations.get(session); + const announced = this._announcedConfigurations.get(session); const currentConfiguration = this._getConfiguration(current); - if (!previousConfiguration) { - this._announcedConfigurations.set(session, currentConfiguration); + if (!announced) { + this._setAnnouncedConfiguration(session, current, currentConfiguration); return; } - const notice = agentMergeConfigurationChangedNotice(previousConfiguration, currentConfiguration); - this._announcedConfigurations.set(session, currentConfiguration); + // Unchanged session overrides mean the effective change came from defaults, including while the runtime was stopped. + const scope: AgentMergeConfigurationChangeScope = structuralEquals(announced.overrides, current.overrides) ? 'global' : 'session'; + const notice = agentMergeConfigurationChangedNotice(announced.configuration, currentConfiguration, scope); + this._setAnnouncedConfiguration(session, current, currentConfiguration); if (notice) { this._postNotice(session, AgentSystemNotificationKind.AgentMergeConfigurationChanged, notice); } @@ -907,7 +924,7 @@ export class AgentMergeController extends Disposable { this._logService.info(`[AgentMergeController] Turning automatic merge off because a repair turn changed the worktree: session=${session}, repairBaseCommit=${agentMerge.repairBaseCommit}, currentCommit=${currentCommit ?? 'unresolved'}`); this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, agentMergeMergePullRequestDemotedNotice()); const overrides = { ...agentMerge.overrides, mergePullRequest: 'never' } as const; - this._announcedConfigurations.set(session, this._getConfiguration({ ...agentMerge, overrides })); + this._setAnnouncedConfiguration(session, { ...agentMerge, overrides }); this._configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: agentMerge.enabled, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 831af086f011c5..9089c59bf1ff1d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -475,12 +475,13 @@ export class AgentService extends Disposable implements IAgentService { * durable marker write kept failing after a retry in `createChat`. The chat * itself was already created and announced successfully, so this in-process * suppression stands in for the durable marker: it is consulted by - * {@link _isChatBacking} (used by external discovery) and by `listSessions`'s overlay - * filter, so the backing session is still never surfaced as a standalone - * top-level session for the lifetime of this process, even though its - * on-disk marker never persisted. A later successful write (e.g. from a - * differently-timed retry) removes the entry; a stale entry for a since - * deleted session is harmless — that URI is never reachable again. + * {@link _readSessionRegistrationFacts} (used by external discovery) and + * by `listSessions`'s overlay filter, so the backing session is still never + * surfaced as a standalone top-level session for the lifetime of this + * process, even though its on-disk marker never persisted. A later + * successful write (e.g. from a differently-timed retry) removes the entry; + * a stale entry for a since deleted session is harmless — that URI is never + * reachable again. */ private readonly _unpersistedChatBackings = new Set(); @@ -1796,7 +1797,7 @@ export class AgentService extends Disposable implements IAgentService { let registryChanged = false; const untitledExternal: IAgentSessionMetadata[] = []; const modifiedTimeAdvances: { readonly session: URI; readonly modifiedTime: number }[] = []; - const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => { + const results = await Promise.all(chats.map(({ external: reportedExternal, ...metadata }) => discoveryLimiter.queue(async () => { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; try { @@ -1812,10 +1813,16 @@ export class AgentService extends Disposable implements IAgentService { } return false; } - if (isSubagentSession(session.toString()) || await this._isChatBacking(session)) { + if (isSubagentSession(session.toString())) { suppressed++; return false; } + const registrationFacts = await this._readSessionRegistrationFacts(session); + if (registrationFacts.chatBacking) { + suppressed++; + return false; + } + const external = reportedExternal && !registrationFacts.hostCreated; if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, Date.now())) { skippedAsStale++; return false; @@ -1968,9 +1975,9 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Both facts registry backfill needs about a session, from a single database - * open — it asks for both per session, and a large catalogue makes the second - * open the dominant cost of the pass. + * Both facts discovery and registry backfill need about a session, from a + * single database open — they ask for both per session, and a large catalogue + * makes a second open the dominant cost of the pass. */ private async _readSessionRegistrationFacts(session: URI): Promise<{ readonly chatBacking: boolean; readonly hostCreated: boolean }> { if (this._unpersistedChatBackings.has(session.toString())) { @@ -2050,30 +2057,6 @@ export class AgentService extends Disposable implements IAgentService { return known; } - /** - * Whether a session is marked as an internal chat backing, either durably - * or in `_unpersistedChatBackings`. - */ - private async _isChatBacking(session: URI): Promise { - if (this._unpersistedChatBackings.has(session.toString())) { - return true; - } - - try { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return false; - } - try { - return !!(await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)); - } finally { - ref.dispose(); - } - } catch { - return false; - } - } - /** Active list computations and their optional trailing refresh, shared per mode. */ private readonly _inFlightListSessions = new Map(); @@ -6115,10 +6098,10 @@ export class AgentService extends Disposable implements IAgentService { * callers (chat creation / restore) must not fail just because this * durable write did. The write is retried once; if it still fails, the * backing session is added to `_unpersistedChatBackings` so - * `_isChatBacking` (external discovery) and `listSessions`'s overlay filter keep - * suppressing it for the rest of this process's lifetime even without a - * persisted marker. A later successful call for the same session (e.g. a - * retried caller) clears any stale suppression entry. + * `_readSessionRegistrationFacts` (external discovery) and `listSessions`'s + * overlay filter keep suppressing it for the rest of this process's lifetime + * even without a persisted marker. A later successful call for the same + * session (e.g. a retried caller) clears any stale suppression entry. */ private async _markChatBacking(backingSession: URI, chat: URI): Promise { const backingSessionStr = backingSession.toString(); diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts index 54967763c6b8c1..e9d18e96af0e9e 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeBuiltinCommands.ts @@ -5,16 +5,10 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../common/agentHostCustomizationUri.js'; import { CustomizationType } from '../../../common/state/protocol/channels-session/state.js'; import { CustomizationLoadStatus, customizationId, type DirectoryCustomization, type SkillCustomization } from '../../../common/state/sessionState.js'; -/** - * URI scheme for synthetic "built-in" customizations that have no editable - * file on disk. These entries appear in the customization list purely for - * discovery (their name and description); they carry no openable content. - */ -const AGENT_BUILTIN_SCHEME = 'agent-builtin'; - /** * A Claude built-in slash command backed by the Skill tool, used to seed the * **pre-materialize** built-in list. These ship compiled into the Claude @@ -97,7 +91,7 @@ interface IBuiltinSkillEntry { /** * Builds the read-only "Built-in" skills container from resolved * `{ name, description }` entries. Each child is a {@link CustomizationType.Skill} - * on the {@link AGENT_BUILTIN_SCHEME}; the name and description shown in the + * on the {@link AGENT_BUILTIN_CUSTOMIZATION_SCHEME}; the name and description shown in the * list are the discovery information it carries (the entries have no openable * content). Returns `undefined` when there are no entries. */ @@ -107,7 +101,7 @@ function buildBuiltinSkillsContainer(entries: readonly IBuiltinSkillEntry[]): Di } const children: SkillCustomization[] = entries.map(entry => { - const uri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: `/skill/${encodeURIComponent(entry.name)}` }).toString(); + const uri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: `/skill/${encodeURIComponent(entry.name)}` }).toString(); return { type: CustomizationType.Skill, id: customizationId(uri), @@ -117,7 +111,7 @@ function buildBuiltinSkillsContainer(entries: readonly IBuiltinSkillEntry[]): Di }; }); - const containerUri = URI.from({ scheme: AGENT_BUILTIN_SCHEME, path: '/skills' }).toString(); + const containerUri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skills' }).toString(); return { type: CustomizationType.Directory, id: customizationId(containerUri), @@ -157,7 +151,7 @@ export function buildClaudeBuiltinSkillsContainer(diskSkillNames: ReadonlySet | undefined> { const key = this._sanitizedSessionKey(session); const dbPath = URI.joinPath(this._basePath, key, SESSION_DB_FILENAME); - if (!await this._fileService.exists(dbPath)) { - return undefined; + try { + await this._fileService.stat(dbPath); + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; } return this._databases.acquire(key); } diff --git a/src/vs/platform/agentHost/test/common/agentHostCustomizationUri.test.ts b/src/vs/platform/agentHost/test/common/agentHostCustomizationUri.test.ts new file mode 100644 index 00000000000000..9e2c25ceec1671 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostCustomizationUri.test.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME, hasReadableCustomizationContent, isAgentBuiltinCustomizationUri } from '../../common/agentHostCustomizationUri.js'; +import { toAgentHostUri } from '../../common/agentHostUri.js'; + +suite('Agent Host customization URI', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('recognizes raw and client-wrapped built-in customizations', () => { + const builtIn = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skill/init' }); + + assert.deepStrictEqual({ + raw: isAgentBuiltinCustomizationUri(builtIn), + wrapped: isAgentBuiltinCustomizationUri(toAgentHostUri(builtIn, 'remote')), + file: isAgentBuiltinCustomizationUri(URI.file('/workspace/SKILL.md')), + rawReadable: hasReadableCustomizationContent(builtIn), + wrappedReadable: hasReadableCustomizationContent(toAgentHostUri(builtIn, 'remote')), + fileReadable: hasReadableCustomizationContent(URI.file('/workspace/SKILL.md')), + }, { + raw: true, + wrapped: true, + file: false, + rawReadable: false, + wrappedReadable: false, + fileReadable: true, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index 50dbc8854e713e..918687baf5d691 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -288,7 +288,7 @@ suite('Agent Merge gate', () => { ); }); - test('describes effective Agent Merge configuration changes', () => { + test('describes effective Agent Merge configuration changes, and who they apply to', () => { const previous: AgentMergeConfiguration = { ...configuration, mergePullRequest: 'never', @@ -304,15 +304,23 @@ suite('Agent Merge gate', () => { mergeMethod: 'squash', replyAttribution: false, }; - - assert.strictEqual(agentMergeConfigurationChangedNotice(previous, current), [ - 'Agent Merge settings changed.', + const changes = [ 'It will no longer address new pull request review comments or wait for them before merging.', 'It will no longer fix failing CI checks.', 'It will no longer resolve merge conflicts or update a behind branch.', 'It will now merge the pull request automatically when it is ready.', 'It will now squash-merge the pull request.', - ].map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n')); + ]; + const noticeFor = (heading: string) => [heading, ...changes] + .map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n'); + + assert.deepStrictEqual({ + session: agentMergeConfigurationChangedNotice(previous, current, 'session'), + global: agentMergeConfigurationChangedNotice(previous, current, 'global'), + }, { + session: noticeFor('Agent Merge settings changed for this session.'), + global: noticeFor('Agent Merge default settings changed for all sessions.'), + }); }); test('describes an already-bound pull request without claiming disabled review behavior', () => { @@ -336,19 +344,20 @@ suite('Agent Merge gate', () => { test('announces reply-attribution changes only while review replies are enabled', () => { assert.deepStrictEqual({ - enabled: agentMergeConfigurationChangedNotice(configuration, { ...configuration, replyAttribution: false }), + enabled: agentMergeConfigurationChangedNotice(configuration, { ...configuration, replyAttribution: false }, 'session'), reviewsDisabled: agentMergeConfigurationChangedNotice( { ...configuration, addressReviews: false }, { ...configuration, addressReviews: false, replyAttribution: false }, + 'session', ), }, { - enabled: 'Agent Merge settings changed.\n\n- Replies it posts will no longer identify Agent Merge as the source.', + enabled: 'Agent Merge settings changed for this session.\n\n- Replies it posts will no longer identify Agent Merge as the source.', reviewsDisabled: undefined, }); }); test('omits an Agent Merge configuration notice when effective behavior is unchanged', () => { - assert.strictEqual(agentMergeConfigurationChangedNotice(configuration, { ...configuration }), undefined); + assert.strictEqual(agentMergeConfigurationChangedNotice(configuration, { ...configuration }, 'session'), undefined); }); test('only merges automatically when the merge choice is not "never"', () => { diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 02c2bffdef5d75..20c20d7c1820e2 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -627,7 +627,7 @@ suite('AgentMergeController', () => { whilePaused: [{ kind: AgentSystemNotificationKind.AgentMergeConfigurationChanged, content: [ - 'Agent Merge settings changed.', + 'Agent Merge settings changed for this session.', 'It will no longer fix failing CI checks.', 'It will now merge the pull request automatically when it is ready.', 'It will now choose an available merge method automatically.', @@ -635,14 +635,14 @@ suite('AgentMergeController', () => { }, { kind: AgentSystemNotificationKind.AgentMergeConfigurationChanged, content: [ - 'Agent Merge settings changed.', + 'Agent Merge default settings changed for all sessions.', 'It will no longer address new pull request review comments or wait for them before merging.', ].map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n'), }], notices: [{ kind: AgentSystemNotificationKind.AgentMergeConfigurationChanged, content: [ - 'Agent Merge settings changed.', + 'Agent Merge settings changed for this session.', 'It will no longer fix failing CI checks.', 'It will now merge the pull request automatically when it is ready.', 'It will now choose an available merge method automatically.', @@ -650,12 +650,12 @@ suite('AgentMergeController', () => { }, { kind: AgentSystemNotificationKind.AgentMergeConfigurationChanged, content: [ - 'Agent Merge settings changed.', + 'Agent Merge default settings changed for all sessions.', 'It will no longer address new pull request review comments or wait for them before merging.', ].map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n'), }, { kind: AgentSystemNotificationKind.AgentMergeConfigurationChanged, - content: 'Agent Merge settings changed.\n\n- It will no longer resolve merge conflicts or update a behind branch.', + content: 'Agent Merge default settings changed for all sessions.\n\n- It will no longer resolve merge conflicts or update a behind branch.', }], }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 82a1f53f06d4b0..9a87891adb2b81 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3488,6 +3488,34 @@ suite('AgentService (node dispatcher)', () => { })), [{ session: external.toString(), external: true, source: 'discovery' }]); }); + test('discovery keeps a host-created session internal when the provider reports it as external', async () => { + const sessionData = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const hostCreated = AgentSession.uri('copilot', 'host-created'); + const genuineExternal = AgentSession.uri('copilot', 'genuine-external'); + await sessionData.database(hostCreated).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + discoveredChat(hostCreated), + discoveredChat(genuineExternal), + ]); + + assert.deepStrictEqual( + (await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list()).map(entry => ({ + session: entry.session.toString(), + external: entry.external, + source: entry.source, + })).sort((a, b) => a.session.localeCompare(b.session)), + [ + { session: genuineExternal.toString(), external: true, source: 'discovery' }, + { session: hostCreated.toString(), external: false, source: 'restore' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + ); + }); + test('rediscovery advances recency without overwriting durable unread state for an existing external session', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -4473,28 +4501,38 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ externalCalls: agent.externalCalls, legacyCalls: agent.legacyCalls }, { externalCalls: 1, legacyCalls: 1 }); }); - test('one invalid discovered chat does not block sibling registration', async () => { - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); - registerTestAgentProvider(svc, agent); - await svc.listSessions(); + test('a failed discovered-chat database lookup is skipped and can be retried', async () => { const invalid = AgentSession.uri('copilot', 'invalid-discovered'); const valid = AgentSession.uri('copilot', 'valid-discovered'); - const internals = svc as unknown as { _isChatBacking(session: URI): Promise }; - const originalIsChatBacking = internals._isChatBacking.bind(svc); - internals._isChatBacking = async session => { - if (session.toString() === invalid.toString()) { - throw new Error('invalid backing'); - } - return originalIsChatBacking(session); + const sessionData = createPerSessionDataService(); + let failInvalid = true; + const sessionDataService: ISessionDataService = { + ...sessionData.service, + tryOpenDatabase: async session => { + if (failInvalid && session.toString() === invalid.toString()) { + throw new Error('database stat failed'); + } + return sessionData.service.tryOpenDatabase(session); + }, }; + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const register = (chats: readonly IAgentDiscoveredChat[]) => (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, chats); - agent.fireDiscoveredChats([discoveredChat(invalid), discoveredChat(valid)]); - for (let i = 0; i < 50 && (await svc.getRegisteredSessions()).length === 0; i++) { - await timeout(0); - } + await register([discoveredChat(invalid), discoveredChat(valid)]); + const afterFailure = (await svc.getRegisteredSessions()).map(session => session.toString()); + failInvalid = false; + await register([discoveredChat(invalid)]); - assert.deepStrictEqual((await svc.getRegisteredSessions()).map(session => session.toString()), [valid.toString()]); + assert.deepStrictEqual({ + afterFailure, + afterRetry: (await svc.getRegisteredSessions()).map(session => session.toString()).sort(), + }, { + afterFailure: [valid.toString()], + afterRetry: [invalid.toString(), valid.toString()].sort(), + }); }); test('failed discovery announcement releases its deduplication reservation', async () => { @@ -5845,7 +5883,7 @@ suite('AgentService (node dispatcher)', () => { const sessions = await svc.listSessions(); assert.strictEqual(sessions.length, 1); - assert.deepStrictEqual(sessions[0]._meta, { 'vscode.external': true, workspaceless: true }); + assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); }); test('listSessions overlays the adopted-legacy marker so a migrated session keeps its legacy listing', async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6be13468faf901..a431135c28cd6e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6271,6 +6271,42 @@ suite('CopilotAgent', () => { } }); + test('logs the raw SDK client name only for eligible external sessions', async () => { + class RecordingLogService extends NullLogService { + readonly messages: string[] = []; + + override info(message: string, ..._args: unknown[]): void { + this.messages.push(message); + } + } + + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/external-log-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/external-log-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const logService = new RecordingLogService(); + const client = new TestCopilotClient([ + sdkSession('external-cli-log', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('external-autopilot-log', workingDirectory, { clientName: 'github/autopilot', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('rejected-external-log', workingDirectory, { clientName: 'github/cli', modifiedTime: new Date() }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, logService }); + try { + await collectDiscoveredChats(agent); + + assert.deepStrictEqual( + logService.messages.filter(message => message.startsWith('[Copilot] Chat discovery: classified ')).sort(), + [ + `[Copilot] Chat discovery: classified ${AgentSession.uri(agent.id, 'external-autopilot-log').toString()} as external (clientName: github/autopilot)`, + `[Copilot] Chat discovery: classified ${AgentSession.uri(agent.id, 'external-cli-log').toString()} as external (clientName: github/cli)`, + ].sort(), + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('does not surface SDK sessions with an unknown or missing client name', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-cwd-`); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 2ce6b7ca9dfa16..2e2fc1381c23b7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1156,7 +1156,7 @@ suite('CopilotAgentSession', () => { }); }); - test('collects SDK debug logs without process logs', async () => { + test('collects SDK debug logs with process logs', async () => { const { session, mockSession } = await createAgentSession(disposables); const outputDirectory = URI.file('/tmp/agent-host-debug'); @@ -1170,10 +1170,10 @@ suite('CopilotAgentSession', () => { included: [false, false], calls: [{ destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, - include: { events: true, processLogs: false, shellLogs: true }, + include: { events: true, processLogs: true, shellLogs: true }, }, { destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, - include: { events: false, processLogs: false, shellLogs: false }, + include: { events: false, processLogs: true, shellLogs: false }, }], }); }); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts index c51568a5f88658..289db79ec302a9 100644 --- a/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/claudeBuiltinCommands.test.ts @@ -6,12 +6,10 @@ import assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../common/agentHostCustomizationUri.js'; import { CustomizationType } from '../../../common/state/protocol/state.js'; import { buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from '../../../node/claude/customizations/claudeBuiltinCommands.js'; -/** Black-box copy of the (intentionally unexported) built-in URI scheme. */ -const AGENT_BUILTIN_SCHEME = 'agent-builtin'; - suite('claudeBuiltinCommands', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -24,14 +22,14 @@ suite('claudeBuiltinCommands', () => { assert.strictEqual(container.type, CustomizationType.Directory); assert.strictEqual(container.contents, CustomizationType.Skill); assert.strictEqual(container.writable, false); - assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_CUSTOMIZATION_SCHEME); const children = container.children ?? []; assert.ok(children.length > 0, 'expected built-in skills'); for (const child of children) { const uri = URI.parse(child.uri); assert.strictEqual(child.type, CustomizationType.Skill); - assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME, `child ${child.name} should use the agent-builtin scheme`); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_CUSTOMIZATION_SCHEME, `child ${child.name} should use the agent-builtin scheme`); assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); assert.ok(child.description && child.description.length > 0, `child ${child.name} should have a description`); } @@ -65,14 +63,14 @@ suite('claudeBuiltinCommands', () => { assert.strictEqual(container.contents, CustomizationType.Skill); assert.strictEqual(container.writable, false); - assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_SCHEME); + assert.strictEqual(URI.parse(container.uri).scheme, AGENT_BUILTIN_CUSTOMIZATION_SCHEME); const children = container.children ?? []; // The on-disk skill is excluded; the two genuine runtime built-ins remain. const summary = children.map(child => { assert.strictEqual(child.type, CustomizationType.Skill); const uri = URI.parse(child.uri); - assert.strictEqual(uri.scheme, AGENT_BUILTIN_SCHEME); + assert.strictEqual(uri.scheme, AGENT_BUILTIN_CUSTOMIZATION_SCHEME); assert.strictEqual(uri.path, `/skill/${child.name}`, `child ${child.name} should be a /skill/ path`); return { name: child.name, description: child.description }; }); diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index bbb8e9a14cb30d..879fb586b4fc38 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -10,12 +10,21 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; +import { createFileSystemProviderError, FileSystemProviderErrorCode, type IStat } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; import { buildChatUri } from '../../common/state/sessionState.js'; import { SessionDataService } from '../../node/sessionDataService.js'; +class ControllableStatFileSystemProvider extends InMemoryFileSystemProvider { + statError: Error | undefined; + + override stat(resource: URI): Promise { + return this.statError ? Promise.reject(this.statError) : super.stat(resource); + } +} + suite('SessionDataService', () => { const disposables = new DisposableStore(); @@ -100,11 +109,14 @@ suite('SessionDataService — openDatabase ref-counting', () => { const disposables = new DisposableStore(); const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + let fileService: FileService; + let provider: ControllableStatFileSystemProvider; let service: SessionDataService; setup(() => { - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + fileService = disposables.add(new FileService(new NullLogService())); + provider = disposables.add(new ControllableStatFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.inMemory, provider)); service = new SessionDataService(basePath, fileService, new NullLogService(), () => ':memory:'); }); @@ -124,6 +136,16 @@ suite('SessionDataService — openDatabase ref-counting', () => { await ref.object.close(); }); + test('tryOpenDatabase returns undefined only for a missing database', async () => { + const session = AgentSession.uri('copilot', 'strict-existing-test'); + const missing = await service.tryOpenDatabase(session); + const permissionError = createFileSystemProviderError('permission denied', FileSystemProviderErrorCode.NoPermissions); + provider.statError = permissionError; + + assert.strictEqual(missing, undefined); + await assert.rejects(service.tryOpenDatabase(session), error => error === permissionError); + }); + test('multiple references share the same database', async () => { const session = AgentSession.uri('copilot', 'shared-test'); const ref1 = service.openDatabase(session); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 00e12e78007805..3571749ab1e8f6 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -55,6 +55,7 @@ export interface NativeParsedArgs { 'new-window'?: boolean; 'reuse-window'?: boolean; 'agents'?: boolean; + 'session-title'?: string; 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 8a36a25aea5fe4..0cfada5200fe86 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -109,6 +109,7 @@ export const OPTIONS: OptionDescriptions> = { '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.") }, 'agents': { type: 'boolean', cat: 'o', deprecates: ['sessions'], description: localize('agents', "Opens the agents window.") }, + 'session-title': { type: 'string' }, '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/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index 81322829405739..27908c18df45cc 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -11,6 +11,8 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IOpenerService = createDecorator('openerService'); +export const defaultExternalUriOpenerId = 'default'; + export type OpenInternalOptions = { /** @@ -41,6 +43,10 @@ export type OpenInternalOptions = { export type OpenExternalOptions = { readonly openExternal?: boolean; readonly allowTunneling?: boolean; + /** + * Allows contributed external URI openers. These openers are tried before validators, + * which validate the resolved URI only when falling back to the default external opener. + */ readonly allowContributedOpeners?: boolean | string; readonly fromWorkspace?: boolean; readonly skipValidation?: boolean; @@ -82,7 +88,7 @@ export interface IOpenerService { /** * Register a participant that can validate if the URI resource be opened. - * Validators are run before openers. + * Validators run before openers unless contributed external URI openers are enabled. */ registerValidator(validator: IValidator): IDisposable; diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index cd17ad8eec366d..30af118490c207 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -291,7 +291,7 @@ position: relative; } -.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title::after { +.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title:not(.tabs)::after { content: ''; position: absolute; right: var(--vscode-spacing-size100); diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 1da8d04549cd8f..dd4f26558ca1e7 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -39,6 +39,7 @@ import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js'; import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js'; +import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; interface IChatTab { readonly chat: IChat; @@ -142,10 +143,14 @@ export class ChatCompositeBar extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly _commandService: ICommandService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, ) { super(); this._container = $('.chat-composite-bar.session-chat-tabs-bar'); + const updateCompactHeight = () => this._container.classList.toggle('compact-height', this._editorGroupsService.partOptions.tabHeight === 'compact'); + updateCompactHeight(); + this._register(this._editorGroupsService.onDidChangeEditorPartOptions(updateCompactHeight)); // Tabs row — only shown when the group has multiple chats or is split out. this._tabsRow = $('.chat-composite-bar-tabs-row'); diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index f2a394465a4c7a..dd6bc1ad8af62d 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -110,6 +110,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { private _sessionActive = true; /** Whether this group's session is currently visible in the sessions part. */ private _sessionVisible = true; + /** Whether this is the first group in the chat grid's logical order. */ + private _primary = false; /** Index of this group within the persisted layout, written into {@link toJSON}. */ private _serializationIndex = 0; @@ -156,6 +158,9 @@ export class ChatGroupView extends Disposable implements ISerializableView { } setGroupPosition(index: number, count: number): void { + this._primary = index === 0; + this._currentView.value?.setPrimary(this._primary); + if (count <= 1) { this.element.removeAttribute('role'); this.element.removeAttribute('aria-label'); @@ -260,6 +265,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._contentContainer.replaceChildren(view.element, this._remoteHostUnavailableEmptyState.domNode); this._currentView.value = view; currentView.set(view, undefined); + view.setPrimary(this._primary); view.setActive(this._sessionActive); view.setVisible(this._sessionVisible); this._layoutChildren(); diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index b3621e13ea8a15..7ad49b776284ec 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -135,6 +135,14 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + /** + * Notifies the view whether it occupies the first group in the chat grid. + * Session-scoped UI can use this to avoid repeating across split groups. + */ + setPrimary(_primary: boolean): void { + // no-op by default + } + /** * Shows an indeterminate progress bar at the top of this leaf while the * given promise is pending, mirroring how each editor group surfaces diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index c4f5d2bc3fb3b1..5202a1222345f6 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -20,7 +20,7 @@ /* Tabs host: the chat tab strip, shown only when the session has multiple chats. */ .chat-composite-bar.session-chat-tabs-bar { - padding: 0 var(--vscode-spacing-size100); + padding: 0 var(--vscode-spacing-size20); box-sizing: border-box; container-type: inline-size; @@ -165,11 +165,16 @@ .chat-composite-bar-tabs-row { display: flex; align-items: center; - height: 35px; + height: var(--vscode-spacing-size320, 32px); box-sizing: border-box; overflow: hidden; } +.chat-composite-bar.compact-height .chat-composite-bar-tabs-row { + --editor-group-tab-height: var(--vscode-spacing-size200, 20px); + height: var(--vscode-spacing-size280, 28px); +} + .session-view.tabs-replace-header .chat-groups-view.single-group .chat-composite-bar-tabs-row { border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } @@ -201,7 +206,7 @@ display: flex; align-items: center; height: 100%; - min-height: calc(var(--vscode-spacing-size240) + var(--vscode-spacing-size40) * 2); + min-height: calc(var(--editor-group-tab-height, var(--vscode-spacing-size240)) + var(--vscode-spacing-size40) * 2); } .chat-composite-bar-toolbar { diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index 1d11337194c6aa..db42383df20663 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -21,6 +21,95 @@ overflow: hidden; } +.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title.tabs { + --modern-ui-editor-tabs-border: color-mix(in srgb, var(--vscode-activeSessionView-foreground, var(--vscode-agentsPanel-foreground)) 12%, transparent); +} + +:is(.hc-black, .hc-light).agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) .editor-group-container > .title.tabs { + --modern-ui-editor-tabs-border: var(--vscode-contrastBorder); +} + +.agent-sessions-workbench.dock-detail-panel .part.editor:not(.modal-editor-part) > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { + right: var(--vscode-spacing-size20); + left: var(--vscode-spacing-size20); + width: auto; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .browser-root > .browser-navbar { + box-sizing: border-box; + height: var(--vscode-spacing-size320); + padding: var(--vscode-spacing-size20) 0 0 var(--vscode-spacing-size40); + gap: var(--vscode-spacing-size40); +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-tabs-compact-height .browser-root > .browser-navbar { + height: var(--vscode-spacing-size280); +} + +.agent-sessions-workbench .part.editor .browser-welcome-content { + gap: var(--vscode-spacing-size40); +} + +.agent-sessions-workbench .part.editor .browser-welcome-icon { + display: none; +} + +.agent-sessions-workbench .part.editor .browser-welcome-title, +.agent-sessions-workbench .part.editor .browser-welcome-subtitle { + padding: 0; + margin: 0; + font-size: var(--vscode-fontSize-body1); + line-height: normal; +} + +.agent-sessions-workbench .part.editor .browser-welcome-title { + color: var(--vscode-foreground); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.agent-sessions-workbench .part.editor .browser-welcome-subtitle { + color: var(--vscode-descriptionForeground); + font-weight: var(--vscode-fontWeight-regular); +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .search-editor > .query-container { + --search-editor-query-layout-offset: var(--vscode-spacing-size80, 8px); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: var(--vscode-spacing-size40); + box-sizing: border-box; + min-height: var(--vscode-spacing-size320); + margin: 0; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-editorGroupHeader-tabsBorder, var(--vscode-editorGroup-border)); +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-tabs-compact-height .search-editor > .query-container { + min-height: var(--vscode-spacing-size280); + padding-block: var(--vscode-spacing-sizeNone); +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .search-editor > .query-container > .search-widget { + min-width: 0; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .search-editor > .query-container > .includes-excludes { + min-height: 0; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .search-editor > .query-container > .includes-excludes:not(.expanded) > .expand { + position: static; + display: flex; + align-items: center; + justify-content: center; +} + +.agent-sessions-workbench.dock-detail-panel .part.editor .search-editor > .query-container > .includes-excludes.expanded { + grid-column: 1 / -1; + width: 100%; +} + /* Editor Layout Actions Toolbar */ .agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-actions { diff --git a/src/vs/sessions/browser/parts/media/sessionsEmptyState.css b/src/vs/sessions/browser/parts/media/sessionsEmptyState.css new file mode 100644 index 00000000000000..63e2844e0161b7 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sessionsEmptyState.css @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--vscode-spacing-size40); + text-align: center; + word-break: break-word; +} + +.sessions-empty-state-title, +.sessions-empty-state-description { + font-size: var(--vscode-fontSize-body1); + line-height: normal; +} + +.sessions-empty-state-title { + color: var(--vscode-foreground); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.sessions-empty-state-description { + color: var(--vscode-descriptionForeground); + font-weight: var(--vscode-fontWeight-regular); +} diff --git a/src/vs/sessions/browser/parts/sessionsEmptyState.ts b/src/vs/sessions/browser/parts/sessionsEmptyState.ts new file mode 100644 index 00000000000000..131a590aa2a8a5 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionsEmptyState.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsEmptyState.css'; +import * as dom from '../../../base/browser/dom.js'; + +/** + * Appends the shared title and description treatment for Agents Window empty states. + */ +export function renderSessionsEmptyState(parent: HTMLElement, title: string, description: string): HTMLElement { + const container = dom.append(parent, dom.$('.sessions-empty-state')); + + const titleElement = dom.append(container, dom.$('.sessions-empty-state-title')); + titleElement.textContent = title; + + const descriptionElement = dom.append(container, dom.$('.sessions-empty-state-description')); + descriptionElement.textContent = description; + + return container; +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index fa0dea88f8d383..b7ed0dd55b1c54 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -1195,10 +1195,17 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Initialization + private registerEditorTabHeightClass(): void { + const updateCompactHeight = () => this.mainContainer.classList.toggle('editor-tabs-compact-height', this.editorGroupService.partOptions.tabHeight === 'compact'); + updateCompactHeight(); + this._register(this.editorGroupService.onDidChangeEditorPartOptions(updateCompactHeight)); + } + initLayout(accessor: ServicesAccessor): void { // Services - accessing these triggers their instantiation // which creates and registers the parts this.editorGroupService = accessor.get(IEditorGroupsService); + this.registerEditorTabHeightClass(); this.editorService = accessor.get(IEditorService); this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 072b01cfbf5b57..3af91679961cb7 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -64,7 +64,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/ import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IWorkspaceFolderLabelService } from '../../../../workbench/services/workspaces/common/workspaceFolderLabelService.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { getChangesEditorLabels } from './changesEditorLabels.js'; import { ISessionChangesService } from './sessionChangesService.js'; @@ -78,6 +78,7 @@ import { Color } from '../../../../base/common/color.js'; import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; +import { renderSessionsEmptyState } from '../../../browser/parts/sessionsEmptyState.js'; import { ChecksViewModel } from './checksViewModel.js'; import { REVEAL_CI_CHECKS_COMMAND_ID } from './checksActions.js'; // eslint-disable-next-line local/code-import-patterns -- TODO: move skill button constants out of providers @@ -828,8 +829,11 @@ export class ChangesViewPane extends ViewPane { this.welcomeContainer = dom.append(this.contentContainer, $('.changes-welcome')); this.welcomeContainer.style.display = 'none'; - const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); - welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); + renderSessionsEmptyState( + this.welcomeContainer, + localize('changesView.emptyTitle', "Changes"), + localize('changesView.noChanges', "No changed files"), + ); // CI Status widget — bottom pane this.ciStatusWidget = this._register(this.scopedInstantiationService.createInstance(CIStatusWidget, this.splitViewContainer)); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index b8daa42436af5f..5d7d4ea6ef24a4 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -39,13 +39,6 @@ justify-content: center; flex: 1; padding: 32px; - text-align: center; - gap: 8px; -} - -.changes-view-body .changes-welcome-message { - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-label1); } /* Main container */ diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css new file mode 100644 index 00000000000000..297b2904768994 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.editor .session-changes-editor-label.monaco-decoration-badge::after { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + padding: 0 var(--vscode-spacing-size40); + margin: auto 0 auto var(--vscode-spacing-size40); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, transparent); + border-radius: var(--vscode-cornerRadius-small); + background-color: var(--vscode-agentsBadge-background); + color: var(--vscode-agentsBadge-foreground); + font-size: var(--vscode-fontSize-label3); + font-weight: var(--vscode-fontWeight-semiBold); + line-height: normal; + opacity: 1; +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts index c97a4759304d37..2f296da8bebfa9 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -31,9 +31,10 @@ import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/servic import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorOptions, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { MultiDiffEditorLogger } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorLogging.js'; import { IDiffEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IResourceLabel, IWorkbenchUIElementFactory, MultiDiffEditorItemLabelKind } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { Menus } from '../../../browser/menus.js'; diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts index 1d686753d19645..37a319c2551116 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/sessionChangesEditorInput.css'; import { localize } from '../../../../nls.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { Event } from '../../../../base/common/event.js'; +import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; @@ -15,7 +18,8 @@ import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEdi import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { DockedEditorInput } from '../../../common/dockedEditorInput.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { getSessionChangesFileCountLabel } from '../common/changes.js'; +import { ISessionChangesService } from '../common/sessionChangesService.js'; /** * Editor input for the Agents window Changes tab. It wraps the session's @@ -32,6 +36,7 @@ export class SessionChangesEditorInput extends DockedEditorInput { constructor( readonly multiDiffSource: URI, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ISessionChangesService private readonly sessionChangesService: ISessionChangesService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); @@ -40,6 +45,9 @@ export class SessionChangesEditorInput extends DockedEditorInput { this._onDidChangeCapabilities.fire(); } })); + + const onDidChangeCount = Event.fromObservableLight(sessionChangesService.activeSessionChangeCountObs); + this._register(onDidChangeCount(() => this._onDidChangeLabel.fire())); } override get resource(): URI { @@ -63,6 +71,17 @@ export class SessionChangesEditorInput extends DockedEditorInput { return localize('sessionChangesEditor.name', "Changes"); } + override getAriaLabel(): string { + const changeCount = this.sessionChangesService.activeSessionChangeCountObs.get(); + return changeCount === 0 + ? this.getName() + : localize('sessionChangesEditor.ariaLabel', "{0}, {1}", this.getName(), getSessionChangesFileCountLabel(changeCount)); + } + + override getLabelExtraClasses(): string[] { + return ['session-changes-editor-label']; + } + override getIcon(): ThemeIcon { return Codicon.diffMultiple; } diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts index 6d6a5f8302280a..f44ba6ff62479b 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts @@ -3,62 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; -import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../workbench/services/decorations/common/decorations.js'; import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { ISessionChangeset } from '../../../services/sessions/common/session.js'; +import { getSessionChangesFileCountLabel } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; +import { ISessionChangesEditorOptions, ISessionChangesService } from '../common/sessionChangesService.js'; -export const ISessionChangesService = createDecorator('sessionChangesService'); - -/** Options for opening a session Changes editor with an optional changeset selection. */ -export interface ISessionChangesEditorOptions extends IMultiDiffEditorOptions { - readonly changesetSelection?: - | { readonly kind: 'id'; readonly id: string | undefined } - | { readonly kind: 'transient'; readonly changeset: ISessionChangeset }; -} - -/** - * Owns the identity of a session's **Changes** (multi-file diff) editor. It is - * the single source of truth for the `changes-multi-diff-source:` resource that - * the multi-diff editor is opened with, so callers don't have to know the URI - * shape: the session header action and the Changes view open the editor with - * {@link openChangesEditor}, the layout controller recognizes the active - * editor as a Changes editor with {@link getSessionResource}, and the - * multi-diff source resolver uses both. - */ -export interface ISessionChangesService { - readonly _serviceBrand: undefined; - - /** - * Build the multi-diff source URI that identifies the Changes editor for a - * session. Opening an editor with this resource shows the session's changes; - * reusing the same URI reuses the same editor input while the resource list - * updates reactively. - */ - getChangesEditorResource(sessionResource: URI): URI; - - /** - * If the given editor resource identifies a session Changes editor (one built - * by {@link getChangesEditorResource}), return the session it belongs to; - * otherwise `undefined`. - */ - getSessionResource(editorResource: URI): URI | undefined; - - /** - * Open the Changes editor for a session. In the single-pane layout this opens - * the custom {@link SessionChangesEditorInput}; otherwise a plain multi-diff editor. - */ - openChangesEditor(sessionResource: URI, options?: ISessionChangesEditorOptions, group?: PreferredGroup): Promise; -} +export { ISessionChangesService } from '../common/sessionChangesService.js'; +export type { ISessionChangesEditorOptions } from '../common/sessionChangesService.js'; const CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'changes-multi-diff-source'; @@ -66,16 +31,61 @@ interface IChangesMultiDiffUriFields { readonly sessionResource: string; } -export class SessionChangesService implements ISessionChangesService { +export class SessionChangesService extends Disposable implements ISessionChangesService { declare readonly _serviceBrand: undefined; + readonly activeSessionChangeCountObs: IObservable; + + private readonly _onDidChangeDecorations = this._register(new Emitter()); + + private _decoratedChangeCount = 0; + private _decoratedResource: URI | undefined; constructor( @IEditorService private readonly editorService: IEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAgentWorkbenchLayoutService private readonly layoutService: IAgentWorkbenchLayoutService, @IChangesViewService private readonly changesViewService: IChangesViewService, - ) { } + @IDecorationsService decorationsService: IDecorationsService, + ) { + super(); + + this.activeSessionChangeCountObs = derived(this, reader => changesViewService.activeSessionChangesObs.read(reader).length); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + const provider = { + label: localize('sessionChangesEditor.decorations', "Changes"), + onDidChange: this._onDidChangeDecorations.event, + provideDecorations: resource => this._provideDecoration(resource), + } satisfies IDecorationsProvider; + this._register(decorationsService.registerDecorationsProvider(provider)); + + this._register(autorun(reader => { + const activeSessionResource = changesViewService.activeSessionResourceObs.read(reader); + const changeCount = this.activeSessionChangeCountObs.read(reader); + const resource = activeSessionResource ? this.getChangesEditorResource(activeSessionResource) : undefined; + if (isEqual(this._decoratedResource, resource) && this._decoratedChangeCount === changeCount) { + return; + } + + const affectedResources: URI[] = []; + if (this._decoratedResource) { + affectedResources.push(this._decoratedResource); + } + if (resource && !isEqual(this._decoratedResource, resource)) { + affectedResources.push(resource); + } + + this._decoratedResource = resource; + this._decoratedChangeCount = changeCount; + if (affectedResources.length > 0) { + this._onDidChangeDecorations.fire(affectedResources); + } + })); + } getChangesEditorResource(sessionResource: URI): URI { return URI.from({ @@ -134,6 +144,18 @@ export class SessionChangesService implements ISessionChangesService { return pane?.group; } + private _provideDecoration(resource: URI): IDecorationData | undefined { + if (this._decoratedChangeCount === 0 || !isEqual(resource, this._decoratedResource)) { + return undefined; + } + + return { + weight: 100, + letter: this._decoratedChangeCount < 10 ? this._decoratedChangeCount.toString() : '9+', + tooltip: getSessionChangesFileCountLabel(this._decoratedChangeCount), + }; + } + private async expandRevealTarget(input: EditorInput | undefined, options: IMultiDiffEditorOptions | undefined): Promise { const resource = options?.viewState?.revealData?.resource; if (!resource || !(input instanceof SessionChangesEditorInput || input instanceof MultiDiffEditorInput)) { diff --git a/src/vs/sessions/contrib/changes/common/changes.ts b/src/vs/sessions/contrib/changes/common/changes.ts index e66924b7512e70..a4315ef85c31b1 100644 --- a/src/vs/sessions/contrib/changes/common/changes.ts +++ b/src/vs/sessions/contrib/changes/common/changes.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; @@ -24,6 +25,12 @@ export const VIEW_SESSION_CHANGES_COMMAND_ID = 'workbench.agentSessions.action.v */ export const SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING = 'sessions.changes.openSingleFileDiff'; +export function getSessionChangesFileCountLabel(changeCount: number): string { + return changeCount === 1 + ? localize('sessionChangesEditor.oneChangedFile', "1 file") + : localize('sessionChangesEditor.changedFiles', "{0} files", changeCount); +} + export const enum ChangesViewMode { List = 'list', Tree = 'tree' diff --git a/src/vs/sessions/contrib/changes/common/sessionChangesService.ts b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts new file mode 100644 index 00000000000000..2d01b7e0b5bc6b --- /dev/null +++ b/src/vs/sessions/contrib/changes/common/sessionChangesService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { ISessionChangeset } from '../../../services/sessions/common/session.js'; + +export const ISessionChangesService = createDecorator('sessionChangesService'); + +/** Options for opening a session Changes editor with an optional changeset selection. */ +export interface ISessionChangesEditorOptions extends IMultiDiffEditorOptions { + readonly changesetSelection?: + | { readonly kind: 'id'; readonly id: string | undefined } + | { readonly kind: 'transient'; readonly changeset: ISessionChangeset }; +} + +/** Owns the identity and presentation state of a session's Changes editor. */ +export interface ISessionChangesService { + readonly _serviceBrand: undefined; + readonly activeSessionChangeCountObs: IObservable; + + /** Builds the multi-diff source URI that identifies a session's Changes editor. */ + getChangesEditorResource(sessionResource: URI): URI; + + /** Returns the session identified by a Changes editor resource. */ + getSessionResource(editorResource: URI): URI | undefined; + + /** Opens the Changes editor for a session. */ + openChangesEditor(sessionResource: URI, options?: ISessionChangesEditorOptions, group?: PreferredGroup): Promise; +} diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts index 6fff73fd0706bc..c918d70986ff64 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Emitter, Event, ValueWithChangeEvent } from '../../../../../base/common/event.js'; +import { constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -16,6 +17,7 @@ import { MultiDiffEditorInput } from '../../../../../workbench/contrib/multiDiff import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { TestEditorGroupView, workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISessionFileChange } from '../../../../services/sessions/common/session.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; import { ISessionChangesService } from '../../browser/sessionChangesService.js'; @@ -23,6 +25,12 @@ import { IChangesViewService } from '../../common/changesViewService.js'; suite('SessionChangesEditorInput', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const emptyChangesViewService = new class extends mock() { + override readonly activeSessionChangesObs = constObservable([]); + }; + const emptySessionChangesService = new class extends mock() { + override readonly activeSessionChangeCountObs = constObservable(0); + }; test('releases resolved multi-diff models without disposing restorable input state', async () => { const instantiationService = disposables.add(new TestInstantiationService()); @@ -32,6 +40,8 @@ suite('SessionChangesEditorInput', () => { return true; } }); + instantiationService.stub(IChangesViewService, emptyChangesViewService); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); const viewModel = disposables.add(new MultiDiffEditorViewModel({ documents: ValueWithChangeEvent.const([]), }, instantiationService)); @@ -87,9 +97,9 @@ suite('SessionChangesEditorInput', () => { } const instantiationService = workbenchInstantiationService(undefined, disposables); - instantiationService.stub(IChangesViewService, {}); + instantiationService.stub(IChangesViewService, emptyChangesViewService); instantiationService.stub(IAgentWorkbenchLayoutService, {}); - instantiationService.stub(ISessionChangesService, {}); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); instantiationService.stub(IWorkbenchLayoutService, { onDidChangePartVisibility: Event.None, isVisible: () => true, @@ -124,9 +134,9 @@ suite('SessionChangesEditorInput', () => { } const instantiationService = workbenchInstantiationService(undefined, disposables); - instantiationService.stub(IChangesViewService, {}); + instantiationService.stub(IChangesViewService, emptyChangesViewService); instantiationService.stub(IAgentWorkbenchLayoutService, {}); - instantiationService.stub(ISessionChangesService, {}); + instantiationService.stub(ISessionChangesService, emptySessionChangesService); instantiationService.stub(IWorkbenchLayoutService, { onDidChangePartVisibility: Event.None, isVisible: () => true, @@ -155,7 +165,12 @@ suite('SessionChangesEditorInput', () => { return part === Parts.EDITOR_PART && editorVisible; } }; - const input = disposables.add(new SessionChangesEditorInput(URI.parse('test-changes:session'), instantiationService, layoutService)); + const input = disposables.add(new SessionChangesEditorInput( + URI.parse('test-changes:session'), + instantiationService, + emptySessionChangesService, + layoutService, + )); let capabilitiesChanges = 0; disposables.add(input.onDidChangeCapabilities(() => capabilitiesChanges++)); @@ -178,4 +193,54 @@ suite('SessionChangesEditorInput', () => { capabilitiesChanges: 1 }); }); + + test('updates the tab badge class and accessible label with the changed file count', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const changes = observableValue('changes', []); + const layoutService = new class extends mock() { + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } + }; + const resource = URI.parse('test-changes:session'); + const sessionChangesService = new class extends mock() { + override readonly activeSessionChangeCountObs = derived(reader => changes.read(reader).length); + }; + const input = disposables.add(new SessionChangesEditorInput( + resource, + instantiationService, + sessionChangesService, + layoutService, + )); + let labelChanges = 0; + disposables.add(input.onDidChangeLabel(() => labelChanges++)); + + changes.set([createFileChange(1)], undefined); + const oneChangeAriaLabel = input.getAriaLabel(); + changes.set(Array.from({ length: 10 }, (_, index) => createFileChange(index)), undefined); + + assert.deepStrictEqual({ + oneChangeAriaLabel, + tenChangesAriaLabel: input.getAriaLabel(), + labelExtraClasses: input.getLabelExtraClasses(), + labelChanges, + }, { + oneChangeAriaLabel: 'Changes, 1 file', + tenChangesAriaLabel: 'Changes, 10 files', + labelExtraClasses: ['session-changes-editor-label'], + labelChanges: 2, + }); + }); }); + +function createFileChange(index: number): ISessionFileChange { + const uri = URI.file(`/workspace/file${index}.ts`); + return { + uri, + originalUri: uri.with({ scheme: 'git', query: 'ref=base' }), + modifiedUri: uri.with({ scheme: 'git', query: 'ref=head' }), + insertions: 1, + deletions: 0, + }; +} diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts index 83765ea745154a..be3088ecb7e7e8 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesService.test.ts @@ -4,28 +4,38 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { DocumentDiffItemViewModel, MultiDiffEditorViewModel } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ITextDiffEditorPane, isResourceMultiDiffEditorInput } from '../../../../../workbench/common/editor.js'; import { MultiDiffEditorInput } from '../../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { IDecorationsProvider, IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; import { IEditorGroup } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; -import { ISessionChangeset, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; +import { ISessionChangeset, ISessionFileChange, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; -import { SessionChangesService } from '../../browser/sessionChangesService.js'; +import { ISessionChangesService, SessionChangesService } from '../../browser/sessionChangesService.js'; import { IChangesViewService } from '../../common/changesViewService.js'; suite('SessionChangesService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const noActiveSessionResource = constObservable(undefined); + const noChanges = constObservable([]); + const emptyDecorationsService = new class extends mock() { + override registerDecorationsProvider() { + return Disposable.None; + } + }; test('expands a revealed file in both Changes editor layouts', async () => { const originalUri = URI.file('/workspace/file.original.ts'); @@ -81,12 +91,19 @@ suite('SessionChangesService', () => { }(); } }(); - const service = new SessionChangesService( + const changesViewService = new class extends mock() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; + }; + instantiationService.stub(IChangesViewService, changesViewService); + const service = disposables.add(new SessionChangesService( editorService, instantiationService, layoutService, - new class extends mock() { }, - ); + changesViewService, + emptyDecorationsService, + )); + instantiationService.stub(ISessionChangesService, service); await service.openChangesEditor(URI.parse('test-session:/session'), options); } @@ -113,6 +130,8 @@ suite('SessionChangesService', () => { override readonly isSinglePaneLayoutEnabled = false; }(); const changesViewService = new class extends mock() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; override setChangesetId(changesetId: string | undefined): void { selections.push({ changesetId }); } @@ -120,12 +139,13 @@ suite('SessionChangesService', () => { selections.push({ transientChangesetId: changeset.id }); } }(); - const service = new SessionChangesService( + const service = disposables.add(new SessionChangesService( editorService, disposables.add(new TestInstantiationService()), layoutService, changesViewService, - ); + emptyDecorationsService, + )); const sessionResource = URI.parse('agent-host:test-session'); await service.openChangesEditor(sessionResource, { @@ -180,17 +200,95 @@ suite('SessionChangesService', () => { }(); const layoutService = new class extends mock() { override readonly isSinglePaneLayoutEnabled = true; + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } }(); const changesViewService = new class extends mock() { + override readonly activeSessionResourceObs = noActiveSessionResource; + override readonly activeSessionChangesObs = noChanges; override showChangeset(changeset: ISessionChangeset): void { selections.push(changeset.id); } }(); - const service = new SessionChangesService(editorService, instantiationService, layoutService, changesViewService); + instantiationService.stub(IWorkbenchLayoutService, layoutService); + instantiationService.stub(IChangesViewService, changesViewService); + const service = disposables.add(new SessionChangesService(editorService, instantiationService, layoutService, changesViewService, emptyDecorationsService)); + instantiationService.stub(ISessionChangesService, service); await service.openChangesEditor(URI.parse('agent-host:test-session'), { changesetSelection: { kind: 'transient', changeset: upcastPartial({ id: 'turn:request' }) }, }); assert.deepStrictEqual(selections, ['turn:request']); }); + + test('registers one decoration provider across repeated Changes editor opens', async () => { + const sessionResource = URI.parse('agent-host:test-session'); + const activeSessionResource = observableValue('activeSessionResource', undefined); + const changes = observableValue('changes', []); + const changesViewService = new class extends mock() { + override readonly activeSessionResourceObs = activeSessionResource; + override readonly activeSessionChangesObs = changes; + }; + const providers: IDecorationsProvider[] = []; + const decorationsService = new class extends mock() { + override registerDecorationsProvider(provider: IDecorationsProvider) { + providers.push(provider); + return Disposable.None; + } + }; + const layoutService = new class extends mock() { + override readonly isSinglePaneLayoutEnabled = true; + override readonly onDidChangePartVisibility = Event.None; + override isVisible(): boolean { + return true; + } + }; + const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection( + [IWorkbenchLayoutService, layoutService], + [IChangesViewService, changesViewService], + ))); + const editorService = new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + const input = args[0]; + if (input instanceof SessionChangesEditorInput) { + disposables.add(input); + } + return undefined; + } + }; + const service = disposables.add(new SessionChangesService( + editorService, + instantiationService, + layoutService, + changesViewService, + decorationsService, + )); + instantiationService.stub(ISessionChangesService, service); + activeSessionResource.set(sessionResource, undefined); + changes.set([{ + uri: URI.file('/workspace/file.ts'), + insertions: 1, + deletions: 0, + }], undefined); + + await service.openChangesEditor(sessionResource); + await service.openChangesEditor(sessionResource); + await service.openChangesEditor(sessionResource); + + assert.deepStrictEqual({ + providerCount: providers.length, + changeCount: service.activeSessionChangeCountObs.get(), + decoration: providers[0].provideDecorations(service.getChangesEditorResource(sessionResource), CancellationToken.None), + }, { + providerCount: 1, + changeCount: 1, + decoration: { + weight: 100, + letter: '1', + tooltip: '1 file', + }, + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 84ceff4948e607..0affeda1917ba1 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -179,6 +179,8 @@ export class ChatView extends AbstractChatView { /** Whether this view currently represents the active session. */ private _isActive = true; + /** Whether this view occupies the first group in the session's chat grid. */ + private _isPrimary = false; /** Observable mirror of {@link _isActive} so the voice overlay can react. */ private readonly _isActiveObs = observableValue(this, true); @@ -412,7 +414,7 @@ export class ChatView extends AbstractChatView { this.chatPillsDebugService.clear(this._chatPills); const previousSession = this._currentSessionObs.get(); this._currentSessionObs.set(session, undefined); - this._externalSessionBanner.setSession(session); + this._externalSessionBanner.setSession(this._isPrimary ? session : undefined); const resource = chat.resource; const previousChatResource = this._currentChatResource; const chatChanged = !isEqual(previousChatResource, resource); @@ -658,6 +660,14 @@ export class ChatView extends AbstractChatView { } } + override setPrimary(primary: boolean): void { + if (this._isPrimary === primary) { + return; + } + this._isPrimary = primary; + this._externalSessionBanner.setSession(primary ? this._currentSessionObs.get() : undefined); + } + override setActive(active: boolean): void { if (this._isActive === active) { return; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 8b9d5d51bd7d13..1e83b178f923ce 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -251,6 +251,7 @@ .sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker { box-sizing: border-box; width: 22px; + height: 22px; min-width: 22px; padding: 0; } @@ -258,9 +259,10 @@ .sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label { box-sizing: border-box; width: 22px; + height: 22px; min-width: 22px; - padding: 2px 2px 2px 8px; - justify-content: flex-start; + padding: 0; + justify-content: center; } .sessions-chat-config-toolbar .monaco-action-bar .action-item.compact-picker .action-label.model-picker-split { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index c48a05618ed109..85dfa9afa1930c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -253,3 +253,22 @@ .agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-dropdown-label { display: none; } + +.agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-picker-slot, +.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot.compact-picker, +.agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-picker-slot .action-label, +.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot.compact-picker .action-label { + box-sizing: border-box; + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; + justify-content: center; +} + +.agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-picker-slot .action-label > .codicon, +.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot.compact-picker .action-label > .codicon { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 26008a8a05829e..9d75adee29b29a 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -89,6 +89,12 @@ align-self: center; } +/* Flex centering shares attachment growth above and below the midpoint. Offset + * that growth so the bottom controls stay anchored and the composer grows up. */ +.sessions-chat-widget:not(.new-chat-in-session) > .new-chat-widget-container > .new-chat-widget-content { + position: relative; +} + .new-chat-widget-container .new-chat-bottom-container { width: 100%; max-width: 800px; @@ -164,6 +170,7 @@ .agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot { box-sizing: border-box; width: 22px; + height: 22px; min-width: 22px; padding: 0; } @@ -172,9 +179,10 @@ .agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label { box-sizing: border-box; width: 22px; + height: 22px; min-width: 22px; - justify-content: flex-start; - padding: 2px 2px 2px 8px; + justify-content: center; + padding: 0; } .agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .compact-picker.sessions-chat-picker-slot .action-label > .codicon { @@ -220,20 +228,25 @@ display: none; } -/* Icon-only action items in the bottom row (e.g. OpenTelemetry status pill) - * lack the chevron that visually balances the 7px left padding, so use - * symmetric horizontal padding and drop the picker min-width that would - * otherwise leave the icon left-aligned inside a 30px box. */ -.new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .monaco-action-bar .action-item, -.new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .monaco-action-bar .action-item .action-label { - min-width: 0; +/* Icon status actions use the compact control box; text actions remain intrinsic. */ +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .monaco-action-bar .action-item.new-chat-status-icon-action { + width: 22px; + min-width: 22px; } -.new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .action-label { - padding: 3px 4px; +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .new-chat-status-icon-action .action-label { + box-sizing: border-box; display: flex; align-items: center; justify-content: center; + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; +} + +.agent-sessions-workbench:not(.phone-layout) .new-chat-widget-container .new-chat-bottom-container .new-chat-status-toolbar .new-chat-status-icon-action .action-label.codicon { + font-size: var(--vscode-codiconFontSize-compact); } /* The category row is quiet at rest, while hover/open state reveals the active target. */ diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 6ada9420d4d1ed..03955581cd7814 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -98,6 +98,7 @@ import { IChatInputNoticeHubService } from '../../../../workbench/contrib/chat/b import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refreshChatInputStack, setChatInputStackInputFocused, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; +import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; import { ISessionModelSelection, SessionModelSelection } from './sessionModelSelection.js'; @@ -106,6 +107,7 @@ import { ISessionContext, SessionContext } from '../../../services/sessions/brow import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js'; import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../workbench/contrib/chat/browser/chatTerminalCommandPaste.js'; +import { compactCodiconsIn } from '../../../../workbench/contrib/chat/browser/chatIcons.js'; import { IChatPasteTargetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatDynamicVariableModel } from '../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; import { NewChatInputPasteTarget } from './newChatInputPasteTarget.js'; @@ -220,6 +222,8 @@ export function hasSendableNewChatContent(query: string, attachments: readonly I class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { private readonly hoverContentDisposables = this._register(new MutableDisposable()); + private _container: HTMLElement | undefined; + private _compactCodiconClass: string | undefined; constructor( action: MenuItemAction, @@ -238,7 +242,9 @@ class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { } override render(container: HTMLElement): void { + this._container = container; super.render(container); + this._updateIconPresentation(); if (this._commandAction.id !== OTEL_STATUS_COMMAND) { return; @@ -251,6 +257,34 @@ class NewChatInputStatusActionViewItem extends MenuEntryActionViewItem { })); } + protected override updateClass(): void { + if (this._compactCodiconClass) { + this.label?.classList.remove(this._compactCodiconClass); + this._compactCodiconClass = undefined; + } + super.updateClass(); + this._updateIconPresentation(); + } + + private _updateIconPresentation(): void { + const rendersIcon = !!this.label && (this.label.classList.contains('codicon') || this.label.classList.contains('icon')); + this._container?.classList.toggle('new-chat-status-icon-action', rendersIcon); + if (rendersIcon && this._container && this.label?.classList.contains('codicon')) { + const originalCodiconClass = this._getCodiconClass(); + compactCodiconsIn(this._container); + const compactCodiconClass = this._getCodiconClass(); + if (compactCodiconClass !== originalCodiconClass) { + this._compactCodiconClass = compactCodiconClass; + } + } + } + + private _getCodiconClass(): string | undefined { + return this.label + ? [...this.label.classList].find(className => className.startsWith('codicon-') && !className.startsWith('codicon-modifier-')) + : undefined; + } + override async onClick(event: MouseEvent): Promise { if (this._commandAction.id === OTEL_STATUS_COMMAND && this.element) { event.preventDefault(); @@ -444,6 +478,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private readonly _compactModelPicker = observableValue(this, false); private _primaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; private _secondaryPickerResponsiveLayout: ChatInputPickerResponsiveLayout | undefined; + private _updateAttachmentOffset: (() => void) | undefined; // Input state private _draftState: IDraftState | undefined = { @@ -668,6 +703,24 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); this._contextAttachments.renderAttachedContext(attachedContextContainer); + const updateAttachmentOffset = () => { + if (isPhoneLayout(this.layoutService)) { + parent.style.removeProperty('top'); + return; + } + parent.style.top = `${-attachRow.getBoundingClientRect().height / 2}px`; + }; + this._updateAttachmentOffset = updateAttachmentOffset; + const attachmentResizeObserver = this._register(new dom.DisposableResizeObserver( + 'NewChatInputWidget.attachments', + updateAttachmentOffset, + dom.getWindow(attachRow), + )); + this._register(attachmentResizeObserver.observe(attachRow)); + this._register(toDisposable(() => { + this._updateAttachmentOffset = undefined; + parent.style.removeProperty('top'); + })); this._register(this.instantiationService.createInstance(ChatDragAndDrop, () => undefined, { get attachments() { return contextAttachments.attachments; }, addAttachments: (entries: readonly IChatRequestVariableEntry[]) => contextAttachments.addAttachments(...entries), @@ -737,7 +790,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { primaryGroup: () => true }, actionViewItemProvider: (action, options) => { - if (action.id === OTEL_STATUS_COMMAND && action instanceof MenuItemAction) { + if (action instanceof MenuItemAction) { return this.instantiationService.createInstance(NewChatInputStatusActionViewItem, action, options); } return undefined; @@ -1174,13 +1227,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation if (!element) { continue; } + const isModelPicker = element.classList.contains('model-picker-item'); items.push({ element, - canShrink: configToolbar.getItemAction(index)?.id === 'sessions.modelPicker', + canShrink: isModelPicker, isCompact: () => element.classList.contains('compact-picker'), setCompact: (compact: boolean) => { element.classList.toggle('compact-picker', compact); - if (configToolbar.getItemAction(index)?.id === 'sessions.modelPicker') { + if (isModelPicker) { this._compactModelPicker.set(compact, undefined); } }, @@ -1617,6 +1671,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } layout(_height: number, _width: number): void { + this._updateAttachmentOffset?.(); this._editor?.layout(); this._primaryPickerResponsiveLayout?.layout(); this._secondaryPickerResponsiveLayout?.layout(); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts index 7f30a32c3d6ddb..fe8f8a08662beb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatInput.fixture.ts @@ -102,6 +102,8 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { }) }), ResponsiveModelResizeCycleCompact: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The Agents active-session chat input shows its compact model codicon centered with equal padding inside a 22-pixel square control while the model configuration remains visible.'], virtualTime: { enabled: false }, render: context => renderChatInput(sessionsWindowContext(context), { isSessionsWindow: true, @@ -111,6 +113,8 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/input/' }, { }) }), ResponsiveModelResizeCycleMinimal: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The Agents active-session chat input shows compact model and permission codicons centered with equal padding inside matching 22-pixel square controls, aligned with the expanded toolbar height.'], virtualTime: { enabled: false }, render: context => renderChatInput(sessionsWindowContext(context), { isSessionsWindow: true, diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 51a09a38f89071..e5a1b5916e6477 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -14,7 +14,7 @@ import { IChatRequestTranscriptContextVariableEntry } from '../../../../../workb import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ChatView, findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; @@ -50,6 +50,21 @@ suite('Sessions - Chat View', () => { assert.deepStrictEqual(loads, [resource]); }); + test('shows the external session banner only in the primary chat group', () => { + const session = Object.create(null) as ISession; + const bannerSessions: Array = []; + const view = Object.assign(Object.create(ChatView.prototype), { + _isPrimary: true, + _currentSessionObs: observableValue(disposables, session), + _externalSessionBanner: { setSession: (value: ISession | undefined) => bannerSessions.push(value) }, + }) as ChatView; + + view.setPrimary(false); + view.setPrimary(true); + + assert.deepStrictEqual(bannerSessions, [undefined, session]); + }); + test('forwards new chat visibility to the aquarium host', () => { const forwarded: boolean[] = []; const isVisible = observableValue(disposables, true); @@ -84,28 +99,39 @@ suite('Sessions - Chat View', () => { const picker = dom.append(item, dom.$('.action-label.model-picker-split.compact')); const name = dom.append(picker, dom.$('.model-picker-section.model-picker-name')); name.style.minWidth = '22px'; - dom.append(name, dom.$('span.codicon')); + const icon = dom.append(name, dom.$('span.codicon')); + icon.style.width = '12px'; + icon.style.height = '12px'; const config = dom.append(picker, dom.$('.model-picker-section.model-picker-config')); const configLabel = dom.append(config, dom.$('span.chat-input-picker-label')); configLabel.textContent = 'High'; + const nameBounds = name.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); assert.deepStrictEqual({ configVisible: dom.getWindow(configLabel).getComputedStyle(configLabel).display !== 'none', configWidth: config.getBoundingClientRect().width > 0, - nameWidth: name.getBoundingClientRect().width, + name: { width: nameBounds.width, height: nameBounds.height }, + iconOffset: { + x: iconBounds.left - nameBounds.left, + y: iconBounds.top - nameBounds.top, + }, }, { configVisible: true, configWidth: true, - nameWidth: 22, + name: { width: 22, height: 22 }, + iconOffset: { x: 5, y: 5 }, }); }); - test('keeps compact empty-state picker icons inside their action item', () => { - const toolbar = dom.append(document.body, dom.$('.sessions-chat-config-toolbar')); - disposables.add(toDisposable(() => toolbar.remove())); + test('centers compact empty-state picker icons inside their action item', () => { + const inputPart = dom.append(document.body, dom.$('.interactive-input-part')); + disposables.add(toDisposable(() => inputPart.remove())); + const toolbar = dom.append(inputPart, dom.$('.sessions-chat-config-toolbar')); const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); - const label = dom.append(item, dom.$('a.action-label')); + const slot = dom.append(item, dom.$('.sessions-chat-picker-slot')); + const label = dom.append(slot, dom.$('a.action-label')); const icon = dom.append(label, dom.$('span.codicon')); icon.style.width = '12px'; icon.style.height = '12px'; @@ -114,17 +140,22 @@ suite('Sessions - Chat View', () => { const labelBounds = label.getBoundingClientRect(); const iconBounds = icon.getBoundingClientRect(); assert.deepStrictEqual({ - labelOffset: labelBounds.left - itemBounds.left, - iconOffset: iconBounds.left - itemBounds.left, + item: { width: itemBounds.width, height: itemBounds.height }, + label: { width: labelBounds.width, height: labelBounds.height }, + iconOffset: { + x: iconBounds.left - labelBounds.left, + y: iconBounds.top - labelBounds.top, + }, iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, }, { - labelOffset: 0, - iconOffset: 8, + item: { width: 22, height: 22 }, + label: { width: 22, height: 22 }, + iconOffset: { x: 5, y: 5 }, iconEscapes: false, }); }); - test('keeps compact bottom-row picker glyphs inside their action item', () => { + test('centers compact bottom-row picker glyphs inside their action item', () => { const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); disposables.add(toDisposable(() => workbench.remove())); workbench.style.setProperty('--vscode-codiconFontSize-compact', '12px'); @@ -132,31 +163,121 @@ suite('Sessions - Chat View', () => { const row = dom.append(widget, dom.$('.new-chat-bottom-container')); const actionBar = dom.append(row, dom.$('.monaco-action-bar')); const item = dom.append(actionBar, dom.$('.action-item.compact-picker')); - const label = dom.append(item, dom.$('a.action-label')); + const slot = dom.append(item, dom.$('.sessions-chat-picker-slot.compact-picker')); + const label = dom.append(slot, dom.$('a.action-label')); const icon = dom.append(label, dom.$('span.codicon')); icon.style.width = '12px'; icon.style.height = '12px'; const itemBounds = item.getBoundingClientRect(); + const slotBounds = slot.getBoundingClientRect(); const labelBounds = label.getBoundingClientRect(); const iconBounds = icon.getBoundingClientRect(); assert.deepStrictEqual({ - itemWidth: itemBounds.width, - labelWidth: labelBounds.width, - labelOffset: labelBounds.left - itemBounds.left, - iconWidth: iconBounds.width, - iconOffset: iconBounds.left - itemBounds.left, + item: { width: itemBounds.width, height: itemBounds.height }, + slot: { width: slotBounds.width, height: slotBounds.height }, + label: { width: labelBounds.width, height: labelBounds.height }, + icon: { width: iconBounds.width, height: iconBounds.height }, + iconOffset: { + x: iconBounds.left - labelBounds.left, + y: iconBounds.top - labelBounds.top, + }, iconEscapes: iconBounds.left < itemBounds.left || iconBounds.right > itemBounds.right, }, { - itemWidth: 22, - labelWidth: 22, - labelOffset: 0, - iconWidth: 12, - iconOffset: 8, + item: { width: 22, height: 22 }, + slot: { width: 22, height: 22 }, + label: { width: 22, height: 22 }, + icon: { width: 12, height: 12 }, + iconOffset: { x: 5, y: 5 }, iconEscapes: false, }); }); + test('uses the compact control box for bottom-row status icons', () => { + const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + const widget = dom.append(workbench, dom.$('.new-chat-widget-container.revealed')); + const row = dom.append(widget, dom.$('.new-chat-bottom-container')); + const statusToolbar = dom.append(row, dom.$('.new-chat-status-toolbar')); + const actionBar = dom.append(statusToolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item.new-chat-status-icon-action')); + const label = dom.append(item, dom.$('a.action-label.codicon.codicon-warning')); + + const itemBounds = item.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + assert.deepStrictEqual({ + item: { width: itemBounds.width, height: itemBounds.height }, + label: { width: labelBounds.width, height: labelBounds.height }, + iconFontSize: dom.getWindow(label).getComputedStyle(label).fontSize, + }, { + item: { width: 22, height: 22 }, + label: { width: 22, height: 22 }, + iconFontSize: '12px', + }); + }); + + test('leaves text-only bottom-row status actions at their intrinsic width', () => { + const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + const widget = dom.append(workbench, dom.$('.new-chat-widget-container.revealed')); + const row = dom.append(widget, dom.$('.new-chat-bottom-container')); + const statusToolbar = dom.append(row, dom.$('.new-chat-status-toolbar')); + const actionBar = dom.append(statusToolbar, dom.$('.monaco-action-bar')); + const item = dom.append(actionBar, dom.$('.action-item')); + const label = dom.append(item, dom.$('a.action-label')); + label.textContent = 'Status'; + + assert.deepStrictEqual({ + itemIsSquareIconAction: item.classList.contains('new-chat-status-icon-action'), + itemWiderThanCompactControl: item.getBoundingClientRect().width > 22, + labelIsNotClipped: label.scrollWidth <= label.clientWidth, + text: label.textContent, + }, { + itemIsSquareIconAction: false, + itemWiderThanCompactControl: true, + labelIsNotClipped: true, + text: 'Status', + }); + }); + + test('centers compact in-session picker glyphs inside their action item', () => { + const workbench = dom.append(document.body, dom.$('.agent-sessions-workbench')); + disposables.add(toDisposable(() => workbench.remove())); + workbench.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + const session = dom.append(workbench, dom.$('.interactive-session')); + const toolbar = dom.append(session, dom.$('.chat-secondary-input-toolbar')); + const actionBar = dom.append(toolbar, dom.$('.monaco-action-bar')); + const actionsContainer = dom.append(actionBar, dom.$('.actions-container')); + actionsContainer.style.display = 'flex'; + const item = dom.append(actionsContainer, dom.$('.action-item.compact-picker')); + const slot = dom.append(item, dom.$('.sessions-chat-picker-slot')); + const label = dom.append(slot, dom.$('a.action-label')); + const icon = dom.append(label, dom.$('span.codicon')); + dom.append(label, dom.$('span.sessions-chat-dropdown-label', undefined, 'Autopilot')); + + const itemBounds = item.getBoundingClientRect(); + const slotBounds = slot.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + item: { width: itemBounds.width, height: itemBounds.height }, + slot: { width: slotBounds.width, height: slotBounds.height }, + label: { width: labelBounds.width, height: labelBounds.height }, + icon: { + width: iconBounds.width, + height: iconBounds.height, + x: iconBounds.left - labelBounds.left, + y: iconBounds.top - labelBounds.top, + }, + }, { + item: { width: 22, height: 22 }, + slot: { width: 22, height: 22 }, + label: { width: 22, height: 22 }, + icon: { width: 12, height: 12, x: 5, y: 5 }, + }); + }); + test('keeps the voice toolbar visible when picker actions run out of space', () => { const session = dom.append(document.body, dom.$('.interactive-session')); disposables.add(toDisposable(() => session.remove())); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 7356e20a375686..0e688274862205 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { assert } from '../../../../../base/common/assert.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -13,6 +14,10 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { URI } from '../../../../../base/common/uri.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -27,12 +32,15 @@ import { IAICustomizationWorkspaceService } from '../../../../../workbench/contr import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { IChatRequestVariableEntry, toPasteVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IPromptsService } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ChatAgentLocation } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IHistoryService } from '../../../../../workbench/services/history/common/history.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { ISearchService } from '../../../../../workbench/services/search/common/search.js'; -import { registerChatFixtureServices } from '../../../../../workbench/test/browser/componentFixtures/chat/chatFixtureUtils.js'; +import { FixtureMenuService, registerChatFixtureServices } from '../../../../../workbench/test/browser/componentFixtures/chat/chatFixtureUtils.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { activeSessionViewBackground } from '../../../../common/theme.js'; +import { Menus } from '../../../../browser/menus.js'; import { AgentHostFilterConnectionStatus, IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { ISessionsChatBackgroundService } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -51,6 +59,7 @@ import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../../bro import '../../../../browser/media/style.css'; import '../../../../browser/parts/media/sessionView.css'; +import '../../../../browser/parts/mobile/mobileChatShell.css'; const DEFAULT_WIDTH = 800; const DEFAULT_HEIGHT = 560; @@ -71,6 +80,43 @@ interface INewChatWidgetFixtureOptions { readonly openWorkspacePicker?: boolean; readonly openGitHubContextPicker?: boolean; readonly withAttachedContext?: boolean; + readonly withAutoModel?: boolean; + readonly primaryToolbarWidth?: number; + readonly phoneLayout?: boolean; +} + +class AutoModelFixtureMenuService extends FixtureMenuService { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ICommandService commandService: ICommandService, + ) { + super(contextKeyService, commandService); + this.addItem(Menus.NewSessionConfig, { + command: { id: 'sessions.modelPicker', title: 'Model' }, + group: 'navigation', + order: 1, + }); + this.addItem(MenuId.ChatInputStatus, { + command: { id: 'fixture.autopilotStatus', title: 'Autopilot', icon: Codicon.rocket }, + group: 'navigation', + order: 1, + }); + this.addItem(MenuId.ChatInputStatus, { + command: { id: 'fixture.warningStatus', title: 'Warning', icon: Codicon.warning }, + group: 'navigation', + order: 2, + }); + this.addItem(MenuId.ChatInputStatus, { + command: { id: 'fixture.connectionStatus', title: 'Connection', icon: Codicon.radioTower }, + group: 'navigation', + order: 3, + }); + this.addItem(MenuId.ChatInputStatus, { + command: { id: 'fixture.textStatus', title: 'Status' }, + group: 'navigation', + order: 4, + }); + } } /** @@ -99,6 +145,9 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN openWorkspacePicker = false, openGitHubContextPicker = false, withAttachedContext = false, + withAutoModel = false, + primaryToolbarWidth, + phoneLayout = false, } = options; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ id: `feedback-${index}`, @@ -111,7 +160,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN })); const workspace = createFixtureWorkspace(withRemoteWorkspace); const sessionTypes = createFixtureSessionTypes(); - const provider = createFixtureProvider(workspace, sessionTypes); + const provider = createFixtureProvider(workspace, sessionTypes, withAutoModel ? [createFixtureAutoModel()] : []); const activeSession = promptOptions || withWorkspace || withRemoteWorkspace || withAttachedContext ? createFixtureActiveSession(workspace, sessionTypes[0]) : undefined; const activeSessionObservable = observableValue('activeSession', activeSession); const composerService = disposableStore.add(new NewSessionComposerService()); @@ -123,6 +172,9 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN colorTheme: context.theme, additionalServices: reg => { registerChatFixtureServices(reg); + if (withAutoModel) { + reg.define(IMenuService, AutoModelFixtureMenuService); + } reg.defineInstance(IUriIdentityService, new class extends mock() { override readonly extUri = extUri; }()); @@ -267,6 +319,7 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN container.style.width = `${width}px`; container.style.height = `${height}px`; container.classList.add('monaco-workbench', 'agent-sessions-workbench'); + container.classList.toggle('phone-layout', phoneLayout); const sessionView = dom.append(container, dom.$('.session-view.is-active')); sessionView.style.width = '100%'; @@ -282,15 +335,48 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN })); sessionViewContent.appendChild(view.element); view.layout(width, height, 0, 0); + const targetWindow = dom.getWindow(container); + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + await nextFrame(); + await nextFrame(); + if (phoneLayout && withAttachedContext) { + const content = view.element.querySelector('.new-chat-widget-content'); + assert(!!content); + assert(content.style.top === ''); + } + if (withAutoModel) { + const statusItems = [...view.element.querySelectorAll('.new-chat-status-toolbar .action-item')]; + const iconItems = statusItems.filter(item => item.classList.contains('new-chat-status-icon-action')); + assert(iconItems.length === 3); + assert(iconItems.some(item => item.querySelector('.codicon-rocket-compact'))); + assert(iconItems.some(item => item.querySelector('.codicon-warning-compact'))); + + const textLabel = statusItems + .filter(item => !item.classList.contains('new-chat-status-icon-action')) + .map(item => item.querySelector('.action-label')) + .find(label => label?.textContent === 'Status'); + assert(!!textLabel); + assert(textLabel.scrollWidth <= textLabel.clientWidth); + + if (phoneLayout) { + assert(iconItems.every(item => (item.querySelector('.action-label')?.getBoundingClientRect().width ?? 0) > 22)); + } + } + if (primaryToolbarWidth !== undefined) { + const toolbar = view.element.querySelector('.sessions-chat-config-toolbar'); + if (!toolbar) { + throw new Error('Expected the new-session primary toolbar to render.'); + } + toolbar.style.flex = `0 0 ${primaryToolbarWidth}px`; + toolbar.style.width = `${primaryToolbarWidth}px`; + await nextFrame(); + await nextFrame(); + } if (openWorkspacePicker) { - const targetWindow = dom.getWindow(container); - const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); await nextFrame(); await nextFrame(); view.element.querySelector('.sessions-workspace-picker-trigger .action-label')?.click(); } else if (openGitHubContextPicker) { - const targetWindow = dom.getWindow(container); - const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); await nextFrame(); await nextFrame(); view.element.querySelector('[aria-label="Attach a GitHub issue or pull request to the new session"]')?.click(); @@ -315,6 +401,16 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { labels: { kind: 'screenshot' }, render: context => renderNewChatWidget(context, { withWorkspace: true }), }), + NewSessionAutoModel: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session input toolbar shows an Auto model picker whose background fits closely around the Copilot icon and Auto label without excessive empty horizontal space. The bottom row shows optically tuned compact rocket, warning, and connection status icons centered in matching controls, followed by the full Status text action without clipping.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, withAutoModel: true }), + }), + NewSessionCompactAutoModel: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The new-session input toolbar shows the Auto model picker in compact mode as a centered Copilot icon inside a 22-pixel square control aligned with the expanded toolbar height.'], + render: context => renderNewChatWidget(context, { withWorkspace: true, withAutoModel: true, primaryToolbarWidth: 25 }), + }), NewSessionWorkspacePicker: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, expectedVisualDescriptions: ['The new-session composer shows Copilot, microsoft/vscode, and Issue/PR pills. The microsoft/vscode workspace pill has the active treatment after opening the workspace picker.'], @@ -327,9 +423,14 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { }), NewSessionAttachedContext: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, - expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons. The folder icon is fully visible without cropping, and the GitHub issue pill includes an issue icon.'], + expectedVisualDescriptions: ['The new-session workspace row shows Copilot, microsoft/vscode with a count badge showing 2, and Issue/PR with a count badge showing 1. The composer attachment row shows removable docs, microsoft/typescript, and microsoft/vscode#333053 context pills with compact dismiss icons. The input expands upward for the attachment row while its bottom controls remain aligned with the default new-session composer. The folder icon is fully visible without cropping, and the GitHub issue pill includes an issue icon.'], render: context => renderNewChatWidget(context, { withWorkspace: true, withAttachedContext: true }), }), + NewSessionPhoneAttachedContext: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The phone new-session composer shows attachment pills without shifting the full-height content surface upward or leaving a gap below it. Status icons remain touch-friendly pills rather than inheriting the desktop 22-pixel square width.'], + render: context => renderNewChatWidget(context, { width: 390, height: 760, withWorkspace: true, withAttachedContext: true, withAutoModel: true, phoneLayout: true }), + }), NewSessionRemoteWorkspace: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, expectedVisualDescriptions: ['The new-session composer shows Copilot and devbox · microsoft/vscode pills. No Issue/PR pill is visible because the remote workspace has no associated GitHub repository metadata.'], @@ -430,7 +531,7 @@ function createFixtureSessionTypes(): readonly ISessionType[] { ]; } -function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: readonly ISessionType[]): ISessionsProvider { +function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: readonly ISessionType[], models: readonly ILanguageModelChatMetadataAndIdentifier[]): ISessionsProvider { return new class extends mock() { override readonly id = 'fixture-provider'; override readonly label = 'Fixture Provider'; @@ -487,7 +588,7 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado override getModelsSnapshot() { return { - models: [], + models, desiredModelResolution: { kind: 'notRequested' as const }, modelTarget: 'agent-host-copilotcli', }; @@ -507,6 +608,23 @@ function createFixtureProvider(workspace: ISessionWorkspace, sessionTypes: reado }(); } +function createFixtureAutoModel(): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: 'copilot/auto', + metadata: { + extension: new ExtensionIdentifier('github.copilot-chat'), + id: 'auto', + name: 'Auto', + vendor: 'copilot', + version: '1.0', + family: 'auto', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, + }, + }; +} + function createFixtureAttachments(): readonly IChatRequestVariableEntry[] { const issueUri = URI.parse('https://github.com/microsoft/vscode/issues/333053'); return [ diff --git a/src/vs/sessions/contrib/editor/browser/addTabActions.ts b/src/vs/sessions/contrib/editor/browser/addTabActions.ts index b0462f1d840f54..6a11bc6236072a 100644 --- a/src/vs/sessions/contrib/editor/browser/addTabActions.ts +++ b/src/vs/sessions/contrib/editor/browser/addTabActions.ts @@ -122,7 +122,7 @@ export class NewBrowserTabAction extends Action2 { const editorService = accessor.get(IEditorService); const browserInput = browserViewWorkbenchService.getOrCreateLazy({ id: generateUuid() }); - await editorService.openEditor(browserInput); + await editorService.openEditor(browserInput, { pinned: true }); } } diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts index b111af648a2f43..668838a1b004db 100644 --- a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts @@ -6,7 +6,6 @@ import './media/emptyFileEditor.css'; import { $, append, Dimension } from '../../../../base/browser/dom.js'; import { Action } from '../../../../base/common/actions.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -15,6 +14,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { renderSessionsEmptyState } from '../../../browser/parts/sessionsEmptyState.js'; import { CompactButtonActionViewItem } from '../../sessions/browser/sessionsActions.js'; import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; @@ -62,10 +62,11 @@ export class EmptyFileEditor extends EditorPane { const container = append(parent, $('.empty-file-editor')); const content = append(container, $('.empty-file-editor-content')); - append(content, $(`.empty-file-editor-icon${ThemeIcon.asCSSSelector(EmptyFileEditorInput.ICON)}`)); - - const description = append(content, $('.empty-file-editor-description')); - description.textContent = localize('emptyFileEditor.description', "Select a file from the Files view"); + renderSessionsEmptyState( + content, + localize('emptyFileEditor.title', "Files"), + localize('emptyFileEditor.description', "Select a file from the Files view"), + ); const actions = append(content, $('.empty-file-editor-actions')); const action = this._register(this.createSearchAction()); diff --git a/src/vs/sessions/contrib/editor/browser/media/editorHeader.css b/src/vs/sessions/contrib/editor/browser/media/editorHeader.css index 9c40d41d995b9f..1ee2b594d17515 100644 --- a/src/vs/sessions/contrib/editor/browser/media/editorHeader.css +++ b/src/vs/sessions/contrib/editor/browser/media/editorHeader.css @@ -22,11 +22,15 @@ align-items: center; box-sizing: border-box; width: 100%; - min-height: 29px; + min-height: var(--vscode-spacing-size320); overflow: hidden; padding: var(--vscode-spacing-size20, 2px) var(--vscode-spacing-size40, 4px); } +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-container.editor-tabs-compact-height .title > .editor-group-header { + min-height: var(--vscode-spacing-size280); +} + .agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css index 44db96d6c40a09..64ade699ef37ee 100644 --- a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css +++ b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css @@ -22,20 +22,6 @@ max-width: 280px; } -.empty-file-editor-icon.codicon { - font-size: 24px !important; - width: 24px; - height: 24px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; -} - -.empty-file-editor-description { - font-size: var(--vscode-fontSize-body1, 13px); - line-height: 1.4; - color: var(--vscode-descriptionForeground); -} - .empty-file-editor-actions { display: flex; align-items: center; diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index 11c8f1ebe22a7e..30ece1eca420b5 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; @@ -27,6 +28,8 @@ import { IEditorService } from '../../../../../workbench/services/editor/common/ import { IEditorGroup, IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { generateColorThemeCSS } from '../../../../../workbench/services/themes/browser/colorThemeCss.js'; import { ColorThemeData } from '../../../../../workbench/services/themes/common/colorThemeData.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; import { openNewSearchEditor } from '../../../../../workbench/contrib/searchEditor/browser/searchEditorActions.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -34,7 +37,7 @@ import { ISessionWorkspace } from '../../../../services/sessions/common/session. import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; -import { NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from '../../browser/addTabActions.js'; +import { NewBrowserTabAction, NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from '../../browser/addTabActions.js'; import { EmptyFileEditorInput, EmptyFileEditorSerializer } from '../../browser/emptyFileEditorInput.js'; import { EditorTabsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../../workbench/common/contextkeys.js'; import { TestEnvironmentService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; @@ -42,6 +45,16 @@ import { IsQuickChatSessionContext, SinglePaneChangesTabAvailableContext, Single // Import editor contribution to trigger action registration. import '../../browser/editor.contribution.js'; +import '../../../../browser/media/workbench.css'; +import '../../../../browser/parts/media/chatCompositeBar.css'; +import '../../../../browser/parts/media/editorPart.css'; + +function appendElement(parent: HTMLElement, className: string): HTMLElement { + const element = mainWindow.document.createElement('div'); + element.className = className; + parent.appendChild(element); + return element; +} suite('Sessions - Editor Contribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -55,6 +68,118 @@ suite('Sessions - Editor Contribution', () => { assert.strictEqual(css.includes('--modern-ui-editor-tab-active-background: #123456;'), true); }); + test('matches the chat separator with and without the theme border class', () => { + const workbench = appendElement(mainWindow.document.body, 'monaco-workbench modern-ui-tabs agent-sessions-workbench dock-detail-panel'); + workbench.style.setProperty('--vscode-activeSessionView-foreground', 'rgb(100, 100, 100)'); + workbench.style.setProperty('--vscode-agentsPanel-foreground', 'rgb(200, 0, 0)'); + workbench.style.setProperty('--vscode-contrastBorder', 'rgb(255, 255, 255)'); + workbench.style.setProperty('--vscode-spacing-size20', '2px'); + workbench.style.setProperty('--vscode-strokeThickness', '1px'); + + const editorPart = appendElement(workbench, 'part editor'); + const editorContent = appendElement(editorPart, 'content'); + const editorGroupContainer = appendElement(editorContent, 'editor-group-container'); + const title = appendElement(editorGroupContainer, 'title tabs'); + const tabsAndActionsContainer = appendElement(title, 'tabs-and-actions-container'); + + const modalEditorPart = appendElement(workbench, 'part editor modal-editor-part'); + const modalEditorContent = appendElement(modalEditorPart, 'content'); + const modalEditorGroupContainer = appendElement(modalEditorContent, 'editor-group-container'); + const modalTitle = appendElement(modalEditorGroupContainer, 'title tabs'); + const modalTabsAndActionsContainer = appendElement(modalTitle, 'tabs-and-actions-container'); + + const sessionView = appendElement(workbench, 'session-view tabs-replace-header'); + sessionView.style.setProperty('--session-view-foreground', 'rgb(100, 100, 100)'); + const chatGroupsView = appendElement(sessionView, 'chat-groups-view single-group'); + const chatBar = appendElement(chatGroupsView, 'chat-composite-bar session-chat-tabs-bar'); + const chatTabsRow = appendElement(chatBar, 'chat-composite-bar-tabs-row'); + + const expectedColorReference = appendElement(workbench, 'expected-color-reference'); + expectedColorReference.style.color = 'color-mix(in srgb, rgb(100, 100, 100) 12%, transparent)'; + + try { + const getSidePanelSeparatorStyles = () => { + const style = mainWindow.getComputedStyle(tabsAndActionsContainer, '::after'); + return { + color: style.backgroundColor, + leftInset: style.left, + rightInset: style.right, + width: style.height, + }; + }; + const chatBarStyle = mainWindow.getComputedStyle(chatBar); + const chatTabsRowStyle = mainWindow.getComputedStyle(chatTabsRow); + const chatSeparatorStyles = { + color: chatTabsRowStyle.borderBottomColor, + leftInset: chatBarStyle.paddingLeft, + rightInset: chatBarStyle.paddingRight, + width: chatTabsRowStyle.borderBottomWidth, + }; + const expectedColor = mainWindow.getComputedStyle(expectedColorReference).color; + const withoutThemeBorderClass = getSidePanelSeparatorStyles(); + + tabsAndActionsContainer.classList.add('tabs-border-bottom'); + tabsAndActionsContainer.style.setProperty('--tabs-border-bottom-color', 'rgb(200, 0, 0)'); + const withThemeBorderClass = getSidePanelSeparatorStyles(); + + workbench.classList.add('hc-black'); + const highContrast = getSidePanelSeparatorStyles(); + const highContrastChatColor = mainWindow.getComputedStyle(chatTabsRow).borderBottomColor; + const modalTitleStyle = mainWindow.getComputedStyle(modalTitle); + const modalSeparatorStyle = mainWindow.getComputedStyle(modalTabsAndActionsContainer, '::after'); + + assert.deepStrictEqual({ + expectedColorIsTransparent: expectedColor === 'rgba(0, 0, 0, 0)', + withoutThemeBorderClass, + withThemeBorderClass, + chatSeparatorStyles, + highContrast, + highContrastChatColor, + hasDuplicateTitleSeparator: mainWindow.getComputedStyle(title, '::after').content !== 'none', + modal: { + borderColor: modalTitleStyle.getPropertyValue('--modern-ui-editor-tabs-border'), + leftInset: modalSeparatorStyle.left, + rightInset: modalSeparatorStyle.right, + }, + }, { + expectedColorIsTransparent: false, + withoutThemeBorderClass: { + color: expectedColor, + leftInset: '2px', + rightInset: '2px', + width: '1px', + }, + withThemeBorderClass: { + color: expectedColor, + leftInset: '2px', + rightInset: '2px', + width: '1px', + }, + chatSeparatorStyles: { + color: expectedColor, + leftInset: '2px', + rightInset: '2px', + width: '1px', + }, + highContrast: { + color: 'rgb(255, 255, 255)', + leftInset: '2px', + rightInset: '2px', + width: '1px', + }, + highContrastChatColor: 'rgb(255, 255, 255)', + hasDuplicateTitleSeparator: false, + modal: { + borderColor: 'transparent', + leftInset: '0px', + rightInset: '0px', + }, + }); + } finally { + workbench.remove(); + } + }); + function stubEditorGroupCount(instantiationService: TestInstantiationService, count: number): void { instantiationService.stub(IEditorGroupsService, new class extends mock() { override get mainPart(): IEditorGroupsService['mainPart'] { @@ -123,6 +248,30 @@ suite('Sessions - Editor Contribution', () => { })), [{ isEmptyFileEditor: true, resource: workspaceFolder.toString(), pinned: true, index: 7 }]); }); + test('new browser tab action opens a pinned browser editor', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const browserInput = new class extends mock() { }; + const opened: { editor: unknown; options: IEditorOptions | undefined }[] = []; + instantiationService.stub(IBrowserViewWorkbenchService, new class extends mock() { + override getOrCreateLazy(): BrowserEditorInput { + return browserInput; + } + }); + instantiationService.stub(IEditorService, new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + opened.push({ editor: args[0], options: args[1] as IEditorOptions | undefined }); + return undefined; + } + }); + + await new NewBrowserTabAction().run(instantiationService); + + assert.deepStrictEqual(opened.map(({ editor, options }) => ({ + isBrowserEditor: editor === browserInput, + pinned: options?.pinned, + })), [{ isBrowserEditor: true, pinned: true }]); + }); + test('Add Tab menu stays available in dock-only mode', () => { const getWhen = (action: NewFileTabAction | NewChangesTabAction | NewSearchTabAction): ContextKeyExpression => { const menu = action.desc.menu; diff --git a/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts b/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts index 721aca0ecdce42..779ff7f2f9a84c 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts @@ -80,12 +80,12 @@ MenuRegistry.appendMenuItem(addTabMenu, { group: 'navigation', }); -function renderHeader(ctx: ComponentFixtureContext, breadcrumbs: boolean, primaryAction: boolean, secondaryAction = false, layoutActions = false, showTabs: 'multiple' | 'single' | 'none' = 'multiple', addTab = false): void { +function renderHeader(ctx: ComponentFixtureContext, breadcrumbs: boolean, primaryAction: boolean, secondaryAction = false, layoutActions = false, showTabs: 'multiple' | 'single' | 'none' = 'multiple', addTab = false, tabHeight: 'default' | 'compact' = 'default'): void { ctx.container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); renderEditorTabBarFixture(ctx, { modernUI: true, - partOptions: { showTabs }, + partOptions: { showTabs, tabHeight }, breadcrumbs: breadcrumbs ? { filePath: 'on', icons: true } : undefined, showHeader: true, headerMenuIds: { @@ -98,7 +98,8 @@ function renderHeader(ctx: ComponentFixtureContext, breadcrumbs: boolean, primar } export default defineThemedFixtureGroup({ path: 'sessions/editorHeader/' }, { - FullHeader: defineComponentFixture({ render: ctx => renderHeader(ctx, true, true, true, true) }), + FullHeader: defineComponentFixture({ render: ctx => renderHeader(ctx, true, true, true, true), additionalThemes: ['darkHighContrast'] }), + CompactFullHeader: defineComponentFixture({ render: ctx => renderHeader(ctx, true, true, true, true, 'multiple', false, 'compact'), additionalThemes: ['darkHighContrast'] }), BreadcrumbsAndAction: defineComponentFixture({ render: ctx => renderHeader(ctx, true, true) }), BreadcrumbsAndSecondaryAction: defineComponentFixture({ render: ctx => renderHeader(ctx, true, false, true) }), BreadcrumbsOnly: defineComponentFixture({ render: ctx => renderHeader(ctx, true, false) }), diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index f6e92582361788..e6948f1506f58e 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -25,9 +25,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; -import { IURLService } from '../../../../platform/url/common/url.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { Menus } from '../../../browser/menus.js'; import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -66,9 +64,6 @@ interface IPullRequestListEntry extends IGitHubReferenceListEntry { // --- Open Pull Request action -const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; -const openPullRequestWebviewPath = '/open-pull-request-webview'; - class PullRequestActionContext { constructor(readonly pullRequest: IGitHubPullRequestRef) { } } @@ -119,25 +114,8 @@ class OpenPullRequestAction extends Action2 { return; } - const extensionService = accessor.get(IExtensionService); - const urlService = accessor.get(IURLService); const openerService = accessor.get(IOpenerService); - if (await extensionService.getExtension(githubPullRequestsExtensionId)) { - const uri = urlService.create({ - authority: githubPullRequestsExtensionId, - path: openPullRequestWebviewPath, - query: JSON.stringify({ - owner: pullRequest.owner, - repo: pullRequest.repo, - pullRequestNumber: pullRequest.number, - }), - }); - if (await urlService.open(uri, { trusted: true })) { - return; - } - } - - await openerService.open(pullRequest.uri, { openExternal: true }); + await openerService.open(pullRequest.uri, { openExternal: true, allowContributedOpeners: true }); } } registerAction2(OpenPullRequestAction); diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts index eecfad0ba4ffe1..983af869ce5caa 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts @@ -6,17 +6,14 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable } from '../../../../../base/common/observable.js'; -import { URI, UriComponents } from '../../../../../base/common/uri.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; -import { IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { IOpenURLOptions, IURLService } from '../../../../../platform/url/common/url.js'; -import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasPullRequestContext } from '../../../../common/contextkeys.js'; import { IGitHubPullRequestRef, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; @@ -54,24 +51,15 @@ function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullReque }; } -class TestURLService extends mock() { - readonly opened: { readonly uri: URI; readonly options: IOpenURLOptions | undefined }[] = []; - - override create(options?: Partial): URI { - return URI.from({ scheme: 'code-oss', ...options }); - } - - override async open(uri: URI, options?: IOpenURLOptions): Promise { - this.opened.push({ uri, options }); - return true; - } -} - class TestOpenerService extends mock() { - readonly opened: { readonly resource: URI; readonly openExternal: boolean | undefined }[] = []; + readonly opened: { readonly resource: URI; readonly openExternal: boolean | undefined; readonly allowContributedOpeners: boolean | string | undefined }[] = []; - override async open(resource: URI, options?: { readonly openExternal?: boolean }): Promise { - this.opened.push({ resource, openExternal: options?.openExternal }); + override async open(resource: URI, options?: { readonly openExternal?: boolean; readonly allowContributedOpeners?: boolean | string }): Promise { + this.opened.push({ + resource, + openExternal: options?.openExternal, + allowContributedOpeners: options?.allowContributedOpeners, + }); return true; } } @@ -137,63 +125,24 @@ suite('Pull Request Actions', () => { assert.deepStrictEqual(clipboardService.writes, []); }); - test('Open Pull Request opens the pull request URL externally when the GitHub Pull Requests extension is unavailable', async () => { + test('Open Pull Request allows contributed external URI openers', async () => { const pullRequestUri = URI.parse('https://github.com/owner/repo/pull/1'); const session = createSessionWithPullRequest(pullRequestUri); const instantiationService = new TestInstantiationService(); - const urlService = new TestURLService(); const openerService = new TestOpenerService(); - instantiationService.stub(IExtensionService, new class extends mock() { - override async getExtension(): Promise { - return undefined; - } - }); instantiationService.stub(IOpenerService, openerService); - instantiationService.stub(IURLService, urlService); instantiationService.stub(ISessionsService, new class extends mock() { override readonly activeSession = constObservable(undefined); }); await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); - assert.deepStrictEqual({ - handledUris: urlService.opened, - opened: openerService.opened, - }, { - handledUris: [], - opened: [{ resource: pullRequestUri, openExternal: true }], - }); - }); - - test('Open Pull Request prefers the explicit pull request repository identity', async () => { - const pullRequestUri = URI.parse('https://github.com/upstream/project/pull/7'); - const session = createSessionWithPullRequest(pullRequestUri, [{ - owner: 'upstream', - repo: 'project', - number: 7, - uri: pullRequestUri, + assert.deepStrictEqual(openerService.opened, [{ + resource: pullRequestUri, + openExternal: true, + allowContributedOpeners: true, }]); - const instantiationService = new TestInstantiationService(); - const urlService = new TestURLService(); - instantiationService.stub(IExtensionService, new class extends mock() { - override async getExtension(): Promise { - return new class extends mock() { }; - } - }); - instantiationService.stub(IOpenerService, new TestOpenerService()); - instantiationService.stub(IURLService, urlService); - instantiationService.stub(ISessionsService, new class extends mock() { - override readonly activeSession = constObservable(undefined); - }); - - await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); - - assert.deepStrictEqual(JSON.parse(urlService.opened[0].uri.query), { - owner: 'upstream', - repo: 'project', - pullRequestNumber: 7, - }); }); test('Copy Pull Request URL uses an explicit contextual pull request', async () => { @@ -217,52 +166,4 @@ suite('Pull Request Actions', () => { assert.deepStrictEqual(clipboardService.writes, [secondPullRequestUri.toString(true)]); }); - test('Open Pull Request uses the GitHub Pull Requests extension when available', async () => { - const pullRequestUri = URI.parse('https://github.com/owner/repo/pull/1'); - const session = createSessionWithPullRequest(pullRequestUri); - - const instantiationService = new TestInstantiationService(); - const requestedExtensionIds: string[] = []; - const urlService = new TestURLService(); - const openerService = new TestOpenerService(); - instantiationService.stub(IExtensionService, new class extends mock() { - override async getExtension(id: string): Promise { - requestedExtensionIds.push(id); - return new class extends mock() { }; - } - }); - instantiationService.stub(IOpenerService, openerService); - instantiationService.stub(IURLService, urlService); - instantiationService.stub(ISessionsService, new class extends mock() { - override readonly activeSession = constObservable(undefined); - }); - - await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); - - assert.deepStrictEqual({ - requestedExtensionIds, - handledUris: urlService.opened.map(({ uri, options }) => ({ - scheme: uri.scheme, - authority: uri.authority, - path: uri.path, - query: JSON.parse(uri.query), - trusted: options?.trusted, - })), - opened: openerService.opened, - }, { - requestedExtensionIds: ['github.vscode-pull-request-github'], - handledUris: [{ - scheme: 'code-oss', - authority: 'github.vscode-pull-request-github', - path: '/open-pull-request-webview', - query: { - owner: 'owner', - repo: 'repo', - pullRequestNumber: 1, - }, - trusted: true, - }], - opened: [], - }); - }); }); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 4f68574569d036..10ed3cf7cbcc19 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -25,6 +25,7 @@ import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../. import { IPaneCompositePartService } from '../../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IPaneComposite } from '../../../../../workbench/common/panecomposite.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { IEditorWillOpenEvent, IUntypedEditorInput, isResourceEditorInput } from '../../../../../workbench/common/editor.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -350,9 +351,14 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption applyWorkingSetCalls: [], saveWorkingSetCalls: [], openChangesEditorCalls: [], - sessionChangesService: new SessionChangesService(new class extends mock() { }, instaService, new class extends mock() { + sessionChangesService: store.add(new SessionChangesService(new class extends mock() { }, instaService, new class extends mock() { override get isSinglePaneLayoutEnabled(): boolean { return options.singlePaneLayoutEnabled ?? false; } - }, new class extends mock() { }), + }, new class extends mock() { + override readonly activeSessionResourceObs = constObservable(undefined); + override readonly activeSessionChangesObs = constObservable([]); + }, new class extends mock() { + override registerDecorationsProvider() { return toDisposable(() => { }); } + })), contextKeyService, }; @@ -369,13 +375,16 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override pinEditor() { } override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } override async replaceEditors(replacements: IEditorReplacement[]) { + for (const replacement of replacements) { + store.add(replacement.replacement); + } await harness.onReplaceEditors?.(replacements); for (const replacement of replacements) { const index = harness.activeGroupEditors.indexOf(replacement.editor); if (index === -1) { continue; } - harness.activeGroupEditors.splice(index, 1, store.add(replacement.replacement)); + harness.activeGroupEditors.splice(index, 1, replacement.replacement); if (harness.activeEditorInput === replacement.editor) { harness.activeEditorInput = replacement.replacement; } @@ -405,6 +414,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption }); instaService.stub(ISessionChangesService, new class extends mock() { + override readonly activeSessionChangeCountObs = harness.sessionChangesService.activeSessionChangeCountObs; override getChangesEditorResource(sessionResource: URI): URI { return harness.sessionChangesService.getChangesEditorResource(sessionResource); } override getSessionResource(editorResource: URI): URI | undefined { return harness.sessionChangesService.getSessionResource(editorResource); } override async openChangesEditor(sessionResource: URI, options?: { index?: number; inactive?: boolean }): Promise { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts index 6e2d6378122d81..02e2329cb74339 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts @@ -101,17 +101,23 @@ const agentMergeActionLabels: Record = { const agentMergeRepairActions = Object.keys(agentMergeActionLabels) as readonly AgentMergeRepairAction[]; -/** Labels for the merge choice, short enough to read inside the submenu title. */ +/** + * Labels for the merge choice, short enough to read inside the submenu title. + * + * The session-scoped menu says On and Off rather than the `always` and `never` + * the setting stores: those values read as absolutes, which they only are for + * the defaults that apply across every session. + */ const agentMergeMergePullRequestLabels: Record = { - always: localize('agentMerge.merge.always', "Always"), + always: localize('agentMerge.merge.always', "On"), ifUnchanged: localize('agentMerge.merge.ifUnchanged', "Only if Agent Merge Made No Changes"), - never: localize('agentMerge.merge.never', "Never"), + never: localize('agentMerge.merge.never', "Off"), }; const agentMergeMergePullRequestDescriptions: Record = { always: localize('agentMerge.merge.always.description', "Merge the pull request whenever it is ready."), - ifUnchanged: localize('agentMerge.merge.ifUnchanged.description', "Merge the pull request only while Agent Merge has not changed it. Once a repair turn lands a commit this switches itself to Never."), - never: localize('agentMerge.merge.never.description', "Never merge the pull request automatically."), + ifUnchanged: localize('agentMerge.merge.ifUnchanged.description', "Merge the pull request only while Agent Merge has not changed it. Once a repair turn lands a commit this switches itself off."), + never: localize('agentMerge.merge.never.description', "Do not merge the pull request automatically."), }; /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index d8618ae91b27d7..2c3711e852f4c3 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -11,6 +11,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { ActionType, isSessionAction, type ActionEnvelope, type INotification, type StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, type AgentCustomization, type AgentInfo, type Customization, type RootState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -23,7 +24,7 @@ import { PromptsType } from '../../../../../../workbench/contrib/chat/common/pro import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -678,6 +679,52 @@ suite('RemoteAgentHostCustomizationHarness', () => { assert.strictEqual(new Set(keys).size, 2, 'all item keys should be unique'); }); + test('provider classifies agent host synthetic customizations as built-in', async () => { + const connection = disposables.add(new MockAgentConnection()); + const containerUri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skills' }).toString(); + const skillUri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skill/code-review' }).toString(); + const container: Customization = { + type: CustomizationType.Directory, + id: containerUri, + uri: containerUri, + name: 'builtin', + enabled: true, + contents: CustomizationType.Skill, + writable: false, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ + type: CustomizationType.Skill, + id: skillUri, + uri: skillUri, + name: 'code-review', + description: 'Review the current diff.', + }], + }; + connection.setRootState({ agents: [createAgentInfo([container])] }); + + const provider = disposables.add(new AgentCustomizationItemProvider( + 'test-authority', + () => { }, + undefined, + new class extends mock() { }(), + new NullLogService(), + createTestCustomAgentsService(connection, [container]), + new MockPromptsService(), + )); + + const items = await provider.provideChatSessionCustomizations(testSessionResource, CancellationToken.None); + + assert.deepStrictEqual(items.map(item => ({ + name: item.name, + source: item.source, + uri: item.uri.toString(), + })), [{ + name: 'code-review', + source: AICustomizationSources.builtin, + uri: 'vscode-agent-host://test-authority/skill/code-review?_ah%3DeyJzY2hlbWUiOiJhZ2VudC1idWlsdGluIn0', + }]); + }); + test('provider parses skill metadata, rewrites folder URIs to SKILL.md, and skips unreadable folder skills', async () => { const connection = disposables.add(new MockAgentConnection()); const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/skills-bundle', uri: 'file:///plugins/skills-bundle', name: 'Skills Bundle', }; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 5b9700d72fd688..ea47dc3411e23e 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -92,8 +92,8 @@ .agent-sessions-header-row { display: flex; align-items: center; - padding: 8px 10px; - min-height: 40px; + padding: 0 var(--vscode-spacing-size100); + height: var(--vscode-spacing-size320); box-sizing: border-box; -webkit-user-select: none; user-select: none; @@ -179,6 +179,16 @@ min-height: 0; } } + +.agent-sessions-workbench:not(.phone-layout) .agent-sessions-viewpane .agent-sessions-header-row { + position: relative; + top: var(--vscode-strokeThickness); +} + +.agent-sessions-workbench.editor-tabs-compact-height:not(.phone-layout) .agent-sessions-viewpane .agent-sessions-header-row { + height: var(--vscode-spacing-size280); +} + .agent-sessions-workbench.shell-gradient-background .agent-sessions-viewpane { background: transparent !important; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 32b78721a8bb4e..11b80a84537d11 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -38,6 +38,7 @@ import { SessionActionFeedback } from './sessionActionFeedback.js'; import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IBrowserWorkbenchEnvironmentService } from '../../../../workbench/services/environment/browser/environmentService.js'; /** * Internal command behind the blocked-sessions dropdown header's "Show All @@ -162,6 +163,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { private _isRendering = false; private _workspaceInfo: ISessionWorkspaceDisplayInfo | undefined; private _isQuickChat = false; + private readonly _sessionTitle: string | undefined; /** The currently open blocked-sessions dropdown, if any. */ private _openContextView: IOpenContextView | undefined; @@ -188,9 +190,11 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @IContextKeyService contextKeyService: IContextKeyService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IHoverService private readonly hoverService: IHoverService, + @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, ) { super(undefined, action, options); + this._sessionTitle = environmentService.sessionTitle?.replace(/\s+/g, ' ').trim() || undefined; this._blockedSessionsVisibleContext = SessionsBlockedSessionsVisibleContext.bindTo(contextKeyService); // Replay the attention blink when the model reports a genuinely new, not-yet- @@ -350,7 +354,9 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { */ private _renderActiveSession(): void { const container = this._container!; - container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); + container.setAttribute('aria-label', this._sessionTitle + ? localize('agentSessionsShowSessionsWithTitle', "Show Sessions: {0}", this._sessionTitle) + : localize('agentSessionsShowSessions', "Show Sessions")); const workspaceInfo = this._workspaceInfo; @@ -365,16 +371,22 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(workspaceIconEl); const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = workspaceInfo.label; + workspaceEl.textContent = this._sessionTitle ?? workspaceInfo.label; centerGroup.appendChild(workspaceEl); - this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceInfo.label })); + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceEl.textContent })); } else if (this._isQuickChat) { const workspaceIconEl = $(`div.agent-sessions-titlebar-workspace-icon${ThemeIcon.asCSSSelector(Codicon.commentDiscussion)}`, { 'aria-hidden': 'true' }); centerGroup.appendChild(workspaceIconEl); const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = localize('noWorkspace', "No workspace"); + workspaceEl.textContent = this._sessionTitle ?? localize('noWorkspace', "No workspace"); centerGroup.appendChild(workspaceEl); + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceEl.textContent })); + } else if (this._sessionTitle) { + const titleElement = $('div.agent-sessions-titlebar-workspace'); + titleElement.textContent = this._sessionTitle; + centerGroup.appendChild(titleElement); + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(titleElement, { content: this._sessionTitle })); } sessionPill.appendChild(centerGroup); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts new file mode 100644 index 00000000000000..e1e0a1dad32f67 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewPane.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Workbench } from '../../../../browser/workbench.js'; +import '../../browser/media/sessionsViewPane.css'; + +const registerEditorTabHeightClass = Reflect.get(Workbench.prototype, 'registerEditorTabHeightClass') as (this: { + readonly mainContainer: HTMLElement; + readonly editorGroupService: { + readonly partOptions: { readonly tabHeight: 'default' | 'compact' }; + readonly onDidChangeEditorPartOptions: Event; + }; + _register(disposable: T): T; +}) => void; + +suite('Sessions - SessionsViewPane', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches the default and compact editor tab heights', () => { + const editorPartOptionsChanged = disposables.add(new Emitter()); + let tabHeight: 'default' | 'compact' = 'default'; + const workbench = mainWindow.document.createElement('div'); + workbench.className = 'agent-sessions-workbench'; + workbench.style.setProperty('--vscode-spacing-size280', '28px'); + workbench.style.setProperty('--vscode-spacing-size320', '32px'); + const viewPane = mainWindow.document.createElement('div'); + viewPane.className = 'agent-sessions-viewpane'; + const headerRow = mainWindow.document.createElement('div'); + headerRow.className = 'agent-sessions-header-row'; + viewPane.appendChild(headerRow); + workbench.appendChild(viewPane); + mainWindow.document.body.appendChild(workbench); + + const host = { + mainContainer: workbench, + editorGroupService: { + get partOptions() { return { tabHeight }; }, + onDidChangeEditorPartOptions: editorPartOptionsChanged.event, + }, + _register: (disposable: T) => disposables.add(disposable), + }; + + try { + registerEditorTabHeightClass.call(host); + const defaultHeight = mainWindow.getComputedStyle(headerRow).height; + + tabHeight = 'compact'; + editorPartOptionsChanged.fire(); + const compactHeight = mainWindow.getComputedStyle(headerRow).height; + + tabHeight = 'default'; + editorPartOptionsChanged.fire(); + const restoredHeight = mainWindow.getComputedStyle(headerRow).height; + + assert.deepStrictEqual({ + defaultHeight, + compactHeight, + restoredHeight, + hasCompactClass: workbench.classList.contains('editor-tabs-compact-height'), + }, { + defaultHeight: '32px', + compactHeight: '28px', + restoredHeight: '32px', + hasCompactClass: false, + }); + } finally { + workbench.remove(); + } + }); +}); diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 67cc2bbe1d07b8..a2b40493e72fc1 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { addDisposableListener, EventType } from '../../../base/browser/dom.js'; import { mainWindow } from '../../../base/browser/window.js'; -import { Event } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { isLinux } from '../../../base/common/platform.js'; @@ -15,6 +15,9 @@ import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { DEFAULT_EDITOR_PART_OPTIONS } from '../../../workbench/browser/parts/editor/editor.js'; +import { IEditorPartOptions, IEditorPartOptionsChangeEvent } from '../../../workbench/common/editor.js'; +import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from '../../browser/parts/chatCompositeBar.js'; import { getSessionChatDragData, isSessionChatDrag } from '../../browser/dnd.js'; @@ -73,6 +76,26 @@ class TestSessionsService extends mock() { } } +class TestEditorGroupsService extends mock() { + private readonly _onDidChangeEditorPartOptions = new Emitter(); + override readonly onDidChangeEditorPartOptions = this._onDidChangeEditorPartOptions.event; + private _partOptions: IEditorPartOptions = { ...DEFAULT_EDITOR_PART_OPTIONS }; + + override get partOptions(): IEditorPartOptions { + return this._partOptions; + } + + setTabHeight(tabHeight: IEditorPartOptions['tabHeight']): void { + const oldPartOptions = this._partOptions; + this._partOptions = { ...oldPartOptions, tabHeight }; + this._onDidChangeEditorPartOptions.fire({ oldPartOptions, newPartOptions: this._partOptions }); + } + + dispose(): void { + this._onDidChangeEditorPartOptions.dispose(); + } +} + function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed): IChat { const resource = URI.parse(`test-chat://${id}`); return new class extends mock() { @@ -109,7 +132,9 @@ interface IChatCompositeBarHarness { readonly instantiationService: TestInstantiationService; readonly commandService: TestCommandService; readonly sessionsService: TestSessionsService; + readonly editorGroupsService: TestEditorGroupsService; readonly bar: ChatCompositeBar; + readonly container: HTMLElement; readonly session: IActiveSession; readonly tabs: readonly HTMLElement[]; readonly chats: ISettableObservable; @@ -123,6 +148,7 @@ function createHarness(disposables: Pick, options?: { re const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); const sessionsService = new TestSessionsService(); + const editorGroupsService = store.add(new TestEditorGroupsService()); const mainChat = createChat('main', 'Main Chat'); const secondaryChat = createChat('secondary', 'Secondary Chat'); const session = createSession([mainChat, secondaryChat], mainChat, options?.isQuickChat); @@ -134,6 +160,7 @@ function createHarness(disposables: Pick, options?: { re instantiationService.stub(ICommandService, commandService); instantiationService.stub(ISessionsService, sessionsService); + instantiationService.stub(IEditorGroupsService, editorGroupsService); instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; }()); @@ -158,7 +185,7 @@ function createHarness(disposables: Pick, options?: { re container.appendChild(bar.element); const tabs = Array.from(bar.element.querySelectorAll('.chat-composite-bar-tab')); - return { store, instantiationService, commandService, sessionsService, bar, session, tabs, chats, activeChatResource, visible, showSessionActions }; + return { store, instantiationService, commandService, sessionsService, editorGroupsService, bar, container, session, tabs, chats, activeChatResource, visible, showSessionActions }; } suite('Sessions - ChatCompositeBar', () => { @@ -191,6 +218,45 @@ suite('Sessions - ChatCompositeBar', () => { assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat'), null); }); + test('matches the default and compact editor tab strip heights', () => { + const { bar, container, editorGroupsService } = createHarness(disposables); + mainWindow.document.body.appendChild(container); + + try { + const tabsRow = bar.element.querySelector('.chat-composite-bar-tabs-row'); + const tabs = bar.element.querySelector('.chat-composite-bar-tabs'); + const defaultHeight = { + barHeight: mainWindow.getComputedStyle(bar.element).height, + tabsRowHeight: tabsRow && mainWindow.getComputedStyle(tabsRow).height, + tabsHeight: tabs && mainWindow.getComputedStyle(tabs).height, + }; + + editorGroupsService.setTabHeight('compact'); + const compactHeight = { + barHeight: mainWindow.getComputedStyle(bar.element).height, + tabsRowHeight: tabsRow && mainWindow.getComputedStyle(tabsRow).height, + tabsHeight: tabs && mainWindow.getComputedStyle(tabs).height, + hasCompactClass: bar.element.classList.contains('compact-height'), + }; + + assert.deepStrictEqual({ defaultHeight, compactHeight }, { + defaultHeight: { + barHeight: '32px', + tabsRowHeight: '32px', + tabsHeight: '32px', + }, + compactHeight: { + barHeight: '28px', + tabsRowHeight: '28px', + tabsHeight: '28px', + hasCompactClass: true, + }, + }); + } finally { + container.remove(); + } + }); + test('updates active, visibility, and session action state without rebuilding tabs', () => { const { activeChatResource, bar, showSessionActions, tabs, visible } = createHarness(disposables); const secondaryResource = tabs[1].dataset.chatResource!; @@ -236,8 +302,8 @@ suite('Sessions - ChatCompositeBar', () => { const observedHeights: number[] = []; disposables.add(bar.onDidChangeHeight(() => observedHeights.push(bar.height))); - resizeObserver.fire(35); - resizeObserver.fire(35); + resizeObserver.fire(32); + resizeObserver.fire(32); resizeObserver.fire(0); assert.deepStrictEqual({ @@ -246,7 +312,7 @@ suite('Sessions - ChatCompositeBar', () => { observedBox: resizeObserver.observedBox, }, { height: 0, - observedHeights: [35, 0], + observedHeights: [32, 0], observedBox: 'border-box', }); }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 9faae29dc391fd..f91b0ad60e7e53 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -15,6 +15,8 @@ import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../base/test/common/timeTravelScheduler.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { DEFAULT_EDITOR_PART_OPTIONS } from '../../../workbench/browser/parts/editor/editor.js'; +import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; @@ -31,6 +33,7 @@ class TestChatView extends AbstractChatView { private readonly _focusTarget = mainWindow.document.createElement('button'); override readonly hasVisibleTranscriptContent = observableValue(this, false); layoutCount = 0; + primary = false; constructor(readonly kind: ChatViewKind) { super(); @@ -49,6 +52,10 @@ class TestChatView extends AbstractChatView { focus(): void { this._focusTarget.focus(); } + + override setPrimary(primary: boolean): void { + this.primary = primary; + } } class TestChatViewFactory extends mock() { @@ -217,6 +224,10 @@ function createHarness(disposables: Pick, tabsReplaceHea const chatViewFactory = new TestChatViewFactory(); const sessionsProvidersService = new TestSessionsProvidersService(); instantiationService.stub(IChatViewFactory, chatViewFactory); + instantiationService.stub(IEditorGroupsService, new class extends mock() { + override readonly onDidChangeEditorPartOptions = Event.None; + override readonly partOptions = DEFAULT_EDITOR_PART_OPTIONS; + }()); instantiationService.stub(ISessionsService, sessionsService); instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly onDidChangeSessions = Event.None; @@ -530,7 +541,7 @@ suite('Sessions - ChatGroupsView', () => { }); test('left split updates logical and accessible group order', async () => { - const { view } = createHarness(disposables); + const { view, chatViewFactory } = createHarness(disposables); const main = createChat('main'); const secondary = createChat('secondary'); const session = new TestActiveSession([main, secondary]); @@ -541,11 +552,14 @@ suite('Sessions - ChatGroupsView', () => { const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); const labelByChat = Object.fromEntries(groups.map(group => [ group.querySelector('.chat-composite-bar-tab')?.dataset.chatResource, - group.getAttribute('aria-label'), + { + label: group.getAttribute('aria-label'), + primary: chatViewFactory.views.find(candidate => candidate.element.parentElement === group.querySelector('.chat-group-view-content'))?.primary, + }, ])); assert.deepStrictEqual(labelByChat, { - [secondary.resource.toString()]: 'Chat Group 1 of 2', - [main.resource.toString()]: 'Chat Group 2 of 2', + [secondary.resource.toString()]: { label: 'Chat Group 1 of 2', primary: true }, + [main.resource.toString()]: { label: 'Chat Group 2 of 2', primary: false }, }); }); diff --git a/src/vs/sessions/test/browser/editorPart.test.ts b/src/vs/sessions/test/browser/editorPart.test.ts new file mode 100644 index 00000000000000..cdc5f021ef915a --- /dev/null +++ b/src/vs/sessions/test/browser/editorPart.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import '../../browser/parts/media/editorPart.css'; + +function appendElement(parent: HTMLElement, className: string): HTMLElement { + const element = mainWindow.document.createElement('div'); + element.className = className; + parent.appendChild(element); + return element; +} + +suite('Sessions - EditorPart', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('constrains the Browser navbar to the editor header height', () => { + const workbench = appendElement(mainWindow.document.body, 'monaco-workbench agent-sessions-workbench dock-detail-panel'); + workbench.style.setProperty('--vscode-spacing-size40', '4px'); + workbench.style.setProperty('--vscode-spacing-size280', '28px'); + workbench.style.setProperty('--vscode-spacing-size320', '32px'); + workbench.style.setProperty('--vscode-strokeThickness', '1px'); + + const editorPart = appendElement(workbench, 'part editor'); + const editorContent = appendElement(editorPart, 'content'); + const editorGroupContainer = appendElement(editorContent, 'editor-group-container'); + const browserRoot = appendElement(editorGroupContainer, 'browser-root'); + const navbar = appendElement(browserRoot, 'browser-navbar'); + navbar.style.display = 'flex'; + navbar.style.alignItems = 'center'; + + const urlContainer = appendElement(navbar, 'browser-url-container'); + urlContainer.style.height = '25px'; + + try { + const defaultHeight = mainWindow.getComputedStyle(navbar).height; + editorGroupContainer.classList.add('editor-tabs-compact-height'); + const compactHeight = mainWindow.getComputedStyle(navbar).height; + + assert.deepStrictEqual({ defaultHeight, compactHeight }, { + defaultHeight: '32px', + compactHeight: '28px', + }); + } finally { + workbench.remove(); + } + }); + + test('uses the shared empty-state hierarchy for Browser', () => { + const workbench = appendElement(mainWindow.document.body, 'monaco-workbench agent-sessions-workbench'); + workbench.style.setProperty('--vscode-spacing-size40', '4px'); + workbench.style.setProperty('--vscode-fontSize-body1', '13px'); + workbench.style.setProperty('--vscode-fontWeight-regular', '400'); + workbench.style.setProperty('--vscode-fontWeight-semiBold', '600'); + workbench.style.setProperty('--vscode-foreground', 'rgb(204, 204, 204)'); + workbench.style.setProperty('--vscode-descriptionForeground', 'rgb(157, 157, 157)'); + + const editorPart = appendElement(workbench, 'part editor'); + const content = appendElement(editorPart, 'browser-welcome-content'); + const icon = appendElement(content, 'browser-welcome-icon'); + const title = appendElement(content, 'browser-welcome-title'); + const subtitle = appendElement(content, 'browser-welcome-subtitle'); + + try { + const contentStyle = mainWindow.getComputedStyle(content); + const titleStyle = mainWindow.getComputedStyle(title); + const subtitleStyle = mainWindow.getComputedStyle(subtitle); + + assert.deepStrictEqual({ + gap: contentStyle.gap, + iconDisplay: mainWindow.getComputedStyle(icon).display, + title: { + color: titleStyle.color, + fontSize: titleStyle.fontSize, + fontWeight: titleStyle.fontWeight, + margin: titleStyle.margin, + padding: titleStyle.padding, + }, + subtitle: { + color: subtitleStyle.color, + fontSize: subtitleStyle.fontSize, + fontWeight: subtitleStyle.fontWeight, + margin: subtitleStyle.margin, + padding: subtitleStyle.padding, + }, + }, { + gap: '4px', + iconDisplay: 'none', + title: { + color: 'rgb(204, 204, 204)', + fontSize: '13px', + fontWeight: '600', + margin: '0px', + padding: '0px', + }, + subtitle: { + color: 'rgb(157, 157, 157)', + fontSize: '13px', + fontWeight: '400', + margin: '0px', + padding: '0px', + }, + }); + } finally { + workbench.remove(); + } + }); + +}); diff --git a/src/vs/workbench/api/browser/mainThreadNotebook.ts b/src/vs/workbench/api/browser/mainThreadNotebook.ts index 799f50babd9ae9..0d3d3dcf63cd8a 100644 --- a/src/vs/workbench/api/browser/mainThreadNotebook.ts +++ b/src/vs/workbench/api/browser/mainThreadNotebook.ts @@ -47,6 +47,8 @@ export class MainThreadNotebooks implements MainThreadNotebookShape { dispose(): void { this._disposables.dispose(); dispose(this._notebookSerializer.values()); + dispose(this._notebookCellStatusBarRegistrations.values()); + this._notebookCellStatusBarRegistrations.clear(); } $registerNotebookSerializer(handle: number, extension: NotebookExtensionDescription, viewType: string, options: TransientOptions, data: INotebookContributionData | undefined): void { diff --git a/src/vs/workbench/api/test/browser/mainThreadNotebook.test.ts b/src/vs/workbench/api/test/browser/mainThreadNotebook.test.ts new file mode 100644 index 00000000000000..c786fea6dc0e7f --- /dev/null +++ b/src/vs/workbench/api/test/browser/mainThreadNotebook.test.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotebookCellStatusBarItemProvider } from '../../../contrib/notebook/common/notebookCommon.js'; +import { INotebookCellStatusBarService } from '../../../contrib/notebook/common/notebookCellStatusBarService.js'; +import { INotebookService } from '../../../contrib/notebook/common/notebookService.js'; +import { mock } from '../../../test/common/workbenchTestServices.js'; +import { MainThreadNotebooks } from '../../browser/mainThreadNotebook.js'; +import { ExtHostNotebookShape } from '../../common/extHost.protocol.js'; +import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; + +suite('MainThreadNotebooks', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes and clears cell status bar provider registrations', async () => { + let registrationDisposals = 0; + const cellStatusBarService = new class extends mock() { + override registerCellStatusBarItemProvider(_provider: INotebookCellStatusBarItemProvider) { + return { dispose: () => registrationDisposals++ }; + } + }; + const service = store.add(new MainThreadNotebooks( + AnyCallRPCProtocol(), + new class extends mock() { }, + cellStatusBarService, + new class extends mock() { }, + )); + + await service.$registerNotebookCellStatusBarItemProvider(1, undefined, '*'); + service.dispose(); + await service.$unregisterNotebookCellStatusBarItemProvider(1, undefined); + + assert.strictEqual(registrationDisposals, 1); + }); +}); diff --git a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts index 31d818c7d724b1..4bb1e32c778365 100644 --- a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts @@ -10,9 +10,13 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ActiveCompareEditorCanSwapContext, ActiveCustomEditorDiffCanToggleLayoutContext, TextCompareEditorActiveContext, TextCompareEditorVisibleContext } from '../../../common/contextkeys.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; -import { FocusTextDiffEditorMode, IDiffEditorCommandsService } from './diffEditorCommandsService.js'; +import { DiffEditorViewMode, FocusTextDiffEditorMode, IDiffEditorCommandsService } from './diffEditorCommandsService.js'; export const TOGGLE_DIFF_SIDE_BY_SIDE = 'toggle.diff.renderSideBySide'; +export const SET_DIFF_VIEW_MODE_INLINE = 'diffEditor.setViewMode.inline'; +export const SET_DIFF_VIEW_MODE_SIDE_BY_SIDE = 'diffEditor.setViewMode.sideBySide'; +export const SET_DIFF_VIEW_MODE_AUTOMATIC = 'diffEditor.setViewMode.automatic'; +export const DIFF_VIEW_MODE_INLINE_TEMPORARY = 'diffEditor.viewMode.inlineTemporary'; export const GOTO_NEXT_CHANGE = 'workbench.action.compareEditor.nextChange'; export const GOTO_PREVIOUS_CHANGE = 'workbench.action.compareEditor.previousChange'; export const DIFF_FOCUS_PRIMARY_SIDE = 'workbench.action.compareEditor.focusPrimarySide'; @@ -76,6 +80,28 @@ export function registerDiffEditorCommands(): void { handler: (accessor, ...args) => accessor.get(IDiffEditorCommandsService).toggleRenderSideBySide(args) }); + for (const [id, mode] of [ + [SET_DIFF_VIEW_MODE_INLINE, 'inline'], + [SET_DIFF_VIEW_MODE_SIDE_BY_SIDE, 'sideBySide'], + [SET_DIFF_VIEW_MODE_AUTOMATIC, 'automatic'], + ] as const satisfies readonly (readonly [string, DiffEditorViewMode])[]) { + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: (accessor, ...args) => accessor.get(IDiffEditorCommandsService).setViewMode(args, mode) + }); + } + + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: DIFF_VIEW_MODE_INLINE_TEMPORARY, + weight: KeybindingWeight.WorkbenchContrib, + when: undefined, + primary: undefined, + handler: () => { } + }); + KeybindingsRegistry.registerCommandAndKeybindingRule({ id: DIFF_FOCUS_PRIMARY_SIDE, weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/browser/parts/editor/diffEditorCommandsService.ts b/src/vs/workbench/browser/parts/editor/diffEditorCommandsService.ts index eca998fdcc0b29..95a8df6a667f3c 100644 --- a/src/vs/workbench/browser/parts/editor/diffEditorCommandsService.ts +++ b/src/vs/workbench/browser/parts/editor/diffEditorCommandsService.ts @@ -25,6 +25,8 @@ export const enum FocusTextDiffEditorMode { Toggle } +export type DiffEditorViewMode = 'inline' | 'sideBySide' | 'automatic'; + /** * Backs the diff-editor commands (see {@link registerDiffEditorCommands}). The Agents window * overrides this to also drive its multi-diff Changes editor. @@ -35,6 +37,9 @@ export interface IDiffEditorCommandsService { /** Toggles inline vs. side-by-side rendering for the active diff editor. */ toggleRenderSideBySide(args: unknown[]): Promise; + /** Sets the layout mode for the active diff editor. */ + setViewMode(args: unknown[], mode: DiffEditorViewMode): Promise; + /** Opens the original or modified side of the active diff editor, whichever has focus, as its own editor. */ openActiveDiffSide(): Promise; @@ -72,6 +77,34 @@ export class DiffEditorCommandsService implements IDiffEditorCommandsService { await this.textResourceConfigurationService.updateValue(modifiedResource, key, !value); } + async setViewMode(args: unknown[], mode: DiffEditorViewMode): Promise { + const activeTextDiffEditor = this.getActiveTextDiffEditor(args); + const control = activeTextDiffEditor?.getControl(); + const modifiedResource = control?.getModifiedEditor().getModel()?.uri; + if (!modifiedResource) { + return; + } + + switch (mode) { + case 'inline': + await this.textResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.renderSideBySide', false); + break; + case 'sideBySide': + await Promise.all([ + this.textResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.renderSideBySide', true), + this.textResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.useInlineViewWhenSpaceIsLimited', false), + ]); + break; + case 'automatic': + await Promise.all([ + this.textResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.renderSideBySide', true), + this.textResourceConfigurationService.updateValue(modifiedResource, 'diffEditor.useInlineViewWhenSpaceIsLimited', true), + ]); + control.resetWidthBasedLayout(); + break; + } + } + async openActiveDiffSide(): Promise { const activeEditor = this.editorService.activeEditor; const activeTextEditorControl = this.editorService.activeTextEditorControl; diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 8a970a8edeaa63..29c74f36fd15c1 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -55,7 +55,7 @@ import { SPLIT_EDITOR, TOGGLE_MAXIMIZE_EDITOR_GROUP, MOVE_EDITOR_INTO_NEW_WINDOW_COMMAND_ID, COPY_EDITOR_INTO_NEW_WINDOW_COMMAND_ID, MOVE_EDITOR_GROUP_INTO_NEW_WINDOW_COMMAND_ID, COPY_EDITOR_GROUP_INTO_NEW_WINDOW_COMMAND_ID, NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID, MOVE_EDITOR_INTO_RIGHT_GROUP, MOVE_EDITOR_INTO_LEFT_GROUP, MOVE_EDITOR_INTO_ABOVE_GROUP, MOVE_EDITOR_INTO_BELOW_GROUP } from './editorCommands.js'; -import { GOTO_NEXT_CHANGE, GOTO_PREVIOUS_CHANGE, TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, TOGGLE_DIFF_SIDE_BY_SIDE, DIFF_SWAP_SIDES } from './diffEditorCommands.js'; +import { DIFF_SWAP_SIDES, DIFF_VIEW_MODE_INLINE_TEMPORARY, GOTO_NEXT_CHANGE, GOTO_PREVIOUS_CHANGE, SET_DIFF_VIEW_MODE_AUTOMATIC, SET_DIFF_VIEW_MODE_INLINE, SET_DIFF_VIEW_MODE_SIDE_BY_SIDE, TOGGLE_DIFF_IGNORE_TRIM_WHITESPACE, TOGGLE_DIFF_SIDE_BY_SIDE } from './diffEditorCommands.js'; import { inQuickPickContext, getQuickNavigateHandler } from '../../quickaccess.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; @@ -420,7 +420,70 @@ MenuRegistry.appendMenuItem(MenuId.EditorSplitMoveSubmenu, { command: { id: SPLI MenuRegistry.appendMenuItem(MenuId.EditorSplitMoveSubmenu, { command: { id: JOIN_EDITOR_IN_GROUP, title: localize('joinInGroup', "Join in Group"), precondition: MultipleEditorsSelectedInGroupContext.negate() }, group: '3_split_in_group', order: 10, when: SideBySideEditorActiveContext }); // Editor Title Menu -MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('inlineView', "Inline View"), toggled: ContextKeyExpr.equals('config.diffEditor.renderSideBySide', false) }, group: '1_diff', order: 10, when: ContextKeyExpr.or(ContextKeyExpr.has('isInDiffEditor'), ActiveCustomEditorDiffCanToggleLayoutContext) }); +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { + submenu: MenuId.DiffEditorViewSubmenu, + title: localize('diffView', "Diff View"), + group: '1_diff', + order: 10, + when: ContextKeyExpr.has('isInDiffEditor'), +}); +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { + command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('inlineView', "Inline View"), toggled: ContextKeyExpr.equals('config.diffEditor.renderSideBySide', false) }, + group: '1_diff', + order: 10, + when: ContextKeyExpr.and(ActiveCustomEditorDiffCanToggleLayoutContext, ContextKeyExpr.not('isInDiffEditor')), +}); +MenuRegistry.appendMenuItem(MenuId.DiffEditorViewSubmenu, { + command: { + id: SET_DIFF_VIEW_MODE_INLINE, + title: localize('diffView.inline', "Inline"), + toggled: ContextKeyExpr.equals('config.diffEditor.renderSideBySide', false), + }, + group: '1_view', + order: 1, +}); +MenuRegistry.appendMenuItem(MenuId.DiffEditorViewSubmenu, { + command: { + id: SET_DIFF_VIEW_MODE_SIDE_BY_SIDE, + title: localize('diffView.sideBySide', "Side by Side"), + toggled: ContextKeyExpr.and( + ContextKeyExpr.equals('config.diffEditor.renderSideBySide', true), + ContextKeyExpr.equals('config.diffEditor.useInlineViewWhenSpaceIsLimited', false), + ), + }, + group: '1_view', + order: 2, +}); +for (const [title, when] of [ + [localize('diffView.automaticSideBySide', "Automatic (Currently Side by Side)"), EditorContextKeys.diffEditorAutomaticRenderSideBySide], + [localize('diffView.automaticInline', "Automatic (Currently Inline)"), EditorContextKeys.diffEditorAutomaticRenderSideBySide.toNegated()], +] as const) { + MenuRegistry.appendMenuItem(MenuId.DiffEditorViewSubmenu, { + command: { + id: SET_DIFF_VIEW_MODE_AUTOMATIC, + title, + toggled: ContextKeyExpr.and( + ContextKeyExpr.equals('config.diffEditor.renderSideBySide', true), + ContextKeyExpr.equals('config.diffEditor.useInlineViewWhenSpaceIsLimited', true), + EditorContextKeys.diffEditorTemporaryInlineMode.toNegated(), + ), + }, + group: '1_view', + order: 3, + when, + }); +} +MenuRegistry.appendMenuItem(MenuId.DiffEditorViewSubmenu, { + command: { + id: DIFF_VIEW_MODE_INLINE_TEMPORARY, + title: localize('diffView.inlineTemporary', "Inline (Temporary)"), + toggled: EditorContextKeys.diffEditorTemporaryInlineMode, + precondition: ContextKeyExpr.false(), + }, + group: '1_view', + order: 4, + when: EditorContextKeys.diffEditorTemporaryInlineMode, +}); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: SHOW_EDITORS_IN_GROUP, title: localize('showOpenedEditors', "Show Opened Editors") }, group: '3_open', order: 10, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '5_close', order: 10, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '5_close', order: 20, when: EditorPartModalContext.toNegated() /* not applicable to modal editor */ }); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 648eeccef065d6..7538dd869be42f 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -29,6 +29,7 @@ import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, EditorPartModalSidebarContext, IsSessionsWindowContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isDiffEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../../services/editor/common/editorGroupColumn.js'; @@ -45,7 +46,6 @@ import { DIFF_FOCUS_OTHER_SIDE, DIFF_FOCUS_PRIMARY_SIDE, DIFF_FOCUS_SECONDARY_SI import { IResolvedEditorCommandsContext, resolveCommandsContext } from './editorCommandsContext.js'; import { prepareMoveCopyEditors } from './editor.js'; import { IRange } from '../../../../editor/common/core/range.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; export const CLOSE_SAVED_EDITORS_COMMAND_ID = 'workbench.action.closeUnmodifiedEditors'; export const CLOSE_EDITORS_IN_GROUP_COMMAND_ID = 'workbench.action.closeEditorsInGroup'; diff --git a/src/vs/workbench/browser/parts/editor/editorHeaderControl.ts b/src/vs/workbench/browser/parts/editor/editorHeaderControl.ts index b2179a51f5f701..26880bd64375ab 100644 --- a/src/vs/workbench/browser/parts/editor/editorHeaderControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorHeaderControl.ts @@ -14,7 +14,8 @@ import { IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView } from './edit export class EditorHeaderControl extends Disposable { - static readonly HEIGHT = 29; + static readonly DEFAULT_HEIGHT = 32; + static readonly COMPACT_HEIGHT = 28; private readonly headerContainer: HTMLElement | undefined; private readonly actionsContainer: HTMLElement | undefined; @@ -33,7 +34,10 @@ export class EditorHeaderControl extends Disposable { get height(): number { if (this.headerContainer) { - return this.visible ? EditorHeaderControl.HEIGHT : 0; + if (!this.visible) { + return 0; + } + return this.groupsView.partOptions.tabHeight === 'compact' ? EditorHeaderControl.COMPACT_HEIGHT : EditorHeaderControl.DEFAULT_HEIGHT; } return this.breadcrumbsControl?.isHidden() === false ? BreadcrumbsControl.HEIGHT : 0; } @@ -41,7 +45,7 @@ export class EditorHeaderControl extends Disposable { constructor( parent: HTMLElement, private readonly groupView: IEditorGroupView, - groupsView: IEditorGroupsView, + private readonly groupsView: IEditorGroupsView, private readonly menuIds: IEditorGroupMenuIds | undefined, showHeader: boolean, @IInstantiationService private readonly instantiationService: IInstantiationService, diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index c5487ffa306e77..b5ae9531268552 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -646,10 +646,12 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC } protected updateTabHeight(): void { + const isCompact = this.groupsView.partOptions.tabHeight === 'compact'; this.parent.style.setProperty('--editor-group-tab-height', `${this.tabHeight}px`); // Signal compact mode via a CSS class so the modern tab rules in tabs.css // can apply a proportionally smaller --editor-group-tab-height value. - this.parent.classList.toggle('compact-height', this.groupsView.partOptions.tabHeight === 'compact'); + this.parent.classList.toggle('compact-height', isCompact); + this.parent.parentElement?.classList.toggle('editor-tabs-compact-height', isCompact); } private updateTabActionSpaceReservation(): void { diff --git a/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts b/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts new file mode 100644 index 00000000000000..c59001596c7f9f --- /dev/null +++ b/src/vs/workbench/contrib/browserView/browser/browserWelcome.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/browserWelcome.css'; +import { $ } from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; + +/** + * Creates the browser editor's welcome content. + */ +export function createBrowserWelcome(title: string, subtitle: string): HTMLElement { + const container = $('.browser-welcome-container'); + const content = $('.browser-welcome-content'); + + const iconContainer = $('.browser-welcome-icon'); + iconContainer.appendChild(renderIcon(Codicon.globe)); + content.appendChild(iconContainer); + + const titleElement = $('.browser-welcome-title'); + titleElement.textContent = title; + content.appendChild(titleElement); + + const subtitleElement = $('.browser-welcome-subtitle'); + subtitleElement.textContent = subtitle; + content.appendChild(subtitleElement); + + container.appendChild(content); + return container; +} diff --git a/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css b/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css new file mode 100644 index 00000000000000..c5fa70bd4d3de4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.browser-root .browser-welcome-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--vscode-editor-background); +} + +.browser-root .browser-welcome-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; +} + +.browser-root .browser-welcome-icon { + min-height: 40px; +} + +.browser-root .browser-welcome-icon .codicon { + font-size: 40px; + margin-bottom: 24px; + color: var(--vscode-descriptionForeground); +} + +.browser-root .browser-welcome-title { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); + margin-top: 5px; + text-align: center; + line-height: normal; + padding: 0 8px; +} + +.browser-root .browser-welcome-subtitle { + font-size: 12px; + position: relative; + text-align: center; + max-width: 280px; + padding: 0 20px; + margin: 8px auto 0; + color: var(--vscode-descriptionForeground); +} + +.browser-root .browser-welcome-subtitle p { + margin-top: 8px; + margin-bottom: 8px; +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserWelcomeFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserWelcomeFeature.ts index 81b6225cbc8ebf..62a4c272e93640 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserWelcomeFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserWelcomeFeature.ts @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../../nls.js'; -import { $ } from '../../../../../base/browser/dom.js'; -import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { createBrowserWelcome } from '../../browser/browserWelcome.js'; import { IBrowserViewModel } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { @@ -34,25 +32,13 @@ export class BrowserWelcomeFeature extends BrowserEditorContribution { ) { super(editor); - this._container = $('.browser-welcome-container'); - const content = $('.browser-welcome-content'); - - const iconContainer = $('.browser-welcome-icon'); - iconContainer.appendChild(renderIcon(Codicon.globe)); - content.appendChild(iconContainer); - - const title = $('.browser-welcome-title'); - title.textContent = localize('browser.welcomeTitle', "Browser"); - content.appendChild(title); - - const subtitle = $('.browser-welcome-subtitle'); const chatEnabled = contextKeyService.getContextKeyValue(ChatContextKeys.enabled.key); - subtitle.textContent = chatEnabled - ? localize('browser.welcomeSubtitleChat', "Use Add Element to Chat to reference UI elements in chat prompts.") - : localize('browser.welcomeSubtitle', "Enter a URL above to get started."); - content.appendChild(subtitle); - - this._container.appendChild(content); + this._container = createBrowserWelcome( + localize('browser.welcomeTitle', "Browser"), + chatEnabled + ? localize('browser.welcomeSubtitleChat', "Use Add Element to Chat to reference UI elements in chat prompts.") + : localize('browser.welcomeSubtitle', "Enter a URL above to get started."), + ); this._widget = { location: BrowserWidgetLocation.ContentArea, element: this._container, order: 50 }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index e60eacb1df1202..948a951ddae8d1 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -664,61 +664,6 @@ } } - .browser-welcome-container { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: var(--vscode-editor-background); - - .browser-welcome-content { - display: flex; - flex-direction: column; - align-items: center; - padding: 12px; - } - - .browser-welcome-icon { - min-height: 40px; - - .codicon { - font-size: 40px; - margin-bottom: 24px; - color: var(--vscode-descriptionForeground); - } - } - - .browser-welcome-title { - font-size: 13px; - font-weight: 600; - color: var(--vscode-foreground); - margin-top: 5px; - text-align: center; - line-height: normal; - padding: 0 8px; - } - - .browser-welcome-subtitle { - font-size: 12px; - position: relative; - text-align: center; - max-width: 280px; - padding: 0 20px; - margin: 8px auto 0; - color: var(--vscode-descriptionForeground); - - p { - margin-top: 8px; - margin-bottom: 8px; - } - } - - } - /* Site info indicator in URL bar */ .browser-site-info-container { display: flex; diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts index 4d5d76768f0d51..653d9edd66bfaa 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts @@ -14,8 +14,8 @@ import { Mutable } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import './bulkEdit.css'; import { ResourceEdit } from '../../../../../editor/browser/services/bulkEditService.js'; -import { IMultiDiffEditorOptions, IMultiDiffResourceId } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { IRange } from '../../../../../editor/common/core/range.js'; +import { IMultiDiffEditorOptions, IMultiDiffResourceId } from '../../../../../editor/common/multiDiffEditor.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index eabc546178fc08..b69a3520c8d980 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -7,10 +7,12 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { basename, dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, type CustomizationDisabledReason } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; +import { isAgentBuiltinCustomizationUri } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { CustomizationLoadStatus, CustomizationType, type AgentCustomization, type ChildCustomization, type ClientPluginCustomization, type Customization, type CustomizationLoadState, type DirectoryCustomization, PluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider, ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; @@ -134,10 +136,10 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }; } - private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, isRemote: boolean): ICustomizationItem[] { + private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, isRemote: boolean, workingDirectories: readonly string[]): ICustomizationItem[] { const items: ICustomizationItem[] = []; for (const child of customization.children ?? []) { - const item = this.toDirectoryChildItem(child, source, isRemote); + const item = this.toDirectoryChildItem(child, getDirectoryChildSource(workingDirectories, child.uri, source), isRemote); if (item) { items.push(item); } @@ -318,9 +320,11 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } for (const sessionCustomization of directoryCustomizations) { - const source = isUnderAnyRoot(workingDirectories, sessionCustomization.uri) ? AICustomizationSources.local : AICustomizationSources.user; + const source = isAgentBuiltinCustomizationUri(URI.parse(sessionCustomization.uri)) + ? AICustomizationSources.builtin + : isUnderAnyRoot(workingDirectories, sessionCustomization.uri) ? AICustomizationSources.local : AICustomizationSources.user; const isRemote = sessionCustomization.clientId !== undefined; - for (const child of this.toDirectoryItems(sessionCustomization, source, isRemote)) { + for (const child of this.toDirectoryItems(sessionCustomization, source, isRemote, workingDirectories)) { items.set(child.itemKey ?? child.uri.toString(), { ...child, status: toStatusString(sessionCustomization.load), @@ -426,6 +430,17 @@ function isUnderAnyRoot(roots: readonly string[], childURI: string): boolean { return roots.some(root => isParentOrEqual(root, childURI)); } +function getDirectoryChildSource(roots: readonly string[], childURI: string, fallback: AICustomizationSource): AICustomizationSource { + try { + if (URI.parse(childURI).scheme !== Schemas.file) { + return fallback; + } + } catch { + return fallback; + } + return isUnderAnyRoot(roots, childURI) ? AICustomizationSources.local : AICustomizationSources.user; +} + function toStatusString(load: CustomizationLoadState | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { return load?.kind; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 1847355cbf3456..ff51c76d74df0f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -62,6 +62,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { ITerminalChatService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; +import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { AgentHostCompletionReferenceKind, ChatTranscriptContextAttachmentDisplayKind, @@ -6077,19 +6078,47 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC continue; } const key = this._fileEntryDedupeKey(entry, request.sessionResource); + let forwardEntry = true; if (key) { if (existingKeys.has(key)) { - continue; + forwardEntry = false; + } else { + existingKeys.add(key); } - existingKeys.add(key); } - const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); - if (!Array.isArray(attachment) && attachment) { - attachments.push(attachment); + if (forwardEntry) { + const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); + if (!Array.isArray(attachment) && attachment) { + attachments.push(attachment); + } } + // The source may already be explicit context, but its output is a separate resource. + this._appendNotebookCellOutputAttachment(attachments, entry, existingKeys); } } + /** Add the stored outputs for an active notebook cell as a virtual JSON document. */ + private _appendNotebookCellOutputAttachment(attachments: MessageAttachment[], entry: IChatRequestVariableEntry, existingKeys: Set): void { + const value = entry.value; + const uri = isLocation(value) ? value.uri : (value instanceof URI ? value : undefined); + const cell = uri ? CellUri.parse(uri) : undefined; + if (!cell) { + return; + } + const outputUri = CellUri.generateCellPropertyUri(cell.notebook, cell.handle, Schemas.vscodeNotebookCellOutput); + const outputKey = this._attachmentDedupeKey(outputUri.toString()); + if (existingKeys.has(outputKey)) { + return; + } + existingKeys.add(outputKey); + attachments.push({ + type: MessageAttachmentKind.Resource, + uri: outputUri.toString(), + label: `${entry.name} output.json`, + displayKind: 'document', + }); + } + /** Dedupe identity for a file/implicit entry: rebased URI, suffixed with the range for a selection. */ private _fileEntryDedupeKey(entry: IChatRequestVariableEntry, sessionResource: URI): string | undefined { if (entry.kind !== 'file' && entry.kind !== 'implicit') { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 5689d7f3fb9c5c..e64a748491471c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -47,6 +47,10 @@ opacity: 0.75; } +.agent-host-chat-input-picker-slot .action-label .codicon { + font-size: var(--vscode-codiconFontSize-compact); +} + .agent-host-chat-input-picker-slot.disabled span.action-label { opacity: 0.6; cursor: default; @@ -99,17 +103,24 @@ display: none; } +.interactive-session .compact-picker .agent-host-chat-input-picker-slot { + width: 22px; + height: 22px; +} + .interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label { box-sizing: border-box; width: 22px; + height: 22px; min-width: 22px; - padding: 2px 2px 2px 8px; - justify-content: flex-start; + padding: 0; + justify-content: center; } .interactive-session .compact-picker .agent-host-chat-input-picker-slot .action-label .codicon { - width: auto; - height: auto; + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } /* diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index d395911febea47..b01895e4168515 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -21,7 +21,7 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent, NotSelectab import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, sectionToPromptType } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementCreateMenuId, AICustomizationManagementSection, AI_CUSTOMIZATION_ITEM_DISABLED_KEY, getAICustomizationManagementItemMenuId, sectionToPromptType } from './aiCustomizationManagement.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -31,7 +31,7 @@ import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabe import { matchesContiguousSubString, IMatch } from '../../../../../base/common/filters.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; -import { IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -41,6 +41,7 @@ import { IClipboardService } from '../../../../../platform/clipboard/common/clip import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; +import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; import { getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -458,7 +459,7 @@ class AICustomizationItemRenderer implements IListRenderer { @@ -838,6 +839,9 @@ export class AICustomizationListWidget extends Disposable { const nameAndDesc = secondaryText ? localize('itemAriaLabel', "{0}. {1}", displayName, secondaryText) : displayName; + if (!hasReadableCustomizationContent(entry.item.uri)) { + return localize('itemAriaLabelNoSourceContent', "{0}, source content unavailable", nameAndDesc); + } return entry.item.disabled ? localize('itemAriaLabelDisabled', "{0}, disabled", nameAndDesc) : nameAndDesc; @@ -858,7 +862,9 @@ export class AICustomizationListWidget extends Disposable { if (e.element.type === 'group-header') { this.toggleGroup(e.element); } else { - this._onDidSelectItem.fire(e.element.item); + if (hasReadableCustomizationContent(e.element.item.uri)) { + this._onDidSelectItem.fire(e.element.item); + } } } })); @@ -937,7 +943,7 @@ export class AICustomizationListWidget extends Disposable { const overlay = this.contextKeyService.createOverlay(overlayPairs); // Get menu actions, excluding inline actions to avoid duplicates - const actions = this.menuService.getMenuActions(AICustomizationManagementItemMenuId, overlay, { + const actions = this.menuService.getMenuActions(getAICustomizationManagementItemMenuId(item.uri), overlay, { arg: context, shouldForwardArgs: true, }); @@ -945,7 +951,7 @@ export class AICustomizationListWidget extends Disposable { const { secondary } = getContextMenuActions(actions, 'inline'); // Add copy path actions (not shown for built-in items where the path is an implementation detail) - const copyActions = item.isBuiltin ? [] : [ + const copyActions = item.isBuiltin || !hasReadableCustomizationContent(item.uri) ? [] : [ new Separator(), new Action('copyFullPath', localize('copyFullPath', "Copy Full Path"), undefined, true, async () => { await this.clipboardService.writeText(item.uri.fsPath); @@ -981,17 +987,7 @@ export class AICustomizationListWidget extends Disposable { pluginUri: item.pluginUri?.toString(), itemId: item.id, }; - const overlayPairs: [string, string | boolean][] = [ - [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], - [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], - [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], - [AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.source], - ]; - if (item.pluginUri) { - overlayPairs.push([AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, item.pluginUri.toString()]); - } - const overlay = this.contextKeyService.createOverlay(overlayPairs); - const menu = disposables.add(this.menuService.createMenu(AICustomizationManagementItemMenuId, overlay)); + const menu = disposables.add(this.createCardItemMenu(item)); const groups = menu.getActions({ arg: context, shouldForwardArgs: true }); const actions: IAction[] = []; const addedActionIds = new Set(); @@ -1011,7 +1007,7 @@ export class AICustomizationListWidget extends Disposable { } actions.push(...uniqueGroupActions); } - if (!item.isBuiltin) { + if (!item.isBuiltin && hasReadableCustomizationContent(item.uri)) { if (actions.length > 0) { actions.push(new Separator()); } @@ -1039,6 +1035,33 @@ export class AICustomizationListWidget extends Disposable { }); } + private createCardItemMenu(item: IAICustomizationListItem): IMenu { + const overlayPairs: [string, string | boolean][] = [ + [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], + [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], + [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], + [AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.source], + ]; + if (item.pluginUri) { + overlayPairs.push([AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, item.pluginUri.toString()]); + } + const overlay = this.contextKeyService.createOverlay(overlayPairs); + return this.menuService.createMenu(getAICustomizationManagementItemMenuId(item.uri), overlay); + } + + private hasCardItemActions(item: IAICustomizationListItem): boolean { + if (hasReadableCustomizationContent(item.uri)) { + return true; + } + + const menu = this.createCardItemMenu(item); + try { + return menu.getActions().some(([, actions]) => actions.length > 0); + } finally { + menu.dispose(); + } + } + /** * Sets the current section and binds the list to the model's per-section * observable. Returns once the initial fetch for the section has resolved @@ -1813,6 +1836,10 @@ export class AICustomizationListWidget extends Disposable { ? localize('customizationCardAriaLabelDisabled', "{0}. {1}. Disabled", displayName, accessibleSecondaryText || groupLabel) : localize('customizationCardAriaLabel', "{0}. {1}", displayName, accessibleSecondaryText || groupLabel); const primary = createCustomizationCardPrimaryAction(row, accessibleLabel, 'customization-row-primary'); + const hasReadableContent = hasReadableCustomizationContent(item.uri); + if (!hasReadableContent) { + primary.setAttribute('aria-disabled', 'true'); + } this.firstCardFocusElement ??= primary; if (!this.cardRowsByUri.has(item.uri.toString())) { this.cardRowsByUri.set(item.uri.toString(), primary); @@ -1821,11 +1848,16 @@ export class AICustomizationListWidget extends Disposable { this.cardDisposables.add(DOM.addDisposableListener(primary, 'focus', () => { this.lastCardFocusItemId = item.id; })); - this.cardDisposables.add(DOM.addDisposableListener(primary, 'click', () => this._onDidSelectItem.fire(item))); - this.cardDisposables.add(DOM.addDisposableListener(row, 'contextmenu', event => { - event.preventDefault(); - this.showCardItemActions(item, row); - })); + if (hasReadableContent) { + this.cardDisposables.add(DOM.addDisposableListener(primary, 'click', () => this._onDidSelectItem.fire(item))); + } + const hasItemActions = this.hasCardItemActions(item); + if (hasItemActions) { + this.cardDisposables.add(DOM.addDisposableListener(row, 'contextmenu', event => { + event.preventDefault(); + this.showCardItemActions(item, row); + })); + } this.cardDisposables.add(this.hoverService.setupDelayedHover(row, () => ({ content: `${displayName}\n${this.labelService.getUriLabel(item.uri, { relative: item.source === AICustomizationSources.local })}`, appearance: { compact: true, skipFadeInAnimation: true }, @@ -1843,28 +1875,32 @@ export class AICustomizationListWidget extends Disposable { const description = DOM.append(details, $('.plugin-list-item-description')); description.textContent = secondaryText ?? localize('customizationNoDescription', "No description provided."); - const actionContainer = DOM.append(row, $('.plugin-list-item-action')); - this.cardDisposables.add(DOM.addDisposableGenericMouseDownListener(actionContainer, e => e.stopPropagation())); - this.cardDisposables.add(DOM.addDisposableListener(actionContainer, 'click', e => e.stopPropagation())); - const more = this.cardDisposables.add(new Button(actionContainer, { - ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), - secondary: true, - supportIcons: true, - ariaLabel: localize('customizationMoreActionsAria', "More actions for {0}", displayName), - })); - more.element.classList.add('plugin-card-icon-button'); - more.label = `$(${Codicon.ellipsis.id})`; - this.cardMenuButtonsById.set(item.id, more.element); - this.cardDisposables.add(DOM.addDisposableListener(more.element, 'focus', () => { - this.lastCardFocusItemId = item.id; - })); - this.cardDisposables.add(more.onDidClick(() => this.showCardItemActions(item, more.element))); + const actionElements: HTMLElement[] = []; + if (hasItemActions) { + const actionContainer = DOM.append(row, $('.plugin-list-item-action')); + this.cardDisposables.add(DOM.addDisposableGenericMouseDownListener(actionContainer, e => e.stopPropagation())); + this.cardDisposables.add(DOM.addDisposableListener(actionContainer, 'click', e => e.stopPropagation())); + const more = this.cardDisposables.add(new Button(actionContainer, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('customizationMoreActionsAria', "More actions for {0}", displayName), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + this.cardMenuButtonsById.set(item.id, more.element); + this.cardDisposables.add(DOM.addDisposableListener(more.element, 'focus', () => { + this.lastCardFocusItemId = item.id; + })); + this.cardDisposables.add(more.onDidClick(() => this.showCardItemActions(item, more.element))); + actionElements.push(more.element); + } cardList.addItem({ row, primaryAction: primary, label: displayName, - actions: [more.element], - contextMenuAction: more.element, + actions: actionElements, + contextMenuAction: actionElements[0], }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 985f11a577531a..452839a6a581c5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -20,6 +20,7 @@ import { IClipboardService } from '../../../../../platform/clipboard/common/clip import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; @@ -55,6 +56,7 @@ import { AICustomizationManagementOpenEditorTarget, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, + AICustomizationManagementSyntheticItemMenuId, AICustomizationManagementSection, AICustomizationSource, resolveAICustomizationManagementOpenEditorTarget, @@ -222,6 +224,9 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + if (!hasReadableCustomizationContent(extractURI(context))) { + return; + } const editorService = accessor.get(IEditorService); const source = extractSource(context); @@ -291,6 +296,9 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + if (!hasReadableCustomizationContent(extractURI(context))) { + return; + } const fileService = accessor.get(IFileService); const dialogService = accessor.get(IDialogService); const telemetryService = accessor.get(ITelemetryService); @@ -419,6 +427,9 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + if (!hasReadableCustomizationContent(extractURI(context))) { + return; + } const clipboardService = accessor.get(IClipboardService); const uri = extractURI(context); const textToCopy = uri.scheme === 'file' ? uri.fsPath : uri.toString(true); @@ -665,53 +676,55 @@ registerAction2(class extends Action2 { } }); -// Context menu: Disable (shown when builtin item is enabled) -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable") }, - group: '5_toggle', - order: 1, - when: ContextKeyExpr.and( - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), - ), -}); - -// Context menu: Enable (shown when builtin item is disabled) -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable") }, - group: '5_toggle', - order: 1, - when: ContextKeyExpr.and( - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), - ), -}); - -// Inline hover: Disable (shown when builtin item is enabled) -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, - group: 'inline', - order: 5, - when: ContextKeyExpr.and( - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), - ), -}); - -// Inline hover: Enable (shown when builtin item is disabled) -MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { - command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, - group: 'inline', - order: 5, - when: ContextKeyExpr.and( - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), - ), -}); +for (const menuId of [AICustomizationManagementItemMenuId, AICustomizationManagementSyntheticItemMenuId]) { + // Context menu: Disable (shown when builtin item is enabled) + MenuRegistry.appendMenuItem(menuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable") }, + group: '5_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), + }); + + // Context menu: Enable (shown when builtin item is disabled) + MenuRegistry.appendMenuItem(menuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable") }, + group: '5_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), + }); + + // Inline hover: Disable (shown when builtin item is enabled) + MenuRegistry.appendMenuItem(menuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), + }); + + // Inline hover: Enable (shown when builtin item is disabled) + MenuRegistry.appendMenuItem(menuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), + }); +} //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index af4a2a46f65eb6..c399e78799e7b8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -10,6 +10,7 @@ import { AICustomizationManagementSection } from '../../common/aiCustomizationWo import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; @@ -108,6 +109,18 @@ export const AICustomizationManagementTitleMenuId = MenuId.for('AICustomizationM */ export const AICustomizationManagementItemMenuId = MenuId.for('AICustomizationManagementEditorItem'); +/** + * Internal-only menu for synthetic items that do not have source content. + * This is intentionally separate from the extension-contributable item menu. + */ +export const AICustomizationManagementSyntheticItemMenuId = MenuId.for('AICustomizationManagementEditorSyntheticItem'); + +export function getAICustomizationManagementItemMenuId(uri: URI): MenuId { + return hasReadableCustomizationContent(uri) + ? AICustomizationManagementItemMenuId + : AICustomizationManagementSyntheticItemMenuId; +} + /** * Menu ID for the AI Customization Management Editor create/add button. * Extensions can contribute commands here to add create actions to the section's add button dropdown. @@ -140,7 +153,6 @@ export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementIt */ export const AI_CUSTOMIZATION_ITEM_DISABLED_KEY = 'aiCustomizationManagementItemDisabled'; - /** * Storage key for persisting the selected section. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 9108eeedc3b1b8..4f31883ebe5fc9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -74,6 +74,7 @@ import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory, CustomizationLocationPicker } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { hasReadableCustomizationContent } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; @@ -2581,6 +2582,10 @@ export class AICustomizationManagementEditor extends EditorPane { } private async showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile = false, isReadOnly = false): Promise { + if (!hasReadableCustomizationContent(uri)) { + return; + } + this.editorReturnViewMode = this.viewMode === 'migration' ? 'migration' : 'list'; this.currentModelRef?.dispose(); this.currentModelRef = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index fc0a697f67d905..33f54d64db9a94 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -132,6 +132,7 @@ import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } fro import { PromptsDebugContribution } from './promptsDebugContribution.js'; import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; import { ChatSpeechToTextService, DictationSettingId, IChatSpeechToTextService } from './speechToText/chatSpeechToTextService.js'; +import { IVoiceCodeTranscriptionClient, VoiceCodeTranscriptionClient } from './speechToText/voiceCodeTranscriptionClient.js'; import './telemetry/chatModelCountTelemetry.js'; import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; import { ClientToolSetsContribution } from './tools/clientToolSetsContribution.js'; @@ -3277,6 +3278,7 @@ agentPluginDiscoveryRegistry.register(new SyncDescriptor(CopilotCliAgentPluginDi registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); registerSingleton(IChatSpeechToTextService, ChatSpeechToTextService, InstantiationType.Eager); +registerSingleton(IVoiceCodeTranscriptionClient, VoiceCodeTranscriptionClient, InstantiationType.Delayed); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); 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 350ffc25f15059..4378f8729c4868 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1918,8 +1918,8 @@ async function resolvePromptSlashCommand(prompt: string, sessionResource: URI, c if (slashMatch) { // need to resolve the slash command to get the prompt file const slashCommand = await customizationHarnessService.resolvePromptSlashCommand(slashMatch[1], sessionResource, CancellationToken.None); - if (slashCommand) { - const parseResult = slashCommand.parsedPromptFile; + const parseResult = slashCommand?.parsedPromptFile; + if (parseResult) { // add the prompt file to the context const refs = parseResult.body?.variableReferences.map(({ name, offset, fullLength }) => ({ name, range: new OffsetRange(offset, offset + fullLength) })) ?? []; const toolReferences = toolsService.toToolReferences(refs); diff --git a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts index d1b499919528e7..5fecd9ad47477e 100644 --- a/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts +++ b/src/vs/workbench/contrib/chat/browser/promptTimeline/promptTimelineModel.ts @@ -11,10 +11,10 @@ import { basename, isEqual } from '../../../../../base/common/resources.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; -import { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { ChatWidget } from '../widget/chatWidget.js'; import { getChatRequestText } from '../chatRequestText.js'; import { ChatTreeItem } from '../chat.js'; diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index eb07cec597ce85..802f985b171ffe 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -18,7 +18,6 @@ import { INotificationService, Severity } from '../../../../../platform/notifica import { IProgress, IProgressService, IProgressStep, Progress, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { DeferredPromise, raceCancellation, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../../base/common/errors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { localize } from '../../../../../nls.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; @@ -27,7 +26,6 @@ import { IEnvironmentService } from '../../../../../platform/environment/common/ import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL, ILocalTranscriptionModelStatus, ILocalTranscriptionService, LocalTranscriptionModelState } from '../../../../../platform/localTranscription/common/localTranscription.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; -import { IVoiceClientService, IVoiceSessionContext, IVoiceTranscription, IVoiceTurnConfig } from '../../common/voiceClient/voiceClientService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; @@ -38,6 +36,8 @@ import { createPcmCaptureNode } from '../pcmCaptureWorklet.js'; import { getMediaCaptureWindow } from '../voiceClient/micCaptureService.js'; import { resolveDictationLanguage } from './dictationLanguage.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IVoiceCodeTranscription, IVoiceCodeTranscriptionClient } from './voiceCodeTranscriptionClient.js'; +import { getTranscriptionWebSocketUrl } from '../voiceClient/voiceEndpoint.js'; export const IChatSpeechToTextService = createDecorator('chatSpeechToTextService'); @@ -63,6 +63,10 @@ export function stripDictationFillers(text: string): string { .replace(/^[ \t]+|[ \t]+$/g, ''); } +export function selectAuthoritativeDictationTranscript(liveTranscript: string, backendTranscript: string | undefined): string { + return backendTranscript ?? liveTranscript; +} + export function selectFinalDictationTranscript(liveTranscript: string, backendTranscript: string | undefined, preserveLiveTranscript: boolean): string { const visibleLiveTranscript = stripDictationFillers(liveTranscript); if (preserveLiveTranscript && visibleLiveTranscript && !stripDictationFillers(backendTranscript ?? '').startsWith(visibleLiveTranscript)) { @@ -115,7 +119,7 @@ export const enum DictationSettingId { ShowButton = 'dictation.showButton', } -/** `dictation.model` sentinel selecting the cloud voice backend used by Voice Mode. */ +/** `dictation.model` sentinel selecting the cloud transcription backend. */ export const DICTATION_MAI_MODEL_ID = 'mai'; /** @@ -147,7 +151,7 @@ type DictationCleanupModel = 'none' | 'copilot-utility-small' | 'gpt-5.4-nano' | /** * Which backend transcribes dictation audio: * - `nemo`: an on-device model via {@link ILocalTranscriptionService} (Foundry Local). - * - `mai`: the cloud voice service used by Voice Mode, via {@link IVoiceClientService}. + * - `mai`: the cloud transcription service. */ type DictationBackend = 'nemo' | 'mai'; @@ -155,14 +159,10 @@ export function isDictationEntitled(entitlement: ChatEntitlement, isInternal: bo return !usesMai || entitlement !== ChatEntitlement.Enterprise || isInternal; } -/** How long to wait for the voice websocket to connect before failing an MAI session. */ -const MAI_CONNECT_TIMEOUT_MS = 8000; /** How long to wait after `ptt_end` for the backend's final transcript before returning what we have. */ -const MAI_FINAL_TIMEOUT_MS = 4000; +const MAI_FINAL_TIMEOUT_MS = 35_000; /** How long to wait for the on-device backend to finish before returning its streamed transcript. */ const NEMO_FINAL_TIMEOUT_MS = 8000; -/** How long to wait for the backend to acknowledge the opened session before streaming audio anyway. */ -const MAI_SESSION_INIT_TIMEOUT_MS = 4000; type SpeechToTextSessionEvent = { outcome: 'completed' | 'cancelled' | 'error'; @@ -468,19 +468,14 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo /** Backend selected for the in-progress session; set at `start`. */ private _activeBackend: DictationBackend = 'nemo'; - // --- MAI (cloud voice) session state. --- + // --- MAI cloud transcription session state. --- /** Disposables for the active MAI session (transcription listener, etc.). */ private readonly _maiSessionDisposables = this._register(new DisposableStore()); /** Capture turn id for the active MAI push-to-talk turn. */ private _maiTurnId = ''; - /** Highest transcription revision seen for the active MAI turn; drops stale/out-of-order events. */ - private _maiRevision = -1; - /** Whether this dictation established the shared voice connection (and may thus tear it down). */ - private _maiOwnsConnection = false; - /** Whether the active MAI startup reached a connected voice socket. */ - private _maiConnected = false; /** Resolves when the backend emits the final transcript after `ptt_end`. */ private _maiFinalTranscript: DeferredPromise | undefined; + private _maiReceivedFinal = false; get isConfigured(): boolean { if (this._configurationService.getValue(ENABLED_SETTING) === false) { @@ -491,7 +486,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return false; } if (backend === 'mai') { - return !!this._voiceWsUrl() && this._hasGitHubSession; + return !!this._transcriptionWsUrl() && this._hasGitHubSession; } // On-device transcription needs no configuration — the model downloads // on first use. It is only unavailable where the platform lacks native @@ -549,7 +544,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo @ITelemetryService private readonly _telemetryService: ITelemetryService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, @ILocalTranscriptionService private readonly _localTranscription: ILocalTranscriptionService, - @IVoiceClientService private readonly _voiceClientService: IVoiceClientService, + @IVoiceCodeTranscriptionClient private readonly _transcriptionClient: IVoiceCodeTranscriptionClient, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IProductService private readonly _productService: IProductService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @@ -652,11 +647,9 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo }); } - /** Voice websocket endpoint used by the MAI backend (shared with Voice Mode). */ - private _voiceWsUrl(): string { - const configured = this._configurationService.getValue('agents.voice.backendUrl'); - const url = typeof configured === 'string' ? configured.trim() : ''; - return url || this._productService.voiceWsUrl || ''; + /** Dedicated transcription websocket endpoint used by the MAI backend. */ + private _transcriptionWsUrl(): string { + return getTranscriptionWebSocketUrl(this._configurationService, this._productService); } private _updateConfiguredContextKey(): void { @@ -808,7 +801,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo }); return; } - if (backend === 'mai' && !this._voiceWsUrl()) { + if (backend === 'mai' && !this._transcriptionWsUrl()) { this._notificationService.notify({ severity: Severity.Warning, message: localize('chatStt.maiNotConfigured', "Cloud speech-to-text is not available: no voice service is configured."), @@ -960,22 +953,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo }); } - /** - * Begin a cloud transcription session over the shared Voice Mode websocket: - * connect, then open a single push-to-talk turn whose streamed audio the - * backend transcribes. Interim/final `transcription` events are piped onto - * the shared cumulative-transcript surface. - * - * The websocket is a single connection shared with Voice Mode. We refuse to - * start when it is already connected (another owner holds it) and only tear - * down a connection we ourselves established, so dictation and Voice Mode - * cannot disconnect each other. - */ private async _startMaiSession(window: Window & typeof globalThis, generation: number): Promise { - if (this._voiceClientService.isConnected) { - this._sessionErrorCode = this._sessionErrorCode || 'connect.busy'; - throw new Error(localize('chatStt.maiBusy', "Cloud dictation is unavailable while Voice Mode is connected.")); - } const authToken = await this._getGitHubToken(); if (generation !== this._sessionGeneration) { return; @@ -986,114 +964,42 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } this._maiTurnId = generateUuid(); - this._maiRevision = -1; - this._maiSessionDisposables.add(this._voiceClientService.onTranscription(e => this._handleMaiTranscription(e))); - // A terminal close (e.g. code 4008 when another window takes over the - // single voice session) stops reconnection; without this the mic would - // stay open in Recording while audio is silently dropped. - this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(e => { - if (this._maiConnected || this._state !== ChatSpeechToTextState.Idle) { - this._sessionCloseCode = e.code; + this._maiReceivedFinal = false; + this._maiSessionDisposables.add(this._transcriptionClient.onTranscription(e => this._handleMaiTranscription(e))); + this._maiSessionDisposables.add(this._transcriptionClient.onError(error => { + this._logService.warn(`[chat-stt] transcription service error during dictation: ${error.detail}`); + if (error.terminal) { this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")); } })); - this._maiSessionDisposables.add(this._voiceClientService.onError(msg => - this._logService.warn(`[chat-stt] voice service error during dictation: ${msg}`))); - - // We are initiating the connection; mark ownership before connecting so a - // failed/partial connect is still torn down by our teardown path. - this._maiOwnsConnection = true; - // Connecting to the cloud voice service and opening the session takes a - // moment on the first dictation; surface the same spinner affordance the - // on-device path uses while its model prepares. Cleared once the session - // is established (below) or by teardown on failure. + this._maiSessionDisposables.add(this._transcriptionClient.onDidClose(code => { + this._sessionCloseCode = code; + this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")); + })); this._setPreparingModel(true); - await this._voiceClientService.connect(window, authToken); - await this._awaitVoiceConnected(); + await this._transcriptionClient.connect(window, authToken); if (generation !== this._sessionGeneration) { return; } - - // The backend drops PTT audio until a session is opened, so establish a - // minimal (session-less) dictation session and wait for the backend to - // acknowledge it before streaming audio. The websocket preserves order, - // but the ack guarantees the session exists server-side first. - // - // Dictation is one continuous turn: the user taps to start, speaks - // several phrases with pauses in between, and taps to stop. Disable the - // backend's automatic turn endpointing (VAD silence / stop phrases) so a - // pause between phrases does not end the turn — otherwise everything - // after the first pause lands in a new (dropped) turn and is lost. - const context: IVoiceSessionContext = { sessions: [], display_locale: '' }; - const turnConfig: IVoiceTurnConfig = { auto_end_mode: 'off', silence_ms: 0, stop_phrases: [], vad_gate_asr: false }; - this._voiceClientService.sendStartSession(context, this._telemetryService.machineId, undefined, turnConfig); - await this._awaitSessionInit(); + await this._transcriptionClient.startSession(); if (generation !== this._sessionGeneration) { return; } - - // Session is live; drop the connecting spinner so the mic reads as - // recording when start() transitions to the Recording state. this._setPreparingModel(false); - this._voiceClientService.sendPttStart(this._maiTurnId, { hasActiveSession: false }); + this._transcriptionClient.sendPttStart(this._maiTurnId); } - /** - * Wait for the backend to acknowledge the opened session (`onSessionInit`), - * resolving on a timeout so a missing ack cannot wedge dictation: the - * websocket preserves order, so `ptt_start` still follows `start_session`. - */ - private async _awaitSessionInit(): Promise { - await new Promise(resolve => { - const store = new DisposableStore(); - this._maiSessionDisposables.add(store); - store.add(toDisposable(resolve)); - const timer = setTimeout(() => { - store.dispose(); - }, MAI_SESSION_INIT_TIMEOUT_MS); - store.add(toDisposable(() => clearTimeout(timer))); - store.add(this._voiceClientService.onSessionInit(() => { - store.dispose(); - })); - }); - } - - /** - * Handle a transcription event from the shared voice socket. Events for a - * different (non-empty) turn are dropped so a stale/foreign frame — e.g. a - * replay from a previous session on the shared backend — cannot resurrect - * the prior transcript; a frame without a turnId is accepted since the - * conversational socket does not always tag transcription frames. Within our - * turn, a stale (non-increasing) revision is dropped so a late event cannot - * overwrite newer text or resolve the final waiter early. `text` is the full - * cumulative transcript for the turn. - */ - private _handleMaiTranscription(e: IVoiceTranscription): void { - if (e.turnId !== undefined && this._maiTurnId && e.turnId !== this._maiTurnId) { - this._logService.trace(`[chat-stt] mai transcription dropped (turn ${e.turnId} != ${this._maiTurnId})`); - return; - } - if (e.revision !== undefined) { - if (e.revision <= this._maiRevision) { - this._logService.trace(`[chat-stt] mai transcription dropped (revision ${e.revision} <= ${this._maiRevision})`); - return; - } - this._maiRevision = e.revision; - } - this._logService.trace(`[chat-stt] mai transcription status=${e.status ?? 'none'} revision=${e.revision ?? 'none'} len=${e.text.length}`); - this._emitTranscript(e.text, e.committed ?? '', e.status === 'final'); + private _handleMaiTranscription(e: IVoiceCodeTranscription): void { + this._logService.trace(`[chat-stt] mai transcription status=${e.status} revision=${e.revision} len=${e.text.length}`); + this._emitTranscript(e.text, e.committed, e.status === 'final'); if (e.status === 'final') { + this._maiReceivedFinal = true; this._maiFinalTranscript?.complete(); } } - /** - * Abort an in-progress MAI dictation after a terminal disconnect: log the - * failure, release the final waiter so `stopAndTranscribe` does not hang, - * tear down the mic/session, and surface an actionable message. - */ private _failMaiSession(message: string): void { - if (this._activeBackend !== 'mai' || (this._state === ChatSpeechToTextState.Idle && !this._maiConnected)) { + if (this._activeBackend !== 'mai' || (this._state === ChatSpeechToTextState.Idle && !this._maiTurnId)) { return; } this._sessionGeneration++; @@ -1122,53 +1028,6 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } } - /** Wait for the voice websocket to report connected, or reject on timeout. */ - private async _awaitVoiceConnected(): Promise { - if (this._voiceClientService.isConnected) { - this._maiConnected = true; - return; - } - await new Promise((resolve, reject) => { - const store = new DisposableStore(); - this._maiSessionDisposables.add(store); - let settled = false; - const settle = (error?: Error) => { - if (settled) { - return; - } - settled = true; - store.dispose(); - if (error) { - reject(error); - } else { - resolve(); - } - }; - store.add(toDisposable(() => { - if (!settled) { - settled = true; - reject(new CancellationError()); - } - })); - const timer = setTimeout(() => { - this._sessionErrorCode = this._sessionErrorCode || 'connect.timeout'; - settle(new Error(localize('chatStt.maiConnectTimeout', "Timed out connecting to the voice service."))); - }, MAI_CONNECT_TIMEOUT_MS); - store.add(toDisposable(() => clearTimeout(timer))); - store.add(this._voiceClientService.onDidChangeConnectionState(connected => { - if (connected) { - this._maiConnected = true; - settle(); - } - })); - store.add(this._voiceClientService.onFatalDisconnect(e => { - this._sessionCloseCode = e.code; - this._sessionErrorCode = this._sessionErrorCode || `connect.rejected.${e.code}`; - settle(new Error(localize('chatStt.maiConnectRejected', "The voice service rejected the connection (code {0}).", e.code))); - })); - }); - } - /** * Begin an on-device transcription session in the utility process and pipe * its interim/final results onto the shared cumulative-transcript surface. @@ -1435,12 +1294,20 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo const stopMs = Date.now(); const liveTranscript = this._transcript; let text = liveTranscript; + let hasAuthoritativeFinal = false; try { const finalText = await this._finishBackend(); if (generation !== this._sessionGeneration) { return undefined; } - text = selectFinalDictationTranscript(liveTranscript, finalText, options?.preserveLiveTranscript === true); + hasAuthoritativeFinal = this._activeBackend === 'mai' && this._maiReceivedFinal; + text = hasAuthoritativeFinal + ? selectAuthoritativeDictationTranscript(liveTranscript, finalText) + : selectFinalDictationTranscript( + liveTranscript, + finalText, + this._activeBackend !== 'mai' && options?.preserveLiveTranscript === true, + ); } catch (err) { if (generation !== this._sessionGeneration) { return undefined; @@ -1479,7 +1346,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._teardown(); this._setState(ChatSpeechToTextState.Idle); const fillerStrippedText = stripDictationFillers(text); - return fillerStrippedText || undefined; + return fillerStrippedText || (hasAuthoritativeFinal ? '' : undefined); } /** @@ -1647,12 +1514,18 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo */ private async _finishBackend(): Promise { if (this._activeBackend === 'mai') { - this._maiFinalTranscript = new DeferredPromise(); - this._voiceClientService.sendPttEnd(); - await Promise.race([ - this._maiFinalTranscript.p, - new Promise(resolve => setTimeout(resolve, MAI_FINAL_TIMEOUT_MS)), - ]); + const finalTranscript = this._maiFinalTranscript = new DeferredPromise(); + this._transcriptionClient.sendPttEnd(this._maiTurnId); + let timeout: ReturnType | undefined; + const receivedFinal = await Promise.race([ + finalTranscript.p.then(() => true), + new Promise(resolve => { + timeout = setTimeout(() => resolve(false), MAI_FINAL_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timeout)); + if (!receivedFinal) { + this._logService.warn(`[chat-stt] cloud final transcription timed out after ${MAI_FINAL_TIMEOUT_MS}ms; using streamed transcript`); + } return this._transcript; } const stop = this._localTranscription.stop(); @@ -1696,11 +1569,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo /** Abort the active backend's session, discarding any transcript in flight. */ private _cancelBackend(): void { if (this._activeBackend === 'mai') { - // Only tear down a connection we established (never Voice Mode's). - if (this._maiOwnsConnection) { - this._voiceClientService.disconnect(); - this._maiOwnsConnection = false; - } + this._transcriptionClient.disconnect(); return; } this._localTranscription.cancel(); @@ -1750,7 +1619,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } const buffer = encodeRawPcm16Buffer(samples); if (this._activeBackend === 'mai') { - this._voiceClientService.sendPttAudioChunk(encodeBase64(buffer)); + try { + this._transcriptionClient.sendPttAudioChunk(this._maiTurnId, encodeBase64(buffer)); + } catch (error) { + this._onAudioPushError(error); + } return; } this._localTranscription.pushAudio(buffer).catch(err => this._onAudioPushError(err)); @@ -1829,18 +1702,14 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // model reached a terminal state does not emit a model-prepare event. this._prepareStartMs = 0; this._localSessionDisposables.clear(); - // Release the cloud voice session and its listeners (idempotent if the - // backend was already cancelled/disconnected). + // Release the cloud transcription session and its listeners. this._maiSessionDisposables.clear(); + this._maiFinalTranscript?.complete(); this._maiFinalTranscript = undefined; this._maiTurnId = ''; - this._maiRevision = -1; - this._maiConnected = false; - // Release the shared voice connection only if this dictation owns it, so - // tearing down never disconnects a session Voice Mode established. - if (this._activeBackend === 'mai' && this._maiOwnsConnection) { - this._voiceClientService.disconnect(); - this._maiOwnsConnection = false; + this._maiReceivedFinal = false; + if (this._activeBackend === 'mai') { + this._transcriptionClient.disconnect(); } // Do not retain transcript text beyond the session that produced it. this._finalizedText = ''; diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/voiceCodeTranscriptionClient.ts b/src/vs/workbench/contrib/chat/browser/speechToText/voiceCodeTranscriptionClient.ts new file mode 100644 index 00000000000000..9cb7be95a10665 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/voiceCodeTranscriptionClient.ts @@ -0,0 +1,408 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { addWebSocketAuthToken, getTranscriptionWebSocketUrl } from '../voiceClient/voiceEndpoint.js'; + +const PING_INTERVAL_MS = 25_000; +const PONG_TIMEOUT_MS = 10_000; +const CONNECT_TIMEOUT_MS = 8000; +const SESSION_INIT_TIMEOUT_MS = 4000; + +export const IVoiceCodeTranscriptionClient = createDecorator('voiceCodeTranscriptionClient'); + +export interface IVoiceCodeTranscription { + readonly turnId: string; + readonly text: string; + readonly status: 'partial' | 'final'; + readonly revision: number; + readonly committed: string; +} + +export interface IVoiceCodeTranscriptionError { + readonly detail: string; + readonly code?: string; + readonly turnId?: string; + readonly terminal?: boolean; +} + +export interface IVoiceCodeTranscriptionClient { + readonly _serviceBrand: undefined; + readonly onTranscription: Event; + readonly onError: Event; + readonly onDidClose: Event; + readonly isConnected: boolean; + + connect(window: Window & typeof globalThis, authToken: string): Promise; + startSession(): Promise; + sendPttStart(turnId: string): void; + sendPttAudioChunk(turnId: string, audio: string): void; + sendPttEnd(turnId: string): void; + disconnect(): void; +} + +export class VoiceCodeTranscriptionClient extends Disposable implements IVoiceCodeTranscriptionClient { + declare readonly _serviceBrand: undefined; + + private readonly _onTranscription = this._register(new Emitter()); + readonly onTranscription = this._onTranscription.event; + private readonly _onError = this._register(new Emitter()); + readonly onError = this._onError.event; + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + private _socket: WebSocket | undefined; + private _connectDeferred: DeferredPromise | undefined; + private _sessionInit: DeferredPromise | undefined; + private _connectTimeout: ReturnType | undefined; + private _sessionInitTimeout: ReturnType | undefined; + private _intentionalClose = false; + private _window: (Window & typeof globalThis) | undefined; + private _pingTimer: ReturnType | undefined; + private _pongTimer: ReturnType | undefined; + private readonly _activeTurns = new Set(); + private readonly _lastRevisionByTurn = new Map(); + + get isConnected(): boolean { + return this._socket?.readyState === WebSocket.OPEN; + } + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IProductService private readonly _productService: IProductService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async connect(window: Window & typeof globalThis, authToken: string): Promise { + this.disconnect(); + const baseUrl = getTranscriptionWebSocketUrl(this._configurationService, this._productService); + if (!baseUrl) { + throw new Error('No transcription WebSocket URL is configured'); + } + + this._intentionalClose = false; + this._window = window; + const socket = new window.WebSocket(addWebSocketAuthToken(baseUrl, authToken)); + this._socket = socket; + const opened = this._connectDeferred = new DeferredPromise(); + this._connectTimeout = setTimeout(() => { + if (this._socket === socket && !opened.isSettled) { + opened.error(new Error('Timed out connecting to the transcription service')); + socket.close(4000, 'connect timeout'); + } + }, CONNECT_TIMEOUT_MS); + socket.onopen = () => { + if (this._socket === socket) { + this._clearConnectTimeout(); + this._startPing(); + opened.complete(); + } + }; + socket.onmessage = event => { + if (this._socket === socket) { + this._handleMessage(event); + } + }; + socket.onerror = () => { + if (this._socket !== socket) { + return; + } + this._clearConnectTimeout(); + if (!opened.isSettled) { + opened.error(new Error('Transcription WebSocket connection failed')); + return; + } + this._reportError('Transcription WebSocket error', undefined, undefined, true); + }; + socket.onclose = event => { + if (this._socket !== socket) { + return; + } + this._socket = undefined; + this._clearConnectTimeout(); + this._clearSessionInitTimeout(); + this._stopPing(); + const detail = `Transcription connection closed (${event.code})${event.reason ? `: ${event.reason}` : ''}`; + this._sessionInit?.error(new Error(detail)); + this._sessionInit = undefined; + if (!opened.isSettled) { + opened.error(new Error(detail)); + } + if (!this._intentionalClose) { + this._onDidClose.fire(event.code); + this._reportError(detail, undefined, undefined, true); + } + }; + try { + await opened.p; + } finally { + if (this._connectDeferred === opened) { + this._connectDeferred = undefined; + } + } + } + + async startSession(): Promise { + this._assertConnected(); + if (this._sessionInit) { + throw new Error('Transcription session initialization is already in progress'); + } + this._sessionInit = new DeferredPromise(); + this._sessionInitTimeout = setTimeout(() => { + if (this._sessionInit) { + this._sessionInit.error(new Error('Timed out initializing the transcription session')); + this._sessionInit = undefined; + this._socket?.close(4000, 'session initialization timeout'); + } + }, SESSION_INIT_TIMEOUT_MS); + this._send({ type: 'start_session' }); + try { + await this._sessionInit.p; + } finally { + this._clearSessionInitTimeout(); + } + } + + sendPttStart(turnId: string): void { + this._assertTurnId(turnId); + if (this._activeTurns.has(turnId)) { + throw new Error('Transcription turn is already active'); + } + this._activeTurns.add(turnId); + this._lastRevisionByTurn.set(turnId, 0); + this._send({ type: 'ptt_start', turn_id: turnId }); + } + + sendPttAudioChunk(turnId: string, audio: string): void { + this._assertTurnId(turnId); + this._assertActiveTurn(turnId); + if (!audio) { + throw new Error('Transcription audio must be non-empty'); + } + this._send({ type: 'ptt_audio_chunk', turn_id: turnId, audio }); + } + + sendPttEnd(turnId: string): void { + this._assertTurnId(turnId); + this._assertActiveTurn(turnId); + this._send({ type: 'ptt_end', turn_id: turnId }); + this._activeTurns.delete(turnId); + } + + disconnect(): void { + this._intentionalClose = true; + this._stopPing(); + this._clearConnectTimeout(); + this._clearSessionInitTimeout(); + this._connectDeferred?.cancel(); + this._connectDeferred = undefined; + this._sessionInit?.cancel(); + this._sessionInit = undefined; + const socket = this._socket; + this._socket = undefined; + this._window = undefined; + this._activeTurns.clear(); + this._lastRevisionByTurn.clear(); + if (socket && socket.readyState < WebSocket.CLOSING) { + socket.close(); + } + } + + override dispose(): void { + this.disconnect(); + super.dispose(); + } + + private _handleMessage(event: MessageEvent): void { + const message = parseMessage(event.data); + if (!message) { + this._logService.warn('[chat-stt] ignored malformed transcription frame'); + return; + } + switch (message.type) { + case 'pong': + this._clearPongTimeout(); + return; + case 'session_init': + if (typeof message.session_id !== 'string' || !message.session_id) { + this._logService.warn('[chat-stt] ignored malformed session_init frame'); + return; + } + this._sessionInit?.complete(); + this._sessionInit = undefined; + this._clearSessionInitTimeout(); + return; + case 'transcription': + this._handleTranscription(message); + return; + case 'error': + this._handleError(message); + return; + default: + this._logService.warn(`[chat-stt] ignored unsupported transcription frame type ${message.type}`); + } + } + + private _handleTranscription(message: Record): void { + const transcription = parseTranscription(message); + if (!transcription) { + this._logService.warn('[chat-stt] ignored malformed transcription metadata'); + return; + } + const { turnId, revision } = transcription; + const lastRevision = this._lastRevisionByTurn.get(turnId); + if (lastRevision === undefined || revision <= lastRevision) { + return; + } + this._lastRevisionByTurn.set(turnId, revision); + this._onTranscription.fire(transcription); + } + + private _handleError(message: Record): void { + if (typeof message.detail !== 'string' || !message.detail) { + this._logService.warn('[chat-stt] ignored malformed transcription error frame'); + return; + } + this._reportError( + message.detail, + optionalNonEmptyString(message.code), + optionalNonEmptyString(message.turn_id), + optionalBoolean(message.terminal), + ); + } + + private _reportError(detail: string, code?: string, turnId?: string, terminal?: boolean): void { + this._onError.fire({ + detail, + ...(code !== undefined ? { code } : {}), + ...(turnId !== undefined ? { turnId } : {}), + ...(terminal !== undefined ? { terminal } : {}), + }); + } + + private _send(message: Record): void { + this._assertConnected(); + this._socket!.send(JSON.stringify(message)); + } + + private _assertConnected(): void { + if (!this.isConnected) { + throw new Error('Transcription WebSocket is not connected'); + } + } + + private _assertTurnId(turnId: string): void { + if (!turnId) { + throw new Error('Transcription turn ID must be non-empty'); + } + } + + private _assertActiveTurn(turnId: string): void { + if (!this._activeTurns.has(turnId)) { + throw new Error('Transcription turn is not active'); + } + } + + private _startPing(): void { + this._stopPing(); + if (!this._window) { + return; + } + this._pingTimer = this._window.setInterval(() => { + if (!this.isConnected) { + return; + } + this._send({ type: 'ping' }); + this._clearPongTimeout(); + this._pongTimer = setTimeout(() => this._socket?.close(4000, 'pong timeout'), PONG_TIMEOUT_MS); + }, PING_INTERVAL_MS); + } + + private _stopPing(): void { + if (this._pingTimer !== undefined) { + this._window?.clearInterval(this._pingTimer); + this._pingTimer = undefined; + } + this._clearPongTimeout(); + } + + private _clearPongTimeout(): void { + if (this._pongTimer !== undefined) { + clearTimeout(this._pongTimer); + this._pongTimer = undefined; + } + } + + private _clearConnectTimeout(): void { + if (this._connectTimeout !== undefined) { + clearTimeout(this._connectTimeout); + this._connectTimeout = undefined; + } + } + + private _clearSessionInitTimeout(): void { + if (this._sessionInitTimeout !== undefined) { + clearTimeout(this._sessionInitTimeout); + this._sessionInitTimeout = undefined; + } + } +} + +function parseMessage(data: unknown): Record | undefined { + if (typeof data !== 'string') { + return undefined; + } + try { + const value: unknown = JSON.parse(data); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const message = value as Record; + return typeof message.type === 'string' && message.type ? message : undefined; + } catch { + return undefined; + } +} + +function parseTranscription(message: Record): IVoiceCodeTranscription | undefined { + const turnId = optionalNonEmptyString(message.turn_id); + if (turnId === undefined || typeof message.text !== 'string') { + return undefined; + } + const status = transcriptionStatus(message.status); + const revision = positiveSafeInteger(message.revision); + if (status === undefined || revision === undefined || !isOptionalString(message.committed)) { + return undefined; + } + return { turnId, text: message.text, status, revision, committed: message.committed ?? '' }; +} + +function transcriptionStatus(value: unknown): IVoiceCodeTranscription['status'] | undefined { + return value === 'partial' || value === 'final' ? value : undefined; +} + +function positiveSafeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function optionalNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index b96f4af93da37f..a956f97414d0d3 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -39,6 +39,7 @@ import { } from '../../common/voiceClient/voiceClientService.js'; import { isTerminalCloseCode, voiceCloseCodeInfo } from '../../common/voiceClient/voiceCloseCodes.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { getVoiceWebSocketUrl } from './voiceEndpoint.js'; const PING_INTERVAL_MS = 25_000; const PONG_TIMEOUT_MS = 10_000; @@ -306,9 +307,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } private _getWsUrl(): string { - const configured = this._configurationService.getValue('agents.voice.backendUrl'); - const url = typeof configured === 'string' ? configured.trim() : ''; - return url || this._productService.voiceWsUrl || ''; + return getVoiceWebSocketUrl(this._configurationService, this._productService); } async connect(window: Window & typeof globalThis, authToken?: string): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceEndpoint.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceEndpoint.ts new file mode 100644 index 00000000000000..f48605787e4ff1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceEndpoint.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; + +const VOICE_PATH = '/realtime/voice'; +const TRANSCRIPTION_PATH = '/realtime/transcription'; + +export function getVoiceWebSocketUrl(configurationService: IConfigurationService, productService: IProductService): string { + const configured = configurationService.getValue('agents.voice.backendUrl'); + const configuredUrl = typeof configured === 'string' ? configured.trim() : ''; + return configuredUrl || productService.voiceWsUrl || ''; +} + +export function getTranscriptionWebSocketUrl(configurationService: IConfigurationService, productService: IProductService): string { + const configured = configurationService.getValue('agents.voice.backendUrl'); + const configuredUrl = typeof configured === 'string' ? configured.trim() : ''; + const voiceUrl = configuredUrl && isLoopbackWebSocketUrl(configuredUrl) + ? configuredUrl + : productService.voiceWsUrl || ''; + if (!voiceUrl) { + return ''; + } + + try { + const url = new URL(voiceUrl); + const path = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + if (!path.endsWith(VOICE_PATH)) { + return ''; + } + url.pathname = `${path.slice(0, -VOICE_PATH.length)}${TRANSCRIPTION_PATH}`; + return url.toString(); + } catch { + return ''; + } +} + +export function addWebSocketAuthToken(url: string, token: string): string { + const authenticatedUrl = new URL(url); + authenticatedUrl.searchParams.set('token', token); + return authenticatedUrl.toString(); +} + +function isLoopbackWebSocketUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === 'ws:' || url.protocol === 'wss:') && isLoopbackHost(url.hostname); + } catch { + return false; + } +} + +function isLoopbackHost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css index 5a99d69fbb87b7..46888842418dbd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentMergeContent.css @@ -135,22 +135,13 @@ the codicon base rule, which would otherwise force every glyph to 16px. */ font-size: var(--vscode-codiconFontSize-compact); color: var(--vscode-descriptionForeground); pointer-events: none; - transition: opacity 100ms ease-out, transform 100ms ease-out; + transition: transform 100ms ease-out; } .chat-agent-merge .chat-agent-merge-header-content + .chat-agent-merge-twistie { margin-inline-start: auto; } -.chat-agent-merge .chat-agent-merge-twistie { - opacity: 0; -} - -.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header:hover > .chat-agent-merge-twistie, -.chat-agent-merge > .chat-agent-merge-card > .chat-agent-merge-header:focus-within > .chat-agent-merge-twistie { - opacity: 1; -} - /* Reduced motion follows the workbench-managed `.monaco-reduce-motion` class, which sits either on an ancestor or on the workbench element itself. */ .monaco-reduce-motion .chat-agent-merge .chat-agent-merge-twistie.codicon, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 10014ff6858afb..378b59a8e9407c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -3143,11 +3143,6 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!slashCommand) { return true; } - const parseResult = slashCommand.parsedPromptFile; - // add the prompt file to the context - const refs = parseResult.body?.variableReferences.map(({ name, offset, fullLength }) => ({ name, range: new OffsetRange(offset, offset + fullLength) })) ?? []; - const toolReferences = this.toolsService.toToolReferences(refs); - requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences)); const promptRunEvent: ChatPromptRunEvent = { storage: slashCommand.storage, @@ -3160,6 +3155,16 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.telemetryService.publicLog2('chat.promptRun', promptRunEvent); + const parseResult = slashCommand.parsedPromptFile; + if (!parseResult) { + return true; + } + + // add the prompt file to the context + const refs = parseResult.body?.variableReferences.map(({ name, offset, fullLength }) => ({ name, range: new OffsetRange(offset, offset + fullLength) })) ?? []; + const toolReferences = this.toolsService.toToolReferences(refs); + requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences)); + if (parseResult.header) { const applied = await this._applyPromptMetadata(parseResult.header, requestInput); if (!applied) { 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 7ad8f398f95ad9..18ace5d118e554 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -185,6 +185,7 @@ const INPUT_EDITOR_LINE_HEIGHT = 20; const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } }; const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; +const CHAT_INPUT_COMPACT_PICKER_WIDTH = 22; function getToolbarPickerResponsiveItems( toolbar: MenuWorkbenchToolBar, @@ -3539,9 +3540,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this.modelWidget?.minimumWidth ?? 60; } if (shorterChatInputActionIds.has(action.id)) { - return 22; + return CHAT_INPUT_COMPACT_PICKER_WIDTH; } - return inputPickerCompactStates.get(action.id)?.get() ? 22 : undefined; + return inputPickerCompactStates.get(action.id)?.get() ? CHAT_INPUT_COMPACT_PICKER_WIDTH : undefined; }; this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); @@ -3747,20 +3748,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // floor so icon-only items do not retain empty space from the labeled form. // The tunnel-sharing toggle has no chevron and can collapse further. const secondaryPickerMinWidths = new Map([ - [OpenSessionTargetPickerAction.ID, 22], - [OpenDelegationPickerAction.ID, 22], - [OpenWorkspacePickerAction.ID, 22], - [OpenPermissionPickerAction.ID, 22], - [ChatSessionPrimaryPickerAction.ID, 22], - [OpenAgentHostModePickerAction.ID, 22], - ['sessions.agentHost.runningSessionModePicker', 22], - ['sessions.agentHost.runningSessionConfigPicker', 22], - ['sessions.agentHost.runningSessionPermissionModePicker', 22], - ['sessions.agentHost.runningSessionCodexApprovalsPicker', 22], - [OpenAgentHostAutoApprovePickerAction.ID, 22], - [OpenAgentHostPermissionModePickerAction.ID, 22], - [OpenAgentHostCodexApprovalsPickerAction.ID, 22], - [OpenAgentHostFolderPickerAction.ID, 22], + [OpenSessionTargetPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenDelegationPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenWorkspacePickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenPermissionPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [ChatSessionPrimaryPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenAgentHostModePickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + ['sessions.agentHost.runningSessionModePicker', CHAT_INPUT_COMPACT_PICKER_WIDTH], + ['sessions.agentHost.runningSessionConfigPicker', CHAT_INPUT_COMPACT_PICKER_WIDTH], + ['sessions.agentHost.runningSessionPermissionModePicker', CHAT_INPUT_COMPACT_PICKER_WIDTH], + ['sessions.agentHost.runningSessionCodexApprovalsPicker', CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenAgentHostAutoApprovePickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenAgentHostPermissionModePickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenAgentHostCodexApprovalsPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], + [OpenAgentHostFolderPickerAction.ID, CHAT_INPUT_COMPACT_PICKER_WIDTH], ['sessions.tunnelHost.toggleSharing', 16], ]); // Direct-rendered chip lane for agent-host config properties that diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index be4d1807694a0c..ff9dc8e50ef6a4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -86,14 +86,23 @@ .chat-input-picker-item .action-label.model-picker-split.compact .model-picker-name { flex: 0 0 auto; - padding: 0 var(--vscode-spacing-size60); - justify-content: flex-start; + width: 22px; + height: 22px; + padding: 0; + justify-content: center; +} + +.chat-input-picker-item .action-label.model-picker-split.compact .model-picker-name > .codicon { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split.icon-only.minimal .model-picker-name { - width: 24px; - padding: 0 var(--vscode-spacing-size60); - justify-content: flex-start; + width: 22px; + height: 22px; + padding: 0; + justify-content: center; } .chat-input-picker-item .action-label.model-picker-split .model-picker-config { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index a9c91949385653..b44caef1f00c0d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -51,7 +51,8 @@ const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; const MODEL_PICKER_MINIMUM_LABEL_WIDTH = 60; const MODEL_PICKER_NAME_CHROME_WIDTH = 30; const MODEL_PICKER_MINIMUM_NAME_WIDTH = MODEL_PICKER_MINIMUM_LABEL_WIDTH + MODEL_PICKER_NAME_CHROME_WIDTH; -const MODEL_PICKER_COMPACT_NAME_WIDTH = 24; +const MODEL_PICKER_AUTO_NAME_WIDTH = 50; +const MODEL_PICKER_COMPACT_NAME_WIDTH = 22; type ChatModelChangeClassification = { owner: 'lramos15'; comment: 'Reporting when the model picker is switched'; @@ -643,7 +644,12 @@ export class ModelPickerWidget extends Disposable { ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); const showModelLabel = !compact || !modelIcon || noModelsAvailable; - const nameMinimumWidth = compact && !showModelLabel ? MODEL_PICKER_COMPACT_NAME_WIDTH : MODEL_PICKER_MINIMUM_NAME_WIDTH; + const showingAuto = !unavailable && !activating && !genericNoModels && (!this._selectedModel || isAutoModel(this._selectedModel)); + const nameMinimumWidth = compact && !showModelLabel + ? MODEL_PICKER_COMPACT_NAME_WIDTH + : showingAuto + ? MODEL_PICKER_AUTO_NAME_WIDTH + : MODEL_PICKER_MINIMUM_NAME_WIDTH; this._nameButton.style.minWidth = `${nameMinimumWidth}px`; if (showModelLabel) { nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); 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 79fe19d20944a4..5546bef299cd23 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2115,8 +2115,7 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-toolbar-hoverBackground); } -/* When only the icon remains, keep the expanded control's leading inset so - * the glyph does not move as the label disappears. */ +/* Compact picker controls match the 22px toolbar control tier. */ .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only, .interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only, @@ -2131,23 +2130,13 @@ have to be updated for changes to the rules above, or to support more deeply nes justify-content: center; .codicon { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); justify-content: center; } } -.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.icon-only:not(.model-picker-split), -.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.icon-only { - padding-left: var(--vscode-spacing-size60); - justify-content: flex-start; -} - -.interactive-session .chat-secondary-input-toolbar .chat-input-picker-item .action-label.icon-only, -.interactive-session .chat-secondary-input-toolbar .chat-sessionPicker-item .action-label.icon-only { - padding-left: var(--vscode-spacing-size80); - justify-content: flex-start; -} - - /* Icon-only chips in the primary input toolbar (add context, configure tools, MCP servers) all sit on the compact tier, so the row reads as one dense strip of chrome instead of a 16px glyph towering over the 12px send / mic buttons diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 094f53011de39f..9d85c9ad77bbd8 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -22,6 +22,7 @@ import { ExtensionIdentifier } from '../../../../platform/extensions/common/exte import { getCanonicalPluginCommandId } from './plugins/agentPluginService.js'; import { getChatSessionType, LocalChatSessionUri } from './model/chatUri.js'; import { type CustomizationDisabledReason } from '../../../../platform/agentHost/common/customizationEnablement.js'; +import { isAgentBuiltinCustomizationUri } from '../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { CustomizationEnablementKind } from '../../../../platform/agentHost/common/state/protocol/state.js'; @@ -643,6 +644,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer const commands = await this.getSlashCommands(sessionResource, token); const command = commands.find(cmd => cmd.name === name); if (command) { + if (isAgentBuiltinCustomizationUri(command.uri)) { + return command; + } const parsedPromptFile = await this.promptsService.parseNew(command.uri, token); return { ...command, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 4463f828e74eac..033817b9d6bfc6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -382,7 +382,7 @@ export interface IChatPromptSlashCommand { } export interface IResolvedChatPromptSlashCommand extends IChatPromptSlashCommand { - readonly parsedPromptFile: ParsedPromptFile; + readonly parsedPromptFile?: ParsedPromptFile; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 2399b9c330eba7..ec96df2375dd35 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -19,6 +19,7 @@ import { type ITextModel } from '../../../../../../editor/common/model.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { isAgentBuiltinCustomizationUri } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -680,6 +681,9 @@ export class PromptsService extends Disposable implements IPromptsService { const commands = await this.getPromptSlashCommands(token); const command = commands.find(cmd => cmd.name === name && matchesSessionType(cmd.sessionTypes, sessionType)); if (command) { + if (isAgentBuiltinCustomizationUri(command.uri)) { + return command; + } return { ...command, parsedPromptFile: await this.parseNew(command.uri, token), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts index 260e3a992d4f4a..0bd35397d5e0e3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentCustomizationItemProvider.test.ts @@ -145,6 +145,59 @@ suite('AgentCustomizationItemProvider', () => { }]); }); + test('classifies a directory child by its real file URI when its container is synthetic', async () => { + const skillUri = 'file:///workspace/.agents/skills/launch/SKILL.md'; + const customizations: Customization[] = [{ + type: CustomizationType.Directory, + id: 'codex-repository-skills', + uri: 'codex-skills:/repo', + name: 'Repository', + enabled: true, + contents: CustomizationType.Skill, + writable: false, + children: [{ + type: CustomizationType.Skill, + id: skillUri, + uri: skillUri, + name: 'launch', + description: 'Launch Code OSS.', + }], + }]; + + class TestCustomizationService extends NullAgentHostCustomizationService { + override getWorkingDirectories(): readonly string[] { + return ['file:///workspace']; + } + override getCustomizations(): readonly Customization[] { + return customizations; + } + } + + const provider = disposables.add(new AgentCustomizationItemProvider( + 'local', + undefined, + undefined, + upcastPartial({}), + new NullLogService(), + new TestCustomizationService(), + makePromptsService(), + )); + + const items = await provider.provideChatSessionCustomizations(URI.parse('agent-host-codex:///session'), CancellationToken.None); + + assert.deepStrictEqual(items.map(item => ({ + type: item.type, + name: item.name, + uri: item.uri.toString(), + source: item.source, + })), [{ + type: PromptsType.skill, + name: 'launch', + uri: skillUri, + source: AICustomizationSources.local, + }]); + }); + test('overrides a stale enabled provider row when its built-in skill is user-disabled', async () => { const bundleUri = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/bundle' }); const bundledSkillUri = URI.joinPath(bundleUri, 'skills', 'create-pr', 'SKILL.md'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 1d559b58231c7f..b1c0cb5547f727 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -9,6 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { hasKey } from '../../../../../../base/common/types.js'; @@ -115,6 +116,7 @@ import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, createCh import { messageAttachmentsToVariableData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceAttachmentMetadataKey, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceModelRepresentation } from '../../../browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { CellUri } from '../../../../notebook/common/notebookCommon.js'; type ILegacyTimedChatAction = | { type: 'chat/turnComplete'; turnId: string; endedAt: string } @@ -9531,6 +9533,61 @@ suite('AgentHostChatContribution', () => { ]); })); + test('active notebook cell implicit context includes its stored output for Codex sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, { provider: 'codex' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/codex-implicit-notebook-cell' }); + const notebookUri = URI.file('/workspace/notebook.ipynb'); + const cellUri = CellUri.generate(notebookUri, 7); + const outputUri = CellUri.generateCellPropertyUri(notebookUri, 7, Schemas.vscodeNotebookCellOutput); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'notebook.ipynb • Cell 1', isSelection: false, uri: cellUri, value: cellUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what is the cell output?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: cellUri.toString(), label: 'notebook.ipynb • Cell 1', displayKind: 'document' }, + { type: MessageAttachmentKind.Resource, uri: outputUri.toString(), label: 'notebook.ipynb • Cell 1 output.json', displayKind: 'document' }, + ]); + })); + + test('active notebook cell output is forwarded when its source is already attached explicitly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, { provider: 'codex' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/codex-implicit-notebook-cell-dedup' }); + const notebookUri = URI.file('/workspace/notebook.ipynb'); + const cellUri = CellUri.generate(notebookUri, 7); + const outputUri = CellUri.generateCellPropertyUri(notebookUri, 7, Schemas.vscodeNotebookCellOutput); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'notebook.ipynb • Cell 1', isSelection: false, uri: cellUri, value: cellUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what is the cell output?', + sessionResource, + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-cell', name: 'notebook.ipynb • Cell 1', value: cellUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: cellUri.toString(), label: 'notebook.ipynb • Cell 1', displayKind: 'document' }, + { type: MessageAttachmentKind.Resource, uri: outputUri.toString(), label: 'notebook.ipynb • Cell 1 output.json', displayKind: 'document' }, + ]); + })); + test('browser implicit context is not forwarded as an attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-browser' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index f770220f3c89ce..f490dd533715fd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -4,6 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as dom from '../../../../../../base/browser/dom.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -16,6 +20,51 @@ import { SessionType } from '../../../common/chatSessionsService.js'; import { getAgentHostPickerProperty, OpenAgentHostAutoApprovePickerAction, OpenAgentHostCodexApprovalsPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { isAutoApproveValuePolicyRestricted, isPermissionLevelVisible, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; import { ChatPermissionLevel } from '../../../common/constants.js'; +import '../../../browser/agentSessions/agentHost/media/agentHostChatInputPicker.css'; + +suite('AgentHostChatInputPicker - compact layout', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps the Copilot harness picker height stable and centers its compact icon', () => { + const session = dom.append(document.body, dom.$('.interactive-session')); + disposables.add(toDisposable(() => session.remove())); + session.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + const actionBar = dom.append(session, dom.$('.monaco-action-bar')); + const actionsContainer = dom.append(actionBar, dom.$('.actions-container')); + actionsContainer.style.display = 'flex'; + const item = dom.append(actionsContainer, dom.$('.action-item.agent-host-chat-input-picker-host')); + const slot = dom.append(item, dom.$('.agent-host-chat-input-picker-slot')); + const label = dom.append(slot, dom.$('a.action-label')); + const icon = dom.append(label, renderIcon(Codicon.rocketCompact)); + dom.append(label, dom.$('span.agent-host-chat-input-picker-label', undefined, 'Autopilot')); + + const expandedHeight = item.getBoundingClientRect().height; + item.classList.add('compact-picker'); + const itemBounds = item.getBoundingClientRect(); + const slotBounds = slot.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + assert.deepStrictEqual({ + expandedHeight, + item: { width: itemBounds.width, height: itemBounds.height }, + slot: { width: slotBounds.width, height: slotBounds.height }, + label: { width: labelBounds.width, height: labelBounds.height }, + icon: { + width: iconBounds.width, + height: iconBounds.height, + x: iconBounds.left - labelBounds.left, + y: iconBounds.top - labelBounds.top, + }, + }, { + expandedHeight: 22, + item: { width: 22, height: 22 }, + slot: { width: 22, height: 22 }, + label: { width: 22, height: 22 }, + icon: { width: 12, height: 12, x: 5, y: 5 }, + }); + }); +}); suite('AgentHostChatInputPicker - action mapping', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.contribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.contribution.test.ts new file mode 100644 index 00000000000000..14b74665c37c0b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagement.contribution.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import '../../../browser/aiCustomization/aiCustomizationManagement.contribution.js'; +import { + AICustomizationManagementItemMenuId, + AICustomizationManagementSyntheticItemMenuId, + getAICustomizationManagementItemMenuId, +} from '../../../browser/aiCustomization/aiCustomizationManagement.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +suite('AI customization management contribution', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const fileActionIds = new Set([ + 'aiCustomizationManagement.openFile', + 'aiCustomizationManagement.runPrompt', + 'aiCustomizationManagement.copyPath', + 'aiCustomizationManagement.delete', + 'aiCustomizationManagement.installChatCustomizationExtension', + ]); + + test('isolates synthetic items from extension-contributed item actions', () => { + const disposables = new DisposableStore(); + try { + disposables.add(MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { + id: 'test.extensionContributedAction', + title: 'Extension Action', + }, + })); + + const syntheticUri = toAgentHostUri( + URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skill/code-review' }), + 'remote' + ); + const selectedMenuId = getAICustomizationManagementItemMenuId(syntheticUri); + const syntheticActionIds = MenuRegistry.getMenuItems(selectedMenuId) + .filter(isIMenuItem) + .map(item => item.command.id); + const regularActionIds = MenuRegistry.getMenuItems(AICustomizationManagementItemMenuId) + .filter(isIMenuItem) + .map(item => item.command.id); + + assert.deepStrictEqual({ + usesSyntheticMenu: selectedMenuId === AICustomizationManagementSyntheticItemMenuId, + syntheticFileActions: syntheticActionIds.filter(id => fileActionIds.has(id)), + syntheticExtensionActions: syntheticActionIds.filter(id => id === 'test.extensionContributedAction'), + regularHasFileActions: [...fileActionIds].every(id => regularActionIds.includes(id)), + readableUsesExtensibleMenu: getAICustomizationManagementItemMenuId(URI.file('/workspace/SKILL.md')) === AICustomizationManagementItemMenuId, + }, { + usesSyntheticMenu: true, + syntheticFileActions: [], + syntheticExtensionActions: [], + regularHasFileActions: true, + readableUsesExtensibleMenu: true, + }); + } finally { + disposables.dispose(); + } + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index e094948f5924fe..b76432c5b56f5a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -12,6 +12,8 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import type { IManagedHover } from '../../../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; @@ -168,7 +170,6 @@ suite('aiCustomizationManagementEditor', () => { editor.notificationService = { error: () => { }, }; - editor.showEmbeddedEditor = async () => { }; editor.getActiveHarnessLabel = () => 'Copilot'; editor.welcomePage = undefined; editor.contributedSectionContainers = new Map(); @@ -209,6 +210,28 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); + test('ignores programmatic open requests for synthetic built-ins without source content', async () => { + const editor = createTestEditor(); + const builtInUri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skill/init' }); + + await editor.showEmbeddedEditor( + toAgentHostUri(builtInUri, 'remote'), + 'init', + PromptsType.skill, + AICustomizationSources.builtin, + false, + true + ); + + assert.deepStrictEqual({ + viewMode: editor.viewMode, + }, { + viewMode: 'list', + }); + + editor.editorPreviewDisposables.dispose(); + }); + test('uses view-raw copy for true read-only extension content', () => { const editor = createTestEditor(); editor.currentEditingPromptType = PromptsType.agent; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index 5a45016767a4f6..d2ad83916cca28 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -5,16 +5,17 @@ import assert from 'assert'; import sinon from 'sinon'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ChatSpeechToTextService, createDictationCleanupSystemPrompt, isDictationEntitled, selectFinalDictationTranscript, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; +import { ChatSpeechToTextService, ChatSpeechToTextState, createDictationCleanupSystemPrompt, isDictationEntitled, selectAuthoritativeDictationTranscript, selectFinalDictationTranscript, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; import { resolveDictationLanguage } from '../../browser/speechToText/dictationLanguage.js'; import { ChatEntitlement } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; -import { IVoiceClientService, IVoiceFatalDisconnect } from '../../common/voiceClient/voiceClientService.js'; +import { IVoiceCodeTranscriptionClient, IVoiceCodeTranscriptionError } from '../../browser/speechToText/voiceCodeTranscriptionClient.js'; type CleanupTestService = { _configurationService: { @@ -37,26 +38,35 @@ type ConfiguredTestService = { _configurationService: { getValue: () => boolean }; _getBackend: () => 'mai'; _isEntitledForBackend: () => boolean; - _voiceWsUrl: () => string; + _transcriptionWsUrl: () => string; _hasGitHubSession: boolean; _localTranscription: { isSupported: boolean }; readonly isConfigured: boolean; }; -type ConnectionTestService = { - _voiceClientService: Pick; - _maiSessionDisposables: DisposableStore; - _sessionErrorCode: string; +type MaiSessionTestService = { + _sessionGeneration: number; + _maiTurnId: string; _sessionCloseCode: number; - _awaitVoiceConnected: () => Promise; + _maiSessionDisposables: DisposableStore; + _transcriptionClient: Pick; + _logService: { warn(message: string): void }; + _getGitHubToken: () => Promise; + _setPreparingModel: (preparing: boolean) => void; + _failMaiSession: (message: string) => void; + _startMaiSession: (window: Window & typeof globalThis, generation: number) => Promise; }; type FinalizationTestService = { - _activeBackend: 'nemo'; + _activeBackend: 'nemo' | 'mai'; _localTranscription: { stop: () => Promise; cancel: () => Promise; }; + _maiFinalTranscript: DeferredPromise | undefined; + _maiTurnId: string; + _maiReceivedFinal: boolean; + _transcriptionClient: Pick; _finalizedText: string; _deltaText: string; _logService: Pick; @@ -64,6 +74,62 @@ type FinalizationTestService = { _finishBackend: () => Promise; }; +type MaiTeardownTestService = FinalizationTestService & { + _prepareStartMs: number; + _backendFinalizedText: string; + _localSessionDisposables: DisposableStore; + _maiSessionDisposables: DisposableStore; + _stopCapture: () => void; + _setPreparingModel: (preparing: boolean) => void; + _completeDownloadNotification: () => void; + _teardown: () => void; + _transcriptionClient: Pick; +}; + +type StopTestService = { + _sessionGeneration: number; + _activeBackend: 'mai'; + _maiReceivedFinal: boolean; + _finalizedText: string; + _deltaText: string; + _sessionErrorCode: string; + _finalizeMs: number; + _flushCapture: (() => Promise) | undefined; + _finishBackend: () => Promise; + _stopCapture: () => void; + _setState: (state: ChatSpeechToTextState) => void; + _accessibilitySignalService: { playSignal: (signal: unknown) => void }; + _configurationService: { getValue: () => boolean }; + _logSessionTelemetry: (outcome: string) => void; + _teardown: () => void; + _stopAndTranscribe: (generation: number) => Promise; +}; + +type MaiFailureTestService = { + _activeBackend: 'mai'; + _state: ChatSpeechToTextState; + _maiTurnId: string; + _sessionGeneration: number; + _startGeneration: number; + _sessionErrorCode: string; + _maiFinalTranscript: DeferredPromise | undefined; + _logSessionTelemetry: (outcome: string) => void; + _cancelBackend: () => void; + _teardown: () => void; + _setState: (state: ChatSpeechToTextState) => void; + _notificationService: { error: (message: string) => void }; + _failMaiSession: (message: string) => void; +}; + +type AudioPushTestService = { + _activeBackend: 'mai'; + _firstAudioMs: number; + _maiTurnId: string; + _transcriptionClient: Pick; + _onAudioPushError: (error: unknown) => void; + _pushAudio: (samples: Float32Array, window: Window & typeof globalThis) => void; +}; + suite('ChatSpeechToTextService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -101,7 +167,7 @@ suite('ChatSpeechToTextService', () => { service._configurationService = { getValue: () => true }; service._getBackend = () => 'mai'; service._isEntitledForBackend = () => true; - service._voiceWsUrl = () => 'wss://voice.example.com'; + service._transcriptionWsUrl = () => 'wss://voice.example.com'; service._localTranscription = { isSupported: true }; service._hasGitHubSession = false; @@ -112,35 +178,6 @@ suite('ChatSpeechToTextService', () => { assert.deepStrictEqual({ signedOut, signedIn }, { signedOut: false, signedIn: true }); }); - test('rejects cloud connection immediately on a fatal disconnect', async () => { - const fatalDisconnect = new Emitter(); - const sessionDisposables = new DisposableStore(); - const service = Object.create(ChatSpeechToTextService.prototype) as ConnectionTestService; - service._voiceClientService = { - isConnected: false, - onDidChangeConnectionState: Event.None, - onFatalDisconnect: fatalDisconnect.event, - }; - service._maiSessionDisposables = sessionDisposables; - service._sessionErrorCode = ''; - service._sessionCloseCode = 0; - - const connected = service._awaitVoiceConnected(); - fatalDisconnect.fire({ code: 4008, reason: 'rejected' }); - - await assert.rejects(connected, /code 4008/); - assert.deepStrictEqual({ - errorCode: service._sessionErrorCode, - closeCode: service._sessionCloseCode, - }, { - errorCode: 'connect.rejected.4008', - closeCode: 4008, - }); - - fatalDisconnect.dispose(); - sessionDisposables.dispose(); - }); - test('returns the streamed transcript when on-device finalization times out', async () => { const clock = sinon.useFakeTimers(); const warnings: string[] = []; @@ -171,6 +208,7 @@ suite('ChatSpeechToTextService', () => { teardownPending: true, warnings: ['[chat-stt] on-device final transcription timed out after 8000ms; using streamed transcript'], }); + stop.complete(''); await service._pendingLocalTeardown; } finally { @@ -178,6 +216,175 @@ suite('ChatSpeechToTextService', () => { } }); + test('waits for the dedicated MAI final until the backend deadline', async () => { + const clock = sinon.useFakeTimers(); + const warnings: string[] = []; + const sentTurns: string[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as FinalizationTestService; + service._activeBackend = 'mai'; + service._maiTurnId = 'turn-1'; + service._maiReceivedFinal = false; + service._finalizedText = 'streamed transcript'; + service._deltaText = ''; + service._logService = { warn: message => warnings.push(message) }; + service._transcriptionClient = { + sendPttEnd: turnId => sentTurns.push(turnId), + }; + + try { + let settled = false; + const resultPromise = service._finishBackend().then(result => { + settled = true; + return result; + }); + await clock.tickAsync(4000); + assert.strictEqual(settled, false); + await clock.tickAsync(31_000); + + assert.deepStrictEqual({ + result: await resultPromise, + sentTurns, + warnings, + }, { + result: 'streamed transcript', + sentTurns: ['turn-1'], + warnings: ['[chat-stt] cloud final transcription timed out after 35000ms; using streamed transcript'], + }); + } finally { + clock.restore(); + } + }); + + test('teardown releases a pending MAI final wait immediately', async () => { + const clock = sinon.useFakeTimers(); + const warnings: string[] = []; + let disconnects = 0; + const service = Object.create(ChatSpeechToTextService.prototype) as MaiTeardownTestService; + service._activeBackend = 'mai'; + service._maiTurnId = 'turn-1'; + service._maiReceivedFinal = false; + service._finalizedText = 'streamed transcript'; + service._deltaText = ''; + service._backendFinalizedText = ''; + service._prepareStartMs = 0; + service._localSessionDisposables = new DisposableStore(); + service._maiSessionDisposables = new DisposableStore(); + service._logService = { warn: message => warnings.push(message) }; + service._stopCapture = () => { }; + service._setPreparingModel = () => { }; + service._completeDownloadNotification = () => { }; + service._transcriptionClient = { + sendPttEnd: () => { }, + disconnect: () => { disconnects++; }, + }; + + let waiter: DeferredPromise | undefined; + try { + let settled = false; + const resultPromise = service._finishBackend().then(result => { + settled = true; + return result; + }); + waiter = service._maiFinalTranscript; + service._teardown(); + await clock.tickAsync(0); + + assert.deepStrictEqual({ + settled, + result: settled ? await resultPromise : undefined, + disconnects, + warnings, + pendingTimers: clock.countTimers(), + }, { + settled: true, + result: '', + disconnects: 1, + warnings: [], + pendingTimers: 0, + }); + } finally { + waiter?.complete(); + await clock.tickAsync(0); + service._localSessionDisposables.dispose(); + service._maiSessionDisposables.dispose(); + clock.restore(); + } + }); + + test('returns an explicit empty authoritative MAI final', async () => { + const states: ChatSpeechToTextState[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as StopTestService; + service._sessionGeneration = 0; + service._activeBackend = 'mai'; + service._maiReceivedFinal = true; + service._finalizedText = 'stale partial'; + service._deltaText = ''; + service._sessionErrorCode = ''; + service._flushCapture = undefined; + service._finishBackend = async () => ''; + service._stopCapture = () => { }; + service._setState = state => states.push(state); + service._accessibilitySignalService = { playSignal: () => { } }; + service._configurationService = { getValue: () => false }; + service._logSessionTelemetry = () => { }; + service._teardown = () => { }; + + assert.deepStrictEqual({ + result: await service._stopAndTranscribe(0), + states, + }, { + result: '', + states: [ChatSpeechToTextState.Transcribing, ChatSpeechToTextState.Idle], + }); + }); + + test('fails an active MAI startup when its connection closes before recording', () => { + const calls: string[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as MaiFailureTestService; + service._activeBackend = 'mai'; + service._state = ChatSpeechToTextState.Idle; + service._maiTurnId = 'turn-1'; + service._sessionGeneration = 2; + service._startGeneration = 4; + service._sessionErrorCode = ''; + service._maiFinalTranscript = undefined; + service._logSessionTelemetry = outcome => calls.push(`telemetry:${outcome}`); + service._cancelBackend = () => calls.push('cancel'); + service._teardown = () => calls.push('teardown'); + service._setState = state => calls.push(`state:${state}`); + service._notificationService = { error: message => calls.push(`error:${message}`) }; + + service._failMaiSession('disconnected'); + + assert.deepStrictEqual({ + sessionGeneration: service._sessionGeneration, + startGeneration: service._startGeneration, + errorCode: service._sessionErrorCode, + calls, + }, { + sessionGeneration: 3, + startGeneration: 5, + errorCode: 'disconnect', + calls: ['telemetry:error', 'cancel', 'teardown', `state:${ChatSpeechToTextState.Idle}`, 'error:disconnected'], + }); + }); + + test('routes synchronous MAI audio send failures through session failure handling', () => { + const error = new Error('socket closed'); + const failures: unknown[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as AudioPushTestService; + service._activeBackend = 'mai'; + service._firstAudioMs = 0; + service._maiTurnId = 'turn-1'; + service._transcriptionClient = { + sendPttAudioChunk: () => { throw error; }, + }; + service._onAudioPushError = caught => failures.push(caught); + + assert.doesNotThrow(() => service._pushAudio(new Float32Array([0.5]), mainWindow)); + assert.deepStrictEqual(failures, [error]); + }); + test('resolves the dictation language from Voice Mode configuration, display language, and browser locale', () => { assert.deepStrictEqual({ explicit: resolveDictationLanguage('fr-FR', 'de-DE'), @@ -251,6 +458,88 @@ suite('ChatSpeechToTextService', () => { }); }); + test('uses an explicit MAI final transcript even when it is shorter than the partial', () => { + const partial = 'write a focused test for MAI final result selection today.'; + const final = 'write a short test.'; + + assert.deepStrictEqual({ + partialLength: partial.length, + finalLength: final.length, + selection: selectAuthoritativeDictationTranscript(partial, final), + }, { + partialLength: 58, + finalLength: 19, + selection: final, + }); + }); + + test('uses an explicit empty MAI final to clear a stale partial', () => { + assert.strictEqual( + selectAuthoritativeDictationTranscript('stale partial', ''), + '', + ); + }); + + test('starts MAI dictation on its dedicated transcription connection', async () => { + const calls: string[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as MaiSessionTestService; + service._sessionGeneration = 3; + service._maiSessionDisposables = new DisposableStore(); + service._getGitHubToken = async () => 'github-token'; + service._setPreparingModel = preparing => calls.push(`preparing:${preparing}`); + service._transcriptionClient = { + connect: async () => { calls.push('connect'); }, + startSession: async () => { calls.push('startSession'); }, + sendPttStart: turnId => calls.push(`pttStart:${turnId}`), + onTranscription: Event.None, + onError: Event.None, + onDidClose: Event.None, + }; + + await service._startMaiSession(mainWindow, 3); + + assert.deepStrictEqual(calls, ['preparing:true', 'connect', 'startSession', 'preparing:false', `pttStart:${service._maiTurnId}`]); + service._maiSessionDisposables.dispose(); + }); + + test('keeps non-terminal errors active and records the socket close code', async () => { + const emitters = new DisposableStore(); + const errorEmitter = emitters.add(new Emitter()); + const closeEmitter = emitters.add(new Emitter()); + const failures: string[] = []; + const service = Object.create(ChatSpeechToTextService.prototype) as MaiSessionTestService; + service._sessionGeneration = 3; + service._sessionCloseCode = 0; + service._maiSessionDisposables = new DisposableStore(); + service._logService = { warn: () => { } }; + service._getGitHubToken = async () => 'github-token'; + service._setPreparingModel = () => { }; + service._failMaiSession = message => failures.push(message); + service._transcriptionClient = { + connect: async () => { }, + startSession: async () => { }, + sendPttStart: () => { }, + onTranscription: Event.None, + onError: errorEmitter.event, + onDidClose: closeEmitter.event, + }; + await service._startMaiSession(mainWindow, 3); + + errorEmitter.fire({ detail: 'capture limit reached', terminal: false }); + assert.deepStrictEqual(failures, []); + + closeEmitter.fire(4008); + assert.deepStrictEqual({ + closeCode: service._sessionCloseCode, + failureCount: failures.length, + }, { + closeCode: 4008, + failureCount: 1, + }); + service._maiSessionDisposables.dispose(); + emitters.dispose(); + }); + test('cleanup prompt guides list formatting with ordering cues', () => { const prompt = createDictationCleanupSystemPrompt(); diff --git a/src/vs/workbench/contrib/chat/test/browser/speechToText/voiceCodeTranscriptionClient.test.ts b/src/vs/workbench/contrib/chat/test/browser/speechToText/voiceCodeTranscriptionClient.test.ts new file mode 100644 index 00000000000000..0bd81d868d5790 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/speechToText/voiceCodeTranscriptionClient.test.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sinon from 'sinon'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import product from '../../../../../../platform/product/common/product.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { VoiceCodeTranscriptionClient } from '../../../browser/speechToText/voiceCodeTranscriptionClient.js'; + +class TestWebSocket { + static instance: TestWebSocket | undefined; + static ping: (() => void) | undefined; + + readyState: number = WebSocket.CONNECTING; + readonly sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + + constructor(readonly url: string) { + TestWebSocket.instance = this; + } + + open(): void { + this.readyState = WebSocket.OPEN; + this.onopen?.(); + } + + receive(message: object): void { + this.onmessage?.(new mainWindow.MessageEvent('message', { data: JSON.stringify(message) })); + } + + close(code?: number, reason?: string): void { + this.readyState = WebSocket.CLOSED; + this.onclose?.(new mainWindow.CloseEvent('close', { code, reason })); + } + + send(message: string): void { + this.sent.push(message); + } +} + +function createTestWindow(): Window & typeof globalThis { + return new Proxy(mainWindow, { + get(target, property, receiver) { + if (property === 'WebSocket') { + return TestWebSocket; + } + if (property === 'setInterval') { + return (callback: () => void) => { + TestWebSocket.ping = callback; + return 1; + }; + } + if (property === 'clearInterval') { + return () => { }; + } + return Reflect.get(target, property, receiver); + } + }); +} + +suite('VoiceCodeTranscriptionClient', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + setup(() => { + TestWebSocket.instance = undefined; + TestWebSocket.ping = undefined; + }); + + function createClient(): VoiceCodeTranscriptionClient { + const productService: IProductService = { + _serviceBrand: undefined, + ...product, + voiceWsUrl: 'wss://voice.test/voice-code/api/v1/realtime/voice', + }; + return store.add(new VoiceCodeTranscriptionClient( + new TestConfigurationService(), + productService, + new NullLogService(), + )); + } + + async function connect(client: VoiceCodeTranscriptionClient): Promise { + const connecting = client.connect(createTestWindow(), 'github-token'); + const socket = TestWebSocket.instance; + assert.ok(socket); + socket.open(); + await connecting; + return socket; + } + + test('sends the standalone scoped transcription protocol after session initialization', async () => { + const client = createClient(); + const socket = await connect(client); + const initialized = client.startSession(); + socket.receive({ type: 'session_init', session_id: 'session-1' }); + await initialized; + + client.sendPttStart('turn-1'); + client.sendPttAudioChunk('turn-1', 'AAE='); + client.sendPttEnd('turn-1'); + + assert.deepStrictEqual({ + url: socket.url, + messages: socket.sent.map(message => JSON.parse(message)), + }, { + url: 'wss://voice.test/voice-code/api/v1/realtime/transcription?token=github-token', + messages: [ + { type: 'start_session' }, + { type: 'ptt_start', turn_id: 'turn-1' }, + { type: 'ptt_audio_chunk', turn_id: 'turn-1', audio: 'AAE=' }, + { type: 'ptt_end', turn_id: 'turn-1' }, + ], + }); + }); + + test('emits only increasing revisions for the active turn and keeps the committed prefix', async () => { + const client = createClient(); + const socket = await connect(client); + const initialized = client.startSession(); + socket.receive({ type: 'session_init', session_id: 'session-1' }); + await initialized; + client.sendPttStart('turn-1'); + const transcriptions: object[] = []; + store.add(client.onTranscription(transcription => transcriptions.push(transcription))); + + socket.receive({ type: 'transcription', turn_id: 'turn-1', status: 'partial', text: 'write a', committed: 'write ', revision: 1 }); + socket.receive({ type: 'transcription', turn_id: 'turn-1', status: 'partial', text: 'stale', committed: '', revision: 1 }); + socket.receive({ type: 'transcription', turn_id: 'other-turn', status: 'partial', text: 'foreign', committed: '', revision: 2 }); + socket.receive({ type: 'transcription', turn_id: 'turn-1', status: 'final', text: 'write a test', committed: 'write a test', revision: 2 }); + + assert.deepStrictEqual(transcriptions, [ + { turnId: 'turn-1', status: 'partial', text: 'write a', committed: 'write ', revision: 1 }, + { turnId: 'turn-1', status: 'final', text: 'write a test', committed: 'write a test', revision: 2 }, + ]); + }); + + test('emits an explicit empty final transcript', async () => { + const client = createClient(); + const socket = await connect(client); + client.sendPttStart('turn-1'); + const transcriptions: object[] = []; + store.add(client.onTranscription(transcription => transcriptions.push(transcription))); + + socket.receive({ type: 'transcription', turn_id: 'turn-1', status: 'final', text: '', committed: '', revision: 1 }); + + assert.deepStrictEqual(transcriptions, [ + { turnId: 'turn-1', status: 'final', text: '', committed: '', revision: 1 }, + ]); + }); + + test('reports malformed frames and unexpected closure as transport errors', async () => { + const client = createClient(); + const socket = await connect(client); + const errors: object[] = []; + const closures: number[] = []; + const closeEvents: string[] = []; + store.add(client.onError(error => errors.push(error))); + store.add(client.onError(() => closeEvents.push('error'))); + store.add(client.onDidClose(code => { + closures.push(code); + closeEvents.push('close'); + })); + + socket.receive({ type: 'error', detail: 'capture limit reached', code: 'capture_limit', turn_id: 'turn-1', terminal: false }); + socket.receive({ type: 'error', detail: 'backend rejected audio', code: 'bad_audio', turn_id: 'turn-1', terminal: true }); + socket.receive({ type: 'transcription', turn_id: '', status: 'final', text: 'invalid', revision: 1 }); + socket.close(4008, 'rejected'); + + assert.deepStrictEqual(errors, [ + { detail: 'capture limit reached', code: 'capture_limit', turnId: 'turn-1', terminal: false }, + { detail: 'backend rejected audio', code: 'bad_audio', turnId: 'turn-1', terminal: true }, + { detail: 'Transcription connection closed (4008): rejected', terminal: true }, + ]); + assert.deepStrictEqual(closures, [4008]); + assert.deepStrictEqual(closeEvents.slice(-2), ['close', 'error']); + }); + + test('ignores errors from a socket replaced by reconnect', async () => { + const client = createClient(); + const oldSocket = await connect(client); + const oldError = oldSocket.onerror; + const errors: object[] = []; + store.add(client.onError(error => errors.push(error))); + + const reconnecting = client.connect(createTestWindow(), 'github-token'); + const newSocket = TestWebSocket.instance; + assert.ok(newSocket); + newSocket.open(); + await reconnecting; + oldError?.(); + + assert.strictEqual(client.isConnected, true); + assert.deepStrictEqual(errors, []); + }); + + test('pings an idle connection and cancels a pending connection without a close notification', async () => { + const clock = sinon.useFakeTimers(); + const client = createClient(); + const socket = await connect(client); + const closures: number[] = []; + store.add(client.onDidClose(code => closures.push(code))); + try { + assert.ok(TestWebSocket.ping); + TestWebSocket.ping(); + assert.deepStrictEqual(socket.sent.map(message => JSON.parse(message)), [{ type: 'ping' }]); + socket.receive({ type: 'pong' }); + + const connecting = client.connect(createTestWindow(), 'github-token'); + client.disconnect(); + await assert.rejects(connecting); + assert.deepStrictEqual(closures, []); + await clock.tickAsync(10_000); + } finally { + clock.restore(); + } + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceEndpoint.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceEndpoint.test.ts new file mode 100644 index 00000000000000..6c366338911a03 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceEndpoint.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import product from '../../../../../../platform/product/common/product.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { addWebSocketAuthToken, getTranscriptionWebSocketUrl, getVoiceWebSocketUrl } from '../../../browser/voiceClient/voiceEndpoint.js'; + +suite('Voice endpoint', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const productService: IProductService = { + _serviceBrand: undefined, + ...product, + voiceWsUrl: 'wss://voice.test/voice-code/api/v1/realtime/voice?product=stable', + }; + + test('derives the transcription sibling from the product Voice endpoint', () => { + const configurationService = new TestConfigurationService(); + + assert.deepStrictEqual({ + voice: getVoiceWebSocketUrl(configurationService, productService), + transcription: getTranscriptionWebSocketUrl(configurationService, productService), + }, { + voice: 'wss://voice.test/voice-code/api/v1/realtime/voice?product=stable', + transcription: 'wss://voice.test/voice-code/api/v1/realtime/transcription?product=stable', + }); + }); + + test('uses a loopback development endpoint and safely replaces its token', () => { + const configurationService = new TestConfigurationService({ + 'agents.voice.backendUrl': 'ws://localhost:8000/api/v1/realtime/voice?environment=dev&token=stale', + }); + + assert.deepStrictEqual({ + transcription: getTranscriptionWebSocketUrl(configurationService, productService), + authenticated: addWebSocketAuthToken('ws://localhost:8000/api/v1/realtime/transcription?environment=dev&token=stale', 'token +/=?'), + }, { + transcription: 'ws://localhost:8000/api/v1/realtime/transcription?environment=dev&token=stale', + authenticated: 'ws://localhost:8000/api/v1/realtime/transcription?environment=dev&token=token+%2B%2F%3D%3F', + }); + }); + + test('accepts an IPv6 loopback development endpoint', () => { + const configurationService = new TestConfigurationService({ + 'agents.voice.backendUrl': 'ws://[::1]:8000/api/v1/realtime/voice', + }); + + assert.strictEqual( + getTranscriptionWebSocketUrl(configurationService, productService), + 'ws://[::1]:8000/api/v1/realtime/transcription', + ); + }); + + test('keeps a remote Voice Mode override out of the transcription client', () => { + const configurationService = new TestConfigurationService({ + 'agents.voice.backendUrl': 'wss://untrusted.example/api/v1/realtime/voice', + }); + + assert.deepStrictEqual({ + voice: getVoiceWebSocketUrl(configurationService, productService), + transcription: getTranscriptionWebSocketUrl(configurationService, productService), + }, { + voice: 'wss://untrusted.example/api/v1/realtime/voice', + transcription: 'wss://voice.test/voice-code/api/v1/realtime/transcription?product=stable', + }); + }); + + test('ignores a malformed development endpoint override', () => { + const configurationService = new TestConfigurationService({ + 'agents.voice.backendUrl': 42, + }); + + assert.strictEqual( + getTranscriptionWebSocketUrl(configurationService, productService), + 'wss://voice.test/voice-code/api/v1/realtime/transcription?product=stable', + ); + }); + + test('rejects a product endpoint that is not the Voice sibling', () => { + const invalidProduct: IProductService = { + ...productService, + voiceWsUrl: 'wss://voice.test/api/v1/other', + }; + + assert.strictEqual(getTranscriptionWebSocketUrl(new TestConfigurationService(), invalidProduct), ''); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts index 2a191963c2c771..cedb51b34c6dfa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerResponsiveLayout.test.ts @@ -463,27 +463,52 @@ suite('ChatInputPickerResponsiveLayout', () => { }); }); - test('keeps the primary picker icon anchored when its label disappears', () => { + test('centers compact primary and secondary picker icons', () => { host.style.setProperty('--vscode-spacing-size60', '6px'); + host.style.setProperty('--vscode-spacing-size80', '8px'); host.classList.add('interactive-session'); - const toolbar = dom.append(host, dom.$('.chat-input-toolbar')); - const item = dom.append(toolbar, dom.$('.chat-input-picker-item')); - const actionLabel = dom.append(item, dom.$('a.action-label')); - const icon = dom.append(actionLabel, dom.$('span.codicon')); - icon.style.width = '16px'; - icon.style.height = '16px'; - const pickerLabel = dom.append(actionLabel, dom.$('span.chat-input-picker-label')); - pickerLabel.textContent = 'Picker'; - - const expandedOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; - item.classList.add('compact'); - actionLabel.classList.add('icon-only'); - pickerLabel.remove(); - const compactOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; - - assert.deepStrictEqual({ expandedOffset, compactOffset }, { - expandedOffset: 6, - compactOffset: 6, + host.style.setProperty('--vscode-codiconFontSize-compact', '12px'); + + const renderPicker = (toolbarClass: string, itemClass: string) => { + const toolbar = dom.append(host, dom.$(`.${toolbarClass}`)); + const item = dom.append(toolbar, dom.$(`.${itemClass}`)); + const actionLabel = dom.append(item, dom.$('a.action-label')); + const icon = dom.append(actionLabel, dom.$('span.codicon')); + const pickerLabel = dom.append(actionLabel, dom.$('span.chat-input-picker-label')); + pickerLabel.textContent = 'Picker'; + + const expandedOffset = icon.getBoundingClientRect().left - actionLabel.getBoundingClientRect().left; + actionLabel.classList.add('icon-only'); + pickerLabel.remove(); + const actionBounds = actionLabel.getBoundingClientRect(); + const iconBounds = icon.getBoundingClientRect(); + return { + expandedOffset, + action: { width: actionBounds.width, height: actionBounds.height }, + icon: { + width: iconBounds.width, + height: iconBounds.height, + x: iconBounds.left - actionBounds.left, + y: iconBounds.top - actionBounds.top, + }, + }; + }; + + assert.deepStrictEqual({ + primary: renderPicker('chat-input-toolbar', 'chat-input-picker-item'), + secondary: renderPicker('chat-secondary-input-toolbar', 'chat-sessionPicker-item'), + }, { + primary: { + expandedOffset: 6, + action: { width: 22, height: 22 }, + icon: { width: 12, height: 12, x: 5, y: 5 }, + }, + secondary: { + expandedOffset: 8, + action: { width: 22, height: 22 }, + icon: { width: 12, height: 12, x: 5, y: 5 }, + }, }); }); + }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index 59b59d2928c008..cd096cf2450c69 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, ICustomizationItem } from '../../common/customizationHarnessService.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; @@ -331,6 +334,25 @@ suite('CustomizationHarnessService', () => { }); suite('getSlashCommands', () => { + function createSlashCommandService(uri: URI, promptsService: IPromptsService): CustomizationHarnessServiceBase { + const testSessionType = 'test-session-type'; + const emitter = new Emitter(); + store.add(emitter); + const service = new CustomizationHarnessServiceBase([{ + id: testSessionType, + label: 'Test Extension', + icon: ThemeIcon.fromId('extensions'), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [ + { uri, type: PromptsType.skill, source: 'local', name: 'init', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + ], + }, + }], testSessionType, promptsService); + store.add(service); + return service; + } + test('uses the active harness provider for prompt and skill items', async () => { @@ -416,6 +438,44 @@ suite('CustomizationHarnessService', () => { ]); } }); + + test('resolves a wrapped synthetic built-in without reading prompt content', async () => { + let parseCalls = 0; + const promptsService = new class extends MockPromptsService { + override async parseNew(uri: URI, token: CancellationToken) { + parseCalls++; + return super.parseNew(uri, token); + } + }; + const builtInUri = URI.from({ scheme: AGENT_BUILTIN_CUSTOMIZATION_SCHEME, path: '/skill/init' }); + const service = createSlashCommandService(toAgentHostUri(builtInUri, 'remote'), promptsService); + + const command = await service.resolvePromptSlashCommand('init', URI.parse('test-session-type://session'), CancellationToken.None); + + assert.deepStrictEqual({ + name: command?.name, + parsedPromptFile: command?.parsedPromptFile, + parseCalls, + }, { + name: 'init', + parsedPromptFile: undefined, + parseCalls: 0, + }); + }); + + test('propagates cancellation while resolving file-backed command content', async () => { + const promptsService = new class extends MockPromptsService { + override async parseNew(): Promise { + throw new CancellationError(); + } + }; + const service = createSlashCommandService(URI.file('/workspace/.test/skills/init/SKILL.md'), promptsService); + + await assert.rejects( + service.resolvePromptSlashCommand('init', URI.parse('test-session-type://session'), CancellationToken.None), + error => error instanceof CancellationError + ); + }); }); suite('getCustomAgents', () => { diff --git a/src/vs/workbench/contrib/codeEditor/browser/diffEditorAccessibilityHelp.ts b/src/vs/workbench/contrib/codeEditor/browser/diffEditorAccessibilityHelp.ts index f2262f6a4980e5..52fc6b81ec71d1 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/diffEditorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/diffEditorAccessibilityHelp.ts @@ -37,6 +37,7 @@ export class DiffEditorAccessibilityHelp implements IAccessibleViewImplementatio } const switchSides = localize('msg3', "Run the command Diff Editor: Switch Side{0} to toggle between the original and modified editors.", ''); + const diffView = localize('msg6', "Use Diff View in the editor title area's More Actions menu to select inline, side-by-side, or automatic layout."); const diffEditorActiveAnnouncement = localize('msg5', "The setting, accessibility.verbosity.diffEditorActive, controls if a diff editor announcement is made when it becomes the active editor."); const keys = ['accessibility.signals.diffLineDeleted', 'accessibility.signals.diffLineInserted', 'accessibility.signals.diffLineModified']; @@ -44,6 +45,7 @@ export class DiffEditorAccessibilityHelp implements IAccessibleViewImplementatio localize('msg1', "You are in a diff editor."), localize('msg2', "View the next{0} or previous{1} diff in diff review mode, which is optimized for screen readers.", '', ''), switchSides, + diffView, diffEditorActiveAnnouncement, localize('msg4', "To control which accessibility signals should be played, the following settings can be configured: {0}.", keys.join(', ')), ]; diff --git a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts index f54ddfe2109a73..3672c4bb33c659 100644 --- a/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts +++ b/src/vs/workbench/contrib/externalUriOpener/common/configuration.ts @@ -7,9 +7,10 @@ import { IConfigurationNode, IConfigurationRegistry, Extensions } from '../../.. import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import * as nls from '../../../../nls.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { defaultExternalUriOpenerId } from '../../../../platform/opener/common/opener.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -export const defaultExternalUriOpenerId = 'default'; +export { defaultExternalUriOpenerId }; export const externalUriOpenersSettingId = 'workbench.externalUriOpeners'; diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 190b96849a8e9e..728f40291c757e 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -46,6 +46,7 @@ --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-action-hover-background); --modern-ui-editor-tab-action-active-hover-background: var(--vscode-modernEditorTab-activeHoverActionBackground); --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--modern-ui-editor-tab-action-active-hover-background); + --modern-ui-editor-tabs-border: transparent; } .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { @@ -188,11 +189,28 @@ /* Center tabs vertically and strip the bottom border so the pills float. */ .modern-ui-tabs .part.editor .tabs-and-actions-container { - --tabs-border-bottom-color: transparent !important; + --tabs-border-bottom-color: var(--modern-ui-editor-tabs-border) !important; align-items: center; padding: var(--vscode-spacing-size20) 0 0 var(--vscode-spacing-size20); } +.modern-ui-tabs .part.editor .title:not(.two-tab-bars) > .tabs-and-actions-container::after, +.modern-ui-tabs .part.editor .title.two-tab-bars > .tabs-and-actions-container:not(:first-child)::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + z-index: 9; + pointer-events: none; + width: 100%; + height: var(--vscode-strokeThickness); + background-color: var(--tabs-border-bottom-color); +} + +.modern-ui-tabs .part.editor .title.two-tab-bars > .tabs-and-actions-container:first-child.tabs-border-bottom::after { + display: none; +} + /* In two-tab-bars mode the border-block-width rules below own every vertical gutter, so the * container must not add its own top padding to either the pinned or the unpinned row. */ .modern-ui-tabs .part.editor .title.two-tab-bars > .tabs-and-actions-container { diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts index 40ced0ea4db9fd..a82ed9620aba8e 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditor.ts @@ -28,9 +28,10 @@ import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/com import { IEditorService } from '../../../services/editor/common/editorService.js'; import { URI } from '../../../../base/common/uri.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; -import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorOptions, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorLayoutDebugState, IMultiDiffEditorViewState } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IDiffEditor } from '../../../../editor/common/editorCommon.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { Range } from '../../../../editor/common/core/range.js'; import { MultiDiffEditorItem } from './multiDiffSourceResolverService.js'; import { IEditorProgressService } from '../../../../platform/progress/common/progress.js'; diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts index f3a8c619746bce..0d5f4528f05b13 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/scmMultiDiffSourceResolver.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { observableFromEvent, ValueWithChangeEventFromObservable, waitForState } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/common/multiDiffEditor.js'; import { localize2 } from '../../../../nls.js'; import { Action2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts index b4cdbd75e051ca..87ee014e9f7c60 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookMultiDiffEditor.ts @@ -23,12 +23,12 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; import { CellUri, INotebookDiffEditorModel, NOTEBOOK_MULTI_DIFF_EDITOR_ID } from '../../common/notebookCommon.js'; import { FontMeasurements } from '../../../../../editor/browser/config/fontMeasurements.js'; +import { IMultiDiffEditorOptions } from '../../../../../editor/common/multiDiffEditor.js'; import { NotebookOptions } from '../notebookOptions.js'; import { INotebookService } from '../../common/notebookService.js'; import { NotebookMultiDiffEditorInput, NotebookMultiDiffEditorWidgetInput } from './notebookMultiDiffEditorInput.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { ResourceLabel } from '../../../../browser/labels.js'; -import type { IMultiDiffEditorOptions } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { localize } from '../../../../../nls.js'; import { Schemas } from '../../../../../base/common/network.js'; diff --git a/src/vs/workbench/contrib/searchEditor/browser/media/searchEditor.css b/src/vs/workbench/contrib/searchEditor/browser/media/searchEditor.css index 69a2d0c0dce2ae..968719314c75fa 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/media/searchEditor.css +++ b/src/vs/workbench/contrib/searchEditor/browser/media/searchEditor.css @@ -13,6 +13,7 @@ } .search-editor .query-container { + --search-editor-query-layout-offset: 28px; margin: 0px 12px 12px 19px; padding-top: 6px; } diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts index 1d4336a45d7c6a..db3bf69625c5a4 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts @@ -68,6 +68,7 @@ import { ISearchResult } from '../../search/browser/searchTreeModel/searchTreeCo const RESULT_LINE_REGEX = /^(\s+)(\d+)(: | )(\s*)(.*)$/; const FILE_LINE_REGEX = /^(\S.*):$/; +const DEFAULT_QUERY_EDITOR_LAYOUT_OFFSET = 28; type SearchEditorViewState = ICodeEditorViewState & { focused: 'input' | 'editor' }; @@ -677,10 +678,12 @@ export class SearchEditor extends AbstractTextCodeEditor private reLayout() { if (this.dimension) { - this.queryEditorWidget.setWidth(this.dimension.width - 28 /* container margin */); + const configuredOffset = Number.parseFloat(DOM.getWindow(this.queryEditorContainer).getComputedStyle(this.queryEditorContainer).getPropertyValue('--search-editor-query-layout-offset')); + const queryEditorWidth = this.dimension.width - (Number.isFinite(configuredOffset) ? configuredOffset : DEFAULT_QUERY_EDITOR_LAYOUT_OFFSET); + this.queryEditorWidget.setWidth(queryEditorWidth); this.searchResultEditor.layout({ height: this.dimension.height - DOM.getTotalHeight(this.queryEditorContainer), width: this.dimension.width }); - this.inputPatternExcludes.setWidth(this.dimension.width - 28 /* container margin */); - this.inputPatternIncludes.setWidth(this.dimension.width - 28 /* container margin */); + this.inputPatternExcludes.setWidth(queryEditorWidth); + this.inputPatternIncludes.setWidth(queryEditorWidth); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts index d4d6ba9ee53855..c098f31da78077 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts @@ -234,7 +234,12 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi this._profileProviders.set(extensionIdentifier, extMap); } extMap.set(id, profileProvider); - return toDisposable(() => this._profileProviders.delete(id)); + return toDisposable(() => { + extMap.delete(id); + if (extMap.size === 0) { + this._profileProviders.delete(extensionIdentifier); + } + }); } async registerContributedProfile(args: IRegisterContributedProfileArgs): Promise { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalProfileService.integrationTest.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalProfileService.integrationTest.ts index 18c8f428e749ad..677125f1947274 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalProfileService.integrationTest.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalProfileService.integrationTest.ts @@ -327,6 +327,19 @@ suite('TerminalProfileService', () => { deepStrictEqual(terminalProfileService.availableProfiles, [powershellProfile]); deepStrictEqual(terminalProfileService.contributedProfiles, [jsdebugProfile]); }); + + test('should unregister terminal profile providers', () => { + const firstProvider = { createContributedTerminalProfile: async () => undefined }; + const secondProvider = { createContributedTerminalProfile: async () => undefined }; + const registration = terminalProfileService.registerTerminalProfileProvider('first.extension', 'profile', firstProvider); + store.add(terminalProfileService.registerTerminalProfileProvider('second.extension', 'profile', secondProvider)); + + registration.dispose(); + + deepStrictEqual(terminalProfileService.getContributedProfileProvider('first.extension', 'profile'), undefined); + deepStrictEqual(terminalProfileService.getContributedProfileProvider('second.extension', 'profile'), secondProvider); + }); + suite('Profiles Quickpick', () => { let quickInputService: MockQuickInputService; let mockTerminalProfileService: MockTerminalProfileService; diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index a0efae00fc1ce5..a448fc1049dfb5 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -33,6 +33,11 @@ export interface IBrowserWorkbenchEnvironmentService extends IWorkbenchEnvironme */ readonly options?: IWorkbenchConstructionOptions; + /** + * Title of the agent session that launched this workbench. + */ + readonly sessionTitle?: string; + /** * Gets whether a resolver extension is expected for the environment. */ @@ -271,6 +276,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get isSessionsWindow(): boolean { return this.payload?.get('isSessionsWindow') === 'true'; } + @memoize + get sessionTitle(): string | undefined { return this.payload?.get('sessionTitle'); } + @memoize get profile(): string | undefined { return this.payload?.get('profile'); } diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 1abd21a9d7ef76..57b97aed040600 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -154,6 +154,9 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } + @memoize + get sessionTitle(): string | undefined { return this.configuration['session-title']; } + constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts index 21179107b38768..0db026144059af 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts @@ -94,7 +94,21 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { ...defaultAgentMergeConfiguration, fixCI: false, mergePullRequest: 'always', - })!, + }, 'session')!, + AgentSystemNotificationKind.AgentMergeConfigurationChanged, + ), + }), + + /** The same change made to the defaults, which every session follows. */ + DefaultsChanged: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderNotice( + ctx, + agentMergeConfigurationChangedNotice(defaultAgentMergeConfiguration, { + ...defaultAgentMergeConfiguration, + fixCI: false, + mergePullRequest: 'always', + }, 'global')!, AgentSystemNotificationKind.AgentMergeConfigurationChanged, ), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index f5dd8033f5ed38..2c391a73820e13 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -42,6 +42,7 @@ import { IBrowserViewWorkbenchService } from '../../../../contrib/browserView/co import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { RootState, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; @@ -66,6 +67,7 @@ import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../con import { IDictationOnboardingService } from '../../../../contrib/chat/browser/speechToText/dictationOnboarding.js'; import { IChatInputNoticeHubService } from '../../../../contrib/chat/browser/widget/input/chatInputNoticeHub.js'; import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from '../../../../contrib/chat/browser/chatSubmitRequestHandlerService.js'; +import { IChatStatusItemService } from '../../../../contrib/chat/browser/chatStatus/chatStatusItemService.js'; import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; import { IChatWidgetHistoryService } from '../../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IChatModeService } from '../../../../contrib/chat/common/chatModes.js'; @@ -128,6 +130,8 @@ export interface IChatFixtureServicesOptions { readonly todos?: readonly IChatTodo[]; /** Active notification returned from IChatInputNotificationService. */ readonly notification?: IChatInputNotification; + /** Resolved Agent Host session configuration used by real chat input picker fixtures. */ + readonly agentHostSessionConfig?: ResolveSessionConfigResult; } /** @@ -163,7 +167,13 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I // `getContainer` stands in for the workbench container that widgets use to host // overflow nodes (suggest widget, post-paste selector); the fixture document body // is the closest equivalent. - reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } override getContainer(targetWindow: Window): HTMLElement { return targetWindow.document.body; } }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { + override readonly mainContainer = document.body; + override onDidChangePartVisibility = Event.None; + override onDidChangeWindowMaximized = Event.None; + override isVisible() { return true; } + override getContainer(targetWindow: Window): HTMLElement { return targetWindow.document.body; } + }()); reg.defineInstance(IHostService, new class extends mock() { override readonly hasFocus = true; override readonly onDidChangeFocus = Event.None; @@ -327,6 +337,12 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override announceRendered() { } }()); reg.defineInstance(IChatSubmitRequestHandlerService, new ChatSubmitRequestHandlerService()); + reg.defineInstance(IChatStatusItemService, new class extends mock() { + override readonly onDidChange = Event.None; + override setOrUpdateEntry() { } + override deleteEntry() { } + override getEntries() { return []; } + }()); reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); override getSession() { return undefined; } @@ -359,10 +375,14 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } + override async resolveSessionConfig(): Promise { + return options.agentHostSessionConfig ?? { schema: { type: 'object', properties: {} }, values: {} }; + } }()); reg.defineInstance(IAgentHostUntitledProvisionalSessionService, new class extends mock() { override readonly onDidChange = Event.None; override get() { return undefined; } + override getOrCreate() { return Promise.resolve(undefined); } }()); reg.defineInstance(IAgentHostSessionWorkingDirectoryResolver, new class extends mock() { override resolve() { return undefined; } @@ -370,6 +390,8 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IAgentHostNewSessionFolderService, new class extends mock() { override readonly onDidChangeFolder = Event.None; override getFolder() { return undefined; } + override getDefaultFolder() { return undefined; } + override resolveNewSessionPrimary() { return undefined; } }()); reg.defineInstance(IAgentHostCustomizationService, new class extends mock() { override readonly onDidChangeCustomizations = Event.None; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index c8ea76fd58dc7f..52ea24b21ad5fd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -7,6 +7,8 @@ import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestDisablement } from '../../../../contrib/chat/common/model/chatModel.js'; @@ -89,11 +91,46 @@ const sampleNotification: IChatInputNotification = { autoDismissOnMessage: false, }; +const copilotHarnessSessionConfig: ResolveSessionConfigResult = { + schema: { + type: 'object', + properties: { + [SessionConfigKey.Mode]: { + type: 'string', + title: 'Mode', + enum: ['interactive', 'autopilot'], + enumLabels: ['Agent', 'Autopilot'], + default: 'interactive', + }, + [SessionConfigKey.AutoApprove]: { + type: 'string', + title: 'Permissions', + enum: ['default', 'autoApprove', 'autopilot'], + enumLabels: ['Default permissions', 'Allow all', 'Autopilot'], + default: 'default', + }, + }, + }, + values: { + [SessionConfigKey.Mode]: 'interactive', + [SessionConfigKey.AutoApprove]: 'default', + }, +}; + export default defineThemedFixtureGroup({ path: 'chat/input/' }, { Default: defineComponentFixture({ render: context => renderChatInput(context) }), WithSandboxing: defineComponentFixture({ render: context => renderChatInput(context, { sandboxingEnabled: true }) }), WithProviderIcon: defineComponentFixture({ render: context => renderChatInput(context, { models: sampleModels }) }), - CompactWithProviderIcon: defineComponentFixture({ render: context => renderChatInput(context, { models: sampleModels, width: 260 }) }), + CompactWithProviderIcon: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The editor chat input shows compact picker controls as 12-pixel codicons centered with equal padding inside matching 22-pixel square controls, aligned with the expanded toolbar height.'], + render: context => renderChatInput(context, { models: sampleModels, width: 180 }) + }), + CopilotHarnessCompactPickers: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['The editor chat input renders the real Copilot Agent Host mode and permissions pickers in compact state. Each compact icon is centered with equal padding inside a 22-pixel square control.'], + render: context => renderChatInput(context, { agentHostSessionConfig: copilotHarnessSessionConfig, width: 500, resizeWidths: [180] }), + }), WithArtifacts: defineComponentFixture({ render: context => renderChatInput(context, { artifacts: sampleArtifacts }) }), // The notice/input seam, the subject of #330483. Driven through the real // notification service so the squared corner comes from the stack. diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts index 40981ce1c85e9b..573c5dab334e42 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts @@ -10,11 +10,16 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IChatWidget } from '../../../../contrib/chat/browser/chat.js'; +import { OpenAgentHostAutoApprovePickerAction, OpenAgentHostModePickerAction } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { getNewChatSessionResource } from '../../../../contrib/chat/common/model/chatUri.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; +import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { IChatViewModel } from '../../../../contrib/chat/common/model/chatViewModel.js'; import { IArtifactSourceGroup } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatInputNotification } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IChatEditingSession } from '../../../../contrib/chat/common/editing/chatEditingService.js'; @@ -67,6 +72,17 @@ const voiceControlRenderings: Record voiceDisconnect: { icon: Codicon.debugDisconnectCompact, containerClasses: ['voice-active'] }, }; +function createFixtureChatViewModel(sessionResource: URI): IChatViewModel { + const model = new class extends mock() { + override readonly sessionResource = sessionResource; + override readonly lastRequestObs = constObservable(undefined); + }(); + return new class extends mock() { + override readonly sessionResource = sessionResource; + override readonly model = model; + }(); +} + export interface ChatInputFixtureOptions { readonly artifacts?: readonly { label: string; uri: string; type: 'devServer' | 'screenshot' | 'plan' | undefined }[]; readonly editingSession?: IChatEditingSession; @@ -90,6 +106,8 @@ export interface ChatInputFixtureOptions { readonly resizeWidths?: readonly number[]; /** Supplies models so the picker renders provider icons. */ readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; + /** Renders the production Copilot Agent Host mode and permissions pickers. */ + readonly agentHostSessionConfig?: ResolveSessionConfigResult; /** Renders a standalone dictation / Voice Mode control in the given state. */ readonly voiceControl?: VoiceControlState; /** @@ -104,9 +122,10 @@ export interface ChatInputFixtureOptions { export async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise { const { container, disposableStore } = context; - const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, resizeWidths = [], models = [], voiceControl, notification, pet = false } = fixtureOptions; + const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, resizeWidths = [], models = [], agentHostSessionConfig, voiceControl, notification, pet = false } = fixtureOptions; const artifactGroups: IArtifactSourceGroup[] = artifacts.length > 0 ? [{ source: { kind: 'agent' as const }, artifacts }] : []; const artifactsObs = observableValue('artifactGroups', artifactGroups); + const sessionResource = agentHostSessionConfig ? getNewChatSessionResource(SessionType.AgentHostCopilot) : undefined; // Sprite sheets are resolved against the file root. if (pet) { @@ -117,7 +136,7 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { - registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos, notification }); + registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos, notification, agentHostSessionConfig }); if (chatPetService) { reg.defineInstance(IChatPetService, chatPetService); } @@ -187,7 +206,12 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO } menuService.addItem(MenuId.ChatExecute, { command: { id: 'workbench.action.chat.submit', title: 'Send', icon: Codicon.arrowUpCompact }, group: 'navigation', order: 4 }); menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openSessionTargetPicker', title: 'Local' }, group: 'navigation', order: 0 }); - menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Permissions' }, group: 'navigation', order: 10 }); + if (agentHostSessionConfig) { + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: OpenAgentHostModePickerAction.ID, title: 'Agent Mode' }, group: 'navigation', order: 0.7 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: OpenAgentHostAutoApprovePickerAction.ID, title: 'Auto-Approve' }, group: 'navigation', order: 0.8 }); + } else { + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Permissions' }, group: 'navigation', order: 10 }); + } const options: IChatInputPartOptions = { renderFollowups: false, @@ -199,7 +223,11 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO isSessionsWindow, // The sandbox toggle is specific to the local harness, so present the // input as the local session type when exercising the sandboxed state. - sessionTypePickerDelegate: sandboxingEnabled ? { getActiveSessionProvider: () => SessionType.Local } : undefined, + sessionTypePickerDelegate: agentHostSessionConfig + ? { getActiveSessionProvider: () => SessionType.AgentHostCopilot } + : sandboxingEnabled + ? { getActiveSessionProvider: () => SessionType.Local } + : undefined, }; const styles: IChatInputStyles = { overlayBackground: 'var(--vscode-editor-background)', @@ -210,7 +238,7 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO const inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, options, styles, false)); const mockWidget = new class extends mock() { override readonly onDidChangeViewModel = new Emitter().event; - override readonly viewModel = undefined; + override readonly viewModel = sessionResource ? createFixtureChatViewModel(sessionResource) : undefined; override readonly contribs = []; override readonly location = ChatAgentLocation.Chat; override readonly viewContext = {}; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts index 9ed55026136aab..78476ba592e107 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts @@ -539,6 +539,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/changes/' }, { Empty: defineComponentFixture({ labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['A centered empty state shows the semibold title "Changes" above the secondary text "No changed files", with compact spacing and no icon.'], render: ctx => renderChangesView(ctx, { viewMode: ChangesViewMode.List, changes: [], diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index 0bec73d13d7f03..449df41cf9a3b7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { DEFAULT_EDITOR_PART_OPTIONS } from '../../../../browser/parts/editor/editor.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; // eslint-disable-next-line local/code-import-patterns import { ChatInteractivity, ChatOriginKind, IChat, ISessionCapabilities, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns @@ -93,6 +96,10 @@ function renderBar(ctx: ComponentFixtureContext, chats: readonly IChat[], active reg.defineInstance(ISessionsProvidersService, new class extends mock() { override getProvider() { return undefined; } }()); + reg.defineInstance(IEditorGroupsService, new class extends mock() { + override readonly onDidChangeEditorPartOptions = Event.None; + override readonly partOptions = DEFAULT_EDITOR_PART_OPTIONS; + }()); }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/emptyStates.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/emptyStates.fixture.ts new file mode 100644 index 00000000000000..007c3c71b80d04 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/emptyStates.fixture.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mainWindow } from '../../../../../base/browser/window.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { createBrowserWelcome } from '../../../../contrib/browserView/browser/browserWelcome.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import { EmptyFileEditor } from '../../../../../sessions/contrib/editor/browser/emptyFileEditor.js'; +// eslint-disable-next-line local/code-import-patterns +import { renderSessionsEmptyState } from '../../../../../sessions/browser/parts/sessionsEmptyState.js'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/browser/parts/media/editorPart.css'; + +const FIXTURE_WIDTH = 600; +const FIXTURE_HEIGHT = 360; + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +function prepareContainer(container: HTMLElement): HTMLElement { + container.style.width = `${FIXTURE_WIDTH}px`; + container.style.height = `${FIXTURE_HEIGHT}px`; + container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); + + const editorPart = dom.append(container, dom.$('.part.editor')); + editorPart.style.width = '100%'; + editorPart.style.height = '100%'; + return editorPart; +} + +function renderFilesEmptyState({ container, disposableStore, theme }: ComponentFixtureContext): void { + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + const editor = disposableStore.add(instantiationService.createInstance(EmptyFileEditor, createMockEditorGroup())); + editor.create(prepareContainer(container)); + editor.layout(new dom.Dimension(FIXTURE_WIDTH, FIXTURE_HEIGHT)); +} + +function renderBrowserEmptyState({ container }: ComponentFixtureContext): void { + const browserRoot = dom.append(prepareContainer(container), dom.$('.browser-root')); + browserRoot.style.position = 'relative'; + browserRoot.style.width = '100%'; + browserRoot.style.height = '100%'; + + const browserContainerWrapper = dom.append(browserRoot, dom.$('.browser-container-wrapper')); + browserContainerWrapper.style.position = 'relative'; + browserContainerWrapper.style.width = '100%'; + browserContainerWrapper.style.height = '100%'; + + const browserContainer = dom.append(browserContainerWrapper, dom.$('.browser-container')); + browserContainer.style.position = 'absolute'; + browserContainer.style.inset = '0'; + + const placeholderContents = dom.append(browserContainer, dom.$('.browser-placeholder-contents')); + placeholderContents.style.position = 'relative'; + placeholderContents.style.width = '100%'; + placeholderContents.style.height = '100%'; + placeholderContents.appendChild(createBrowserWelcome('Browser', 'Use Add Element to Chat to reference UI elements in chat prompts.')); +} + +function renderChangesEmptyState({ container }: ComponentFixtureContext): void { + const editorPart = prepareContainer(container); + editorPart.style.display = 'flex'; + editorPart.style.alignItems = 'center'; + editorPart.style.justifyContent = 'center'; + renderSessionsEmptyState(editorPart, 'Changes', 'No changed files'); +} + +export default defineThemedFixtureGroup({ path: 'sessions/emptyStates/' }, { + Files: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['A centered Files empty state has a semibold "Files" title, a secondary "Select a file from the Files view" description directly below it, no icon, and a Search Files button separated beneath the message.'], + render: renderFilesEmptyState, + }), + Browser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['A centered Browser empty state has a semibold "Browser" title directly above a secondary two-line description, with no globe icon.'], + render: renderBrowserEmptyState, + }), + Changes: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['A centered Changes empty state has a semibold "Changes" title directly above the secondary text "No changed files", with no icon.'], + render: renderChangesEmptyState, + }), +}); diff --git a/src/vs/workbench/test/browser/parts/editor/diffEditorCommandsService.test.ts b/src/vs/workbench/test/browser/parts/editor/diffEditorCommandsService.test.ts index 3b07ae9584a083..d44f0cb03efe24 100644 --- a/src/vs/workbench/test/browser/parts/editor/diffEditorCommandsService.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/diffEditorCommandsService.test.ts @@ -41,12 +41,14 @@ suite('DiffEditorCommandsService', () => { let focusCalls: string[] = []; let goToDiffCalls: Array<'next' | 'previous'> = []; + let resetWidthBasedLayoutCalls = 0; function createDiffEditorControl(model: ITextModel | undefined, originalFocused: boolean, modifiedFocused: boolean): IDiffEditor { return new class extends mock() { override getOriginalEditor() { return createOriginalEditor(originalFocused); } override getModifiedEditor() { return createModifiedEditor(model, modifiedFocused); } override goToDiff(target: 'next' | 'previous') { goToDiffCalls.push(target); } + override resetWidthBasedLayout(): void { resetWidthBasedLayoutCalls++; } }; } @@ -83,6 +85,7 @@ suite('DiffEditorCommandsService', () => { setup(() => { focusCalls = []; goToDiffCalls = []; + resetWidthBasedLayoutCalls = 0; }); test('navigateInDiffEditor goes to the next/previous change of the active text diff editor', () => { @@ -145,4 +148,28 @@ suite('DiffEditorCommandsService', () => { assert.deepStrictEqual(resourceWrites, []); }); + + test('sets explicit and automatic diff view modes', async () => { + const model = { uri: URI.file('/foo.txt') } as ITextModel; + const control = createDiffEditorControl(model, false, false); + const { service, resourceWrites } = createService(createTextDiffEditor(control)); + + await service.setViewMode([], 'inline'); + await service.setViewMode([], 'sideBySide'); + await service.setViewMode([], 'automatic'); + + assert.deepStrictEqual({ + resourceWrites, + resetWidthBasedLayoutCalls, + }, { + resourceWrites: [ + { resource: model.uri, key: 'diffEditor.renderSideBySide', value: false }, + { resource: model.uri, key: 'diffEditor.renderSideBySide', value: true }, + { resource: model.uri, key: 'diffEditor.useInlineViewWhenSpaceIsLimited', value: false }, + { resource: model.uri, key: 'diffEditor.renderSideBySide', value: true }, + { resource: model.uri, key: 'diffEditor.useInlineViewWhenSpaceIsLimited', value: true }, + ], + resetWidthBasedLayoutCalls: 1, + }); + }); }); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 822f3dc323dd4b..acea33e6313ead 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -132,6 +132,18 @@ #### chat/chatPetAccessoryRig/chatPetAccessoryRig/LiveEyeLayering/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/28743055f10abdf3c0a7809b2b3e830b7a04dc65157febf48215eecdf8b03772) +#### chat/input/chatInput/CompactWithProviderIcon/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d49581cdd64a2c1fb30fd998d04b7a9b6ab72a7126cadb0495acdb2c30212ac2) + +#### chat/input/chatInput/CompactWithProviderIcon/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0eff7001dde59fa6a28e994095e643dc0afc593352252bf45b34a15da4be2f42) + +#### chat/input/chatInput/CopilotHarnessCompactPickers/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4a0314153468b84bb81bd087804f19b1a3181ef322d1d88e37a9ddfb67fa4e74) + +#### chat/input/chatInput/CopilotHarnessCompactPickers/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6f6751b15a0a8973b1adb870c688fcd4e3467744c3fa7cb5562186032255dc95) + #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2bd9c5e744b514d97d1ff309a1692f239c4f880464507430f9458946853f3db1) @@ -186,11 +198,35 @@ #### sessions/accountMenu/petAchievementBadges/chatPetAchievementBadges/AllBadges/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) +#### sessions/chat/input/chatInput/ResponsiveModelResizeCycleCompact/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a92c03d2a84e6fa15cfa01193f81961fb648ec03571e97030c458348ea81b759) + +#### sessions/chat/input/chatInput/ResponsiveModelResizeCycleCompact/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/53fe36871b8b384e93c928b25acad531c2bcec683521d22307d025265ed19d9c) + +#### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8afa4c4eeb9f78079ea98efe10b841be3ba1104fe5fcf7f0e936113243017792) + +#### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0fe9bb7434b473795187b0545ccaed6aa77af4b7e9f90890dc8885c7f31ddebb) + #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c03e1bc3f517347607d0422462dd55a50c95d01f03b5203b27829aa296f9b54e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/19b1ebb2ae6b03f3500181ac7d35fe20ccf4f0a3bcac413211f00f3db200a69b) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b646e331c1ecbc6859e19e42179cd7413780f139c71aad5e4ab009aed35d6f04) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/c2903c1bfd9364ff2dd3c6531a47a0b5c4ad11892c21c6db3357eb986c3ae568) + +#### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/828f33b8392e74ac892e8339d6b5401e5e17b9aeb424863f0c8ca0ae5982a30c) + +#### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/704712c8157cbb1e8b05602c92f65941f869a455400a7057057fa03330424c03) + +#### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/08859e75e7f3a262ed2428c7b676881578fc8f62301f4f3ca63d79c21a1e6dd0) + +#### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e896298aaf3e160015d98455ae350254c4acb40d32117b29d8fd8284923f0f44) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e1d5b98be6a615e7e30e3f08e1b1e80219743fbe0b478ff68a0e7b08e234cf56) @@ -198,6 +234,12 @@ #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e6b7c571945d3c311ab89529478340018496b9d4c75e13095b672c913cee52ee) +#### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/92dddf0febbf9a2e7b9d1a33940fefc624ba319f7bb7bd4d911743c535908392) + +#### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4ad50b05198491cea5a200931e6070113ac5238ecf6a31185e4d13e6f9dacdfc) + #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/34408877e1ac8af8668377237c835da520bfc4a1301ebd55f6dab32084f88042) @@ -211,49 +253,49 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/695c9069e5791b7d24b24c1f9db4561755ff5694ad38370a7706426b6d38210a) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1ed985c2b64d4cb7e9ef6c56df06761e308d0f4c8c49370bfaaf289d11121f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/75cb51ad8b1f6ecb8c3891c2ee1d260a45d293c502bef1c33ce0df915c543aa0) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2e624aeed24ef3d79e54a89d5e03ac4b2cd40ec4c7e35bdca430bc5729e36449) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b338d37441f73f6548b2cc9988d3875e351b914aa87269d06f74f4e844a51681) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7f23bf5ec8cc6dca4984588a2799bfd8c474b9cbfdc46b7492dc560674824e50) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/be3b3a64f1f39466e2239ab7d884befc5540d6a42253e77ba96408e4957c1404) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/721b660cdd0da4558a1d9eaae3963b1f43e09e55d7ea95ccd50b7f32e4c38b6e) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/761d4a6a30b41df0f542aa11ba02fb10896fecdd32fd3723e63b0b9062552d6f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9ccc89121f0b10fb398a0ceb1f9302211dbb069e22cd6d13ede9f3411d4fcc9) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/22cf5e117dce9936a9d7189741fbea102c57dea6e5ff9b04f6d9e9532f261ec5) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b33a584913c10c11ccb45516c2d4977eb545a6a5f0d4505f39260927f2fe4cf9) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8d11fedea833e441d29331a2e51b034f6aeb3de94bb9eb4aa313428007748c2f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/11d54edf2c2960a6be90647e26f5c2a664b18b7a8080e1497e60b523f1b3118d) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5c1f4114f8de4b3fcd390d0a952440e0107b0b2ecd98c39cf8a24ba54499f23e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b7852b606c3e4f28f354a58bfa342262c6800407b7494d8c6e1328452f1bb10a) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/40ebe91ecf151cf2dfaa702cb260805da15d3a9cc89465d353a9d2ea37f61405) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/05183fa9e5bbe592f5d711d9661dfed3b6a8d7ea31f0d5c3b916787a8c954aa1) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/806a587cfc90c9fa9b69728137b2a26f0e3827278541f74e50dde685816fae40) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/63f328b18b2118f81d740621197c5e42d12807b627d657f15348fedda043aec6) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34b08a9593a152f108dfa5a005d968a2e709a8ae12fcdc2f57fd0a15280a5b72) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/da60851cc3372baac8d29fd6ed651b39cc66347474b4c412788a255672484e87) #### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/55b9eb610b82aa126043592d1ecc5458ce8f0f4a37b3a9c2db557c60ed39d353) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/af268bfa64dd8a47c8e05e756742156c27b0ff49306146e2aa343260b5838735) #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/11b9c20d4100af6684519045dbef07052202c37041363c888a6cbc4be276b4d4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4c6dfa53103d4a06dd28aa56baad6798cd36894da97bffc351618aed8b10396f) #### sessions/sessionsList/SessionsList_AutomationsNewBadge/DarkHighContrast -![screenshot](https://hediet-screenshots.azurewebsites.net/images/dc2d1502e0902545279c33075ad656aff4206049872b18e9cbe87ddd171729f7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2e624aeed24ef3d79e54a89d5e03ac4b2cd40ec4c7e35bdca430bc5729e36449) #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5db359cb5c1f424f4f5153ad59e633efda657332d2e19049e472f27bd38df64f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0154b8cd8302a62959c0a067e02aadc24d27a54d85891dd19fb9e9b4d99362f2) #### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030)