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/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md deleted file mode 100644 index e833fb07e1ef9f..00000000000000 --- a/.github/prompts/fix-error.prompt.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -agent: agent -description: 'Fix an unhandled error from the VS Code error telemetry dashboard' -argument-hint: Paste the GitHub issue URL for the error-telemetry issue -tools: ['edit', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/runTask', 'read/getTaskOutput', 'search/usages', 'read/problems', 'search/changes', 'execute/testFailure', 'todo', 'execute/runTests', 'web/fetch', 'web/githubRepo'] ---- - -The user has given you a GitHub issue URL for an unhandled error from the VS Code error telemetry dashboard. Fetch the issue to retrieve its details (error message, stack trace, hit count, affected users). - -Follow the `fix-errors` skill guidelines to fix this error. Key principles: - -1. **Read the error construction code first.** Before proposing any fix, search the codebase for where the error is constructed (the `new Error(...)` or custom error class instantiation). Read the surrounding code to understand: - - What conditions trigger the error (thresholds, validation checks, categorization logic) - - What parameters, classifications, or categories the error encodes - - What the intended meaning of each category is and what action each warrants - - Whether the error is a symptom of invalid data, a threshold-based warning, or a design-time signal - Use this understanding to determine the correct fix strategy. Do NOT assume what the error means from its message alone — the construction code is the source of truth. -2. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. -3. **Trace the data flow upward** through the call stack to find the producer of invalid data. -4. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. -5. **If the producer is identifiable**, fix it directly. - -After making changes, check for compilation errors via the build task and run relevant unit tests. - -## Submitting the Fix - -After the fix is validated (compilation clean, tests pass): - -1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). -2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. -3. **Push**: `git push -u origin `. -4. **Create a draft PR** with a description that includes these sections: - - **Summary**: A concise description of what was changed and why. - - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. - - **Trigger scenarios**: What user actions or system conditions cause this error to surface. - - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: - ```` - ```mermaid - sequenceDiagram - participant A as CallerComponent - participant B as MiddleLayer - participant C as LowLevelUtil - A->>B: someOperation(data) - B->>C: validate(data) - C-->>C: data is invalid - C->>B: throws "error message" - B->>A: unhandled error propagates - ``` - ```` - - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). - - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. -5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. - - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. - - If there are review comments, evaluate each one: - - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). - - If not applicable, leave a reply explaining why. - - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. - - **Re-run tests** after addressing review comments to confirm nothing regressed. - - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. -6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. diff --git a/.github/skills/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md deleted file mode 100644 index f2afd203619d03..00000000000000 --- a/.github/skills/fix-errors/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: fix-errors -description: Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors. ---- - -When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count. - -## Approach - -### 1. Do NOT fix at the crash site - -The error manifests at a specific line in the stack trace, but **the fix almost never belongs there**. Fixing at the crash site (e.g., adding a `typeof` guard in a `revive()` function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere. - -### 2. Trace the data flow upward through the call stack - -Read each frame in the stack trace from bottom to top. For each frame, understand: -- What data is being passed and what is expected -- Where that data originated (IPC message, extension API call, storage, user input, etc.) -- Whether the data could have been corrupted or malformed at that point - -The goal is to find the **producer of invalid data**, not the consumer that crashes on it. - -### 3. When the producer cannot be identified from the stack alone - -Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case: - -- **Enrich the error message** at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline. -- **Do NOT silently swallow the error** — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle. -- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site. - -### 4. When the producer IS identifiable - -Fix the producer directly: -- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs -- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as `UriComponents` objects, not as strings) - -## Example - -Given a stack trace like: -``` -at _validateUri (uri.ts) ← validation throws -at new Uri (uri.ts) ← constructor -at URI.revive (uri.ts) ← revive assumes valid UriComponents -at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process -``` - -**Wrong fix**: Add a `typeof` guard in `URI.revive` to return `undefined` for non-object input. This silences the error but the caller still expects a valid URI and will fail later. - -**Right fix (when producer is unknown)**: Enrich the error at the IPC handler level and in `_validateUri` itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example: -```typescript -// In the IPC handler — validate before revive -function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI { - if (data && typeof data !== 'object') { - throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`); - } - // ... -} - -// In _validateUri — include the scheme value -throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); -``` - -**Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. - -## Understanding error construction before fixing - -Before proposing any fix, **always find and read the code that constructs the error**. Search the codebase for the error class name or a unique substring of the error message. The construction code reveals: - -- **What conditions trigger the error** — thresholds, validation checks, state assertions -- **What classifications or categories the error encodes** — the error may have subtypes that require different fix strategies -- **What the error's parameters mean** — numeric values, ratios, or flags embedded in the message often encode diagnostic context -- **Whether the error is actionable** — some errors are threshold-based warnings where the threshold may be legitimately exceeded by design - -Use this understanding to determine the correct fix strategy. The construction code is the source of truth — do NOT assume what the error means from its message alone. - -### Example: Listener leak errors - -Searching for `ListenerLeakError` leads to `src/vs/base/common/event.ts`, where the construction code reveals: - -```typescript -const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; -const error = new ListenerLeakError(kind, message, topStack); -``` - -Reading this code tells you: -- The error has two categories based on a ratio -- **Dominated** (ratio > 30%): one code path accounts for most listeners → that code path is the problem, fix its disposal -- **Popular** (ratio ≤ 30%): many diverse code paths each contribute a few listeners → the identified stack trace is NOT the root cause; it's just the most identical stack among many. Investigate the emitter and its aggregate subscribers instead -- For popular leaks: do NOT remove caching/pooling/reuse patterns that appear in the top stack — they exist to solve other problems. If the aggregate count is by design (e.g., many menus subscribing to a shared context key service), close the issue as "not planned" - -This analysis came from reading the construction code, not from memorized rules about listener leaks. - -## Guidelines - -- Prefer enriching error messages over adding try/catch guards -- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded) -- Do not change the behavior of shared utility functions (like `URI.revive`) in ways that affect all callers — fix at the specific call site or producer -- Run the relevant unit tests after making changes -- Check for compilation errors via the build task before declaring work complete 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 06ce024474ab10..89e2b22a0be213 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -37,7 +37,7 @@ import { formatUriForFileWidget } from '../common/toolUtils'; import { getImageMimeType } from './imageToolUtils'; import { assertFileNotContentExcluded, isFileExternalAndNeedsConfirmation, resolveToolInputPath } from './toolUtils'; import { IGrepResultService } from './grepResultService'; -import { IRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; +import { IRegionContextProviderService, type PathInfo, type RegionResult } from '../../../platform/languageContextProvider/common/regionContextProvider'; export const getReadFileV2Description = (orig: vscode.LanguageModelToolInformation): vscode.LanguageModelToolInformation => ({ name: ToolName.ReadFile, @@ -191,11 +191,12 @@ export class ReadFileTool implements ICopilotTool { try { const grepResultMatches = this.grepResultService.getGrepResult(options.chatRequestId, uri, startLine, endLine); 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, documentSnapshot); + const regionResult: RegionResult | undefined = await this.regionContextProvider.getRegions(documentSnapshot.uri, documentSnapshot.languageId, grepResultMatches, { start: startLine, end: endLine}); + if (regionResult !== undefined && regionResult.regions.length > 0 && documentSnapshot.version === documentSnapshot.document.version) { + const regions = regionResult.regions; + this.sendAdjustedRegionTelemetry(options, startLine, endLine, regions[0].range.start, regions[0].range.end, regionResult.paths, 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}]`); + // 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 [${regionResult.regions[0].range.start + 1}-${regionResult.regions[0].range.end + 1}]`); } else { if (documentSnapshot.version === documentSnapshot.document.version) { this.sendAdjustingFailedTelemetry(options, startLine, endLine, 'noGrepRegions', documentSnapshot); @@ -433,8 +434,11 @@ export class ReadFileTool implements ICopilotTool { } } - private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { + private async sendAdjustedRegionTelemetry(options: Pick, 'model' | 'chatRequestId' | 'input'>, originalStart: number, originalEnd: number, adjustedStart: number, adjustedEnd: number, pathInfo: PathInfo, documentSnapshot: TextDocumentSnapshot | NotebookDocumentSnapshot) { const languageId = documentSnapshot.languageId; + const smallestPath: string = JSON.stringify(pathInfo.smallest); + const largestPath: string | undefined = pathInfo?.largest ? JSON.stringify(pathInfo.largest) : undefined; + /* __GDPR__ "readFileRegionAdjusted" : { "owner": "dbaeumer", @@ -444,13 +448,17 @@ export class ReadFileTool implements ICopilotTool { "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 }, - "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" } + "languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The language ID of the document snapshot" }, + "smallestPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The smallest path in the region context" }, + "largestPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The largest path in the region context" } } */ this.telemetryService.sendMSFTTelemetryEvent('readFileRegionAdjusted', { requestId: options.chatRequestId, languageId, + smallestPath, + largestPath, }, { originalLines: originalEnd - originalStart + 1, diff --git a/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts b/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts index 462cf6684a83da..a01dbf4571e3d1 100644 --- a/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts +++ b/extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts @@ -50,10 +50,26 @@ export type LineRange = { end: number; }; -export type Region = { +export interface Region { kind: string; name?: string; range: LineRange; +} + +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; }; export type WithinRangeCacheScope = { @@ -460,14 +476,16 @@ export interface RegionContextRequest extends tt.server.protocol.Request { } export namespace RegionContextResponse { - export type OK = { - regions: Region[]; - }; + export type OK = RegionResult; export type Failed = CustomResponse.Failed; export function isOk(response: RegionContextResponse | undefined): response is Omit & { body: OK } { - return response?.type === 'response' && Array.isArray((response.body as OK | undefined)?.regions); + if (response?.type !== 'response') { + return false; + } + const body = response.body as OK | undefined; + return Array.isArray(body?.regions) && Array.isArray(body?.paths?.smallest); } export function isError(response: RegionContextResponse | undefined): response is Omit & { body: Failed } { @@ -640,4 +658,4 @@ export namespace NesRenameResponse { export type NesRenameResponse = (tt.server.protocol.Response & { body: NesRenameResponse.OK | NesRenameResponse.Failed; -}) | { type: 'cancelled' }; \ No newline at end of file +}) | { type: 'cancelled' }; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts index 5240908cc9b42a..a01dbf4571e3d1 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts @@ -50,10 +50,26 @@ export type LineRange = { end: number; }; -export type Region = { +export interface Region { kind: string; name?: string; range: LineRange; +} + +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; }; export type WithinRangeCacheScope = { @@ -460,14 +476,16 @@ export interface RegionContextRequest extends tt.server.protocol.Request { } export namespace RegionContextResponse { - export type OK = { - regions: Region[]; - }; + export type OK = RegionResult; export type Failed = CustomResponse.Failed; export function isOk(response: RegionContextResponse | undefined): response is Omit & { body: OK } { - return response?.type === 'response' && Array.isArray((response.body as OK | undefined)?.regions); + if (response?.type !== 'response') { + return false; + } + const body = response.body as OK | undefined; + return Array.isArray(body?.regions) && Array.isArray(body?.paths?.smallest); } export function isError(response: RegionContextResponse | undefined): response is Omit & { body: Failed } { diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts index f225ce4dea312e..a6e3a227915ef2 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/regionContextProvider.ts @@ -6,32 +6,51 @@ import type tt from 'typescript/lib/tsserverlibrary'; import TS from './typescript'; const ts = TS(); -import type { LineRange, Range, Region } from './protocol'; +import { Region, type LineRange, type Range, type RegionResult } from './protocol'; import tss from './typescripts'; type StructuralEntity = { kind: string; name?: string; rangeNode: tt.Node | [tt.Node, tt.Node]; includeJsDoc?: boolean; continueWith?: tt.Node }; +type ScopeInfo = { + regions: Region[]; + path: number[]; +}; + export class RegionContextProvider { - public getRegions(sourceFile: tt.SourceFile, ranges: readonly Range[], requested?: LineRange | undefined): Region[] | undefined { + public getRegions(sourceFile: tt.SourceFile, ranges: readonly Range[], requested?: LineRange | undefined): RegionResult | undefined { if (ranges.length === 0) { return undefined; } if (ranges.length === 1) { - return this.findEnclosingScopes(sourceFile, ranges[0].start.line, ranges[0].start.character, requested); + const scope = this.findEnclosingScopes(sourceFile, ranges[0].start.line, ranges[0].start.character, requested); + return scope === undefined ? undefined : { + regions: scope.regions, + paths: { smallest: scope.path } + }; } + let smallest: { path: number[]; region: Region } | undefined; + let largest: { path: number[]; region: Region } | undefined; const containersList: Region[][] = []; for (const range of ranges) { - const containers = this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); - if (containers !== undefined && containers.length > 0) { - containersList.push(containers.reverse()); + const scope = this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); + if (scope !== undefined && scope.regions.length > 0) { + const { regions, path } = scope; + const region = regions[0]; + if (smallest === undefined || Region.getSpan(region) < Region.getSpan(smallest.region)) { + smallest = { region, path }; + } + if (largest === undefined || Region.getSpan(region) > Region.getSpan(largest.region)) { + largest = { region, path }; + } + containersList.push(regions.reverse()); } } if (containersList.length === 0) { return undefined; - } + } const longestContainers = containersList.reduce((longest, containers) => containers.length > longest.length ? containers : longest); const commonContainers = longestContainers.slice(); @@ -69,10 +88,13 @@ export class RegionContextProvider { } } - return commonContainers.reverse(); + return { + regions: commonContainers.reverse(), + paths: { smallest: smallest?.path ?? [], largest: largest?.path } + }; } - private findEnclosingScopes(sourceFile: tt.SourceFile, line: number, column: number, requested?: LineRange | undefined): Region[] | undefined { + private findEnclosingScopes(sourceFile: tt.SourceFile, line: number, column: number, requested?: LineRange | undefined): ScopeInfo | undefined { const position = sourceFile.getPositionOfLineAndCharacter(line, column); const tokenInfo = tss.getRelevantTokens(sourceFile, position); const node = tokenInfo.touching ?? tokenInfo.token; @@ -108,7 +130,7 @@ export class RegionContextProvider { current = continueWith ?? current; } } - return result.length > 0 ? result : undefined; + return result.length > 0 ? { regions: result, path: tss.StableSyntaxKinds.getPath(node) } : undefined; } private getStructuralEntity(sourceFile: tt.SourceFile, node: tt.Node, requested?: LineRange | undefined): StructuralEntity | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts index 61136b3596a426..7506695256c9ae 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts @@ -1882,6 +1882,8 @@ namespace tss { [ts.SyntaxKind.CommaListExpression, 355], [ts.SyntaxKind.SyntheticReferenceExpression, 356], [ts.SyntaxKind.NotEmittedTypeElement, 357], // New in 5.8.x. Position in 5.8 is 354 and the rest shifts. + [ts.SyntaxKind.DeferKeyword, 358], // New in 6.0.3 + [ts.SyntaxKind.Count, 359] ]); const UnknownStableSyntaxKind: number = 9999; export function getPath(node: tt.Node): number[] { @@ -1894,4 +1896,4 @@ namespace tss { } } } -export = tss; \ No newline at end of file +export = tss; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts index 8872e1fc05f3c6..63b0c1bb9f65ad 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/create.ts @@ -215,8 +215,8 @@ const regionContextHandler = (request: RegionContextRequest): RegionContextHandl try { const sourceFile = input.program.getSourceFile(input.file); - const regions = sourceFile === undefined ? [] : new RegionContextProvider().getRegions(sourceFile, request.arguments!.ranges, request.arguments!.requested) ?? []; - return { response: { regions }, responseRequired: true }; + const result = sourceFile === undefined ? undefined : new RegionContextProvider().getRegions(sourceFile, request.arguments!.ranges, request.arguments!.requested); + return { response: result ?? { regions: [], paths: { smallest: [] } }, responseRequired: true }; } catch (error) { if (error instanceof Error) { return { response: { error: ErrorCode.exception, message: error.message, stack: error.stack }, responseRequired: true }; diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts index 9ebda67e380363..d69e68b7e72667 100644 --- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts +++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/regionContext.spec.ts @@ -7,7 +7,7 @@ import { beforeAll, suite, test } from 'vitest'; import ts from 'typescript'; -import type { LineRange, Range, Region } from '../../common/protocol'; +import type { LineRange, Range, RegionResult } from '../../common/protocol'; import type * as regionContextProvider from '../../common/regionContextProvider'; let RegionContextProvider: typeof regionContextProvider.RegionContextProvider; @@ -18,7 +18,7 @@ beforeAll(async () => { RegionContextProvider = (await import('../../common/regionContextProvider')).RegionContextProvider; }); -function getRegionContext(sourceFile: ts.SourceFile, ranges: readonly Range[], requested?: LineRange): Region[] | undefined { +function getRegionContext(sourceFile: ts.SourceFile, ranges: readonly Range[], requested?: LineRange): RegionResult | undefined { return new RegionContextProvider().getRegions(sourceFile, ranges, requested); } @@ -41,12 +41,15 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(3)]), [ - { kind: 'arrow-function', name: 'callback', range: { start: 2, end: 4 } }, - { kind: 'method', name: 'method', range: { start: 1, end: 5 } }, - { kind: 'class', name: 'Container', range: { start: 0, end: 6 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 6 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(3)]), { + regions: [ + { kind: 'arrow-function', name: 'callback', range: { start: 2, end: 4 } }, + { kind: 'method', name: 'method', range: { start: 1, end: 5 } }, + { kind: 'class', name: 'Container', range: { start: 0, end: 6 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 6 } }, + ], + paths: { smallest: [241, 219, 260, 261, 243, 241, 174, 263, 307] } + } satisfies RegionResult); }); test('merges distinct innermost regions', () => { @@ -61,11 +64,39 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(5)]), [ - { kind: 'merged', range: { start: 1, end: 6 } }, - { kind: 'class', name: 'Container', range: { start: 0, end: 7 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 7 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(5)]), { + regions: [ + { kind: 'merged', range: { start: 1, end: 6 } }, + { kind: 'class', name: 'Container', range: { start: 0, end: 7 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 7 } }, + ], + paths: { + smallest: [241, 174, 263, 307], + largest: [241, 174, 263, 307] + } + } satisfies RegionResult); + }); + + test('selects paths by region span', () => { + const sourceFile = ts.createSourceFile('regions.ts', [ + 'class Container {', + '\tconstructor() {', + '\t\tthis.value = 0;', + '\t}', + '', + '\tmethod(): void {', + '\t\tconst value = 1;', + '\t\treturn;', + '\t}', + '}', + ].join('\n'), ts.ScriptTarget.Latest, true); + const smallest = getRegionContext(sourceFile, [range(2)])?.paths.smallest; + const largest = getRegionContext(sourceFile, [range(6)])?.paths.smallest; + + assert.deepStrictEqual(getRegionContext(sourceFile, [range(2), range(6)])?.paths, { + smallest, + largest + }); }); test('groups property signatures within the requested range', () => { @@ -76,9 +107,15 @@ suite('Region context', () => { '}', ].join('\n'), ts.ScriptTarget.Latest, true); - assert.deepStrictEqual(getRegionContext(sourceFile, [range(1, 1), range(2, 1)], { start: 1, end: 2 }), [ - { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, - { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 3 } }, - ] satisfies Region[]); + assert.deepStrictEqual(getRegionContext(sourceFile, [range(1, 1), range(2, 1)], { start: 1, end: 2 }), { + regions: [ + { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, + { kind: 'sourceFile', name: 'regions.ts', range: { start: 0, end: 3 } }, + ], + paths: { + smallest: [80, 171, 264, 307], + largest: [80, 171, 264, 307] + } + } satisfies RegionResult); }); }); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts index 9ba4f7d05814ed..1eead1c8154447 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/regionContextProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { type IRegionContextProviderService, type Region, type LineRange, NullRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; +import { type IRegionContextProviderService, type RegionResult, type LineRange, NullRegionContextProviderService } from '../../../platform/languageContextProvider/common/regionContextProvider'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; import { TypeScript } from './tsService'; @@ -37,7 +37,7 @@ export class ContainerContextProviderService implements IRegionContextProviderSe this.disposables.dispose(); } - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { return this.provider.getRegions(document, languageId, ranges, requested); } diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts index 3d8daa0a59f5fd..a3135e8474ae0d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts6/regionContextProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import type { IRegionContextProviderService, Region, LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; +import type { IRegionContextProviderService, RegionResult, LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; import * as protocol from '../../common/serverProtocol'; enum ExecutionTarget { @@ -25,7 +25,7 @@ type RegionContextRequestArgs = Omit, vscode.Disposable { private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic }; - async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { + async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { if (document.scheme !== 'file' || (languageId !== 'typescript' && languageId !== 'javascript')) { return undefined; } @@ -50,7 +50,7 @@ export class TS6RegionContextProvider implements Omit 0 ? response.body.regions : undefined; + return protocol.RegionContextResponse.isOk(response) && response.body.regions.length > 0 ? response.body : undefined; } dispose(): void { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts index 8c46ca8572b94b..d3969f7b4fbec1 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/regionContextProvider.ts @@ -8,13 +8,18 @@ import type { Snapshot } from '@typescript/native/unstable/async'; import * as ts from '@typescript/native/unstable/ast'; import type { ILogService } from '../../../../platform/log/common/logService'; -import { type IRegionContextProviderService, type Region, type LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; +import { Region, type IRegionContextProviderService, type RegionResult, type LineRange } from '../../../../platform/languageContextProvider/common/regionContextProvider'; import { TypeScript7Api } from './ts7Api'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import tss from './typescripts'; type StructuralEntity = { kind: string; name?: string; rangeNode: ts.Node | [ts.Node, ts.Node]; includeJsDoc?: boolean; continueWith?: ts.Node }; +type ScopeInfo = { + regions: Region[]; + path: number[]; +}; + interface RegionContextApi { clearSourceFileCache(): void; updateSnapshot(): Promise; @@ -34,7 +39,7 @@ export class TS7RegionContextProvider implements Omit { + async getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise { if (document.scheme !== 'file' || (languageId !== 'typescript' && languageId !== 'javascript')) { return undefined; } @@ -60,13 +65,27 @@ export class TS7RegionContextProvider implements Omit 0) { - containersList.push(containers.reverse()); + const scope = await this.findEnclosingScopes(sourceFile, range.start.line, range.start.character, requested); + if (scope !== undefined && scope.regions.length > 0) { + const { regions, path } = scope; + const region = regions[0]; + if (smallest === undefined || Region.getSpan(region) < Region.getSpan(smallest.region)) { + smallest = { region, path }; + } + if (largest === undefined || Region.getSpan(region) > Region.getSpan(largest.region)) { + largest = { path, region }; + } + containersList.push(regions.reverse()); } } if (containersList.length === 0) { @@ -109,14 +128,17 @@ export class TS7RegionContextProvider implements Omit { + private async findEnclosingScopes(sourceFile: ts.SourceFile, line: number, column: number, requested?: LineRange | undefined): Promise { const position = sourceFile.getPositionOfLineAndCharacter(line, column); const tokenInfo = tss.getRelevantTokens(sourceFile, position); const node = tokenInfo.touching ?? tokenInfo.token; @@ -152,7 +174,7 @@ export class TS7RegionContextProvider implements Omit 0 ? result : undefined; + return result.length > 0 ? { regions: result, path: tss.StableSyntaxKinds.getPath(node) } : undefined; } private getStructuralEntity(sourceFile: ts.SourceFile, node: ts.Node, requested?: LineRange | undefined): StructuralEntity | undefined { diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts index 59bd35008caa0c..98ee3a3c6b5737 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/test/regionContext.spec.ts @@ -10,7 +10,7 @@ import { API } from '@typescript/native/unstable/async'; import * as vscode from 'vscode'; import { afterAll, beforeAll, suite, test } from 'vitest'; -import type { LineRange, Region } from '../../../../../platform/languageContextProvider/common/regionContextProvider'; +import type { LineRange, RegionResult } from '../../../../../platform/languageContextProvider/common/regionContextProvider'; import { TestLogService } from '../../../../../platform/testing/common/testLogService'; import { TS7RegionContextProvider } from '../regionContextProvider'; @@ -30,7 +30,7 @@ suite('TypeScript 7 region context', () => { await api.close(); }); - async function getRegions(ranges: vscode.Range[], requested?: LineRange): Promise { + async function getRegions(ranges: vscode.Range[], requested?: LineRange): Promise { const provider = new TS7RegionContextProvider(new TestLogService(), new TestTypeScript7Api(api, configFile)); try { return await provider.getRegions(vscode.Uri.file(fileName), 'typescript', ranges, requested); @@ -40,26 +40,48 @@ suite('TypeScript 7 region context', () => { } test('returns enclosing structural regions', async () => { - assert.deepStrictEqual(await getRegions([range(9, 2)]), [ - { kind: 'constructor', name: 'constructor', range: { start: 8, end: 10 } }, - { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(9, 2)]), { + regions: [ + { kind: 'constructor', name: 'constructor', range: { start: 8, end: 10 } }, + { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { smallest: [110, 211, 226, 244, 241, 176, 263, 307] } + } satisfies RegionResult); }); test('merges distinct innermost regions', async () => { - assert.deepStrictEqual(await getRegions([range(13, 2), range(18, 2)]), [ - { kind: 'merged', range: { start: 12, end: 22 } }, - { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(13, 2), range(18, 2)]), { + regions: [ + { kind: 'merged', range: { start: 12, end: 22 } }, + { kind: 'class', name: 'Calculator', range: { start: 5, end: 23 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { + smallest: [110, 211, 226, 244, 241, 174, 263, 307], + largest: [107, 253, 241, 174, 263, 307] + } + } satisfies RegionResult); + }); + + test('selects paths by region span', async () => { + assert.deepStrictEqual((await getRegions([range(9, 2), range(13, 2)]))?.paths, { + smallest: [110, 211, 226, 244, 241, 176, 263, 307], + largest: [110, 211, 226, 244, 241, 174, 263, 307] + }); }); test('groups property signatures within the requested range', async () => { - assert.deepStrictEqual(await getRegions([range(1, 1), range(2, 1)], { start: 1, end: 2 }), [ - { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, - { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, - ] satisfies Region[]); + assert.deepStrictEqual(await getRegions([range(1, 1), range(2, 1)], { start: 1, end: 2 }), { + regions: [ + { kind: 'interface-members', name: 'Result', range: { start: 1, end: 2 } }, + { kind: 'sourceFile', name: 'f1.ts', range: { start: 0, end: 32 } }, + ], + paths: { + smallest: [80, 171, 264, 307], + largest: [80, 171, 264, 307] + } + } satisfies RegionResult); }); }); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts index 2618205e497fea..784e8acc7fc66d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/ts7/typescripts.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createHash } from 'node:crypto'; +import type * as vscode from 'vscode'; import { Symbol as NativeSymbol, SymbolFlags, type NodeHandle, type Program, type Project, type Type, type DocumentPosition } from '@typescript/native/unstable/async'; import { @@ -23,7 +24,6 @@ import { type TypeNode, type DeclarationBase } from '@typescript/native/unstable/ast'; -import type * as vscode from 'vscode'; export class OperationCanceledException extends Error { constructor() { @@ -55,94 +55,6 @@ export class CancellationTokenWithTimer { } } -namespace tss { - export type TokenInfo = { - token: Node; - touching?: Node; - previous?: Node; - }; - - export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { - const token = getTokenAtPosition(sourceFile, position); - const result: TokenInfo = { token }; - if (token.kind === SyntaxKind.EndOfFile) { - result.previous = findPrecedingToken(sourceFile, position); - return result; - } - - const start = token.getStart(sourceFile); - if (position > start) { - result.touching = token; - } else if (position < start) { - let candidate: Node | undefined = token.parent; - while (candidate !== undefined) { - if (position >= candidate.getStart(sourceFile)) { - result.touching = candidate; - break; - } - candidate = candidate.parent; - } - } - result.previous = findPrecedingToken(sourceFile, position); - return result; - } - - export namespace Nodes { - export function getChildren(node: Node): readonly Node[] { - if (isSourceFile(node)) { - return node.statements; - } - const result: Node[] = []; - node.forEachChild(child => { - result.push(child); - return undefined; - }); - return result; - } - - export function getTypeName(node: TypeNode): string | undefined { - return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; - } - - export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { - let current: Node | undefined = node; - while (current !== undefined) { - if (current.kind === kind) { - return current; - } - current = current.parent; - } - return undefined; - } - - export function getParentBlock(node: Node): Node | undefined { - let current: Node | undefined = node; - while (current !== undefined) { - if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { - return current; - } - current = current.parent; - } - return undefined; - } - } - - export namespace StableSyntaxKinds { - export function getPath(node: Node): number[] { - const result: number[] = []; - let current: Node | undefined = node; - while (current !== undefined) { - result.push(current.kind); - if (isSourceFile(current)) { - break; - } - current = current.parent; - } - return result; - } - } -} - export type TokenInfo = tss.TokenInfo; export type DirectSuperSymbolInfo = { @@ -484,4 +396,450 @@ export namespace Types { } } +namespace tss { + export type TokenInfo = { + token: Node; + touching?: Node; + previous?: Node; + }; + + export function getRelevantTokens(sourceFile: SourceFile, position: number): TokenInfo { + const token = getTokenAtPosition(sourceFile, position); + const result: TokenInfo = { token }; + if (token.kind === SyntaxKind.EndOfFile) { + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + const start = token.getStart(sourceFile); + if (position > start) { + result.touching = token; + } else if (position < start) { + let candidate: Node | undefined = token.parent; + while (candidate !== undefined) { + if (position >= candidate.getStart(sourceFile)) { + result.touching = candidate; + break; + } + candidate = candidate.parent; + } + } + result.previous = findPrecedingToken(sourceFile, position); + return result; + } + + export namespace Nodes { + export function getChildren(node: Node): readonly Node[] { + if (isSourceFile(node)) { + return node.statements; + } + const result: Node[] = []; + node.forEachChild(child => { + result.push(child); + return undefined; + }); + return result; + } + + export function getTypeName(node: TypeNode): string | undefined { + return isTypeReferenceNode(node) ? node.typeName.getText() : undefined; + } + + export function getParentOfKind(node: Node, kind: SyntaxKind): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (current.kind === kind) { + return current; + } + current = current.parent; + } + return undefined; + } + + export function getParentBlock(node: Node): Node | undefined { + let current: Node | undefined = node; + while (current !== undefined) { + if (isBlock(current) || isModuleBlock(current) || isSourceFile(current)) { + return current; + } + current = current.parent; + } + return undefined; + } + } + + export namespace StableSyntaxKinds { + const KindMap: Map = new Map([ + [SyntaxKind.Unknown, 0], + // [SyntaxKind.EndOfFileToken, 1], + [SyntaxKind.SingleLineCommentTrivia, 2], + [SyntaxKind.MultiLineCommentTrivia, 3], + [SyntaxKind.NewLineTrivia, 4], + [SyntaxKind.WhitespaceTrivia, 5], + // [SyntaxKind.ShebangTrivia, 6], + [SyntaxKind.ConflictMarkerTrivia, 7], + [SyntaxKind.NonTextFileMarkerTrivia, 8], + [SyntaxKind.NumericLiteral, 9], + [SyntaxKind.BigIntLiteral, 10], + [SyntaxKind.StringLiteral, 11], + [SyntaxKind.JsxText, 12], + [SyntaxKind.JsxTextAllWhiteSpaces, 13], + [SyntaxKind.RegularExpressionLiteral, 14], + [SyntaxKind.NoSubstitutionTemplateLiteral, 15], + [SyntaxKind.TemplateHead, 16], + [SyntaxKind.TemplateMiddle, 17], + [SyntaxKind.TemplateTail, 18], + [SyntaxKind.OpenBraceToken, 19], + [SyntaxKind.CloseBraceToken, 20], + [SyntaxKind.OpenParenToken, 21], + [SyntaxKind.CloseParenToken, 22], + [SyntaxKind.OpenBracketToken, 23], + [SyntaxKind.CloseBracketToken, 24], + [SyntaxKind.DotToken, 25], + [SyntaxKind.DotDotDotToken, 26], + [SyntaxKind.SemicolonToken, 27], + [SyntaxKind.CommaToken, 28], + [SyntaxKind.QuestionDotToken, 29], + [SyntaxKind.LessThanToken, 30], + [SyntaxKind.LessThanSlashToken, 31], + [SyntaxKind.GreaterThanToken, 32], + [SyntaxKind.LessThanEqualsToken, 33], + [SyntaxKind.GreaterThanEqualsToken, 34], + [SyntaxKind.EqualsEqualsToken, 35], + [SyntaxKind.ExclamationEqualsToken, 36], + [SyntaxKind.EqualsEqualsEqualsToken, 37], + [SyntaxKind.ExclamationEqualsEqualsToken, 38], + [SyntaxKind.EqualsGreaterThanToken, 39], + [SyntaxKind.PlusToken, 40], + [SyntaxKind.MinusToken, 41], + [SyntaxKind.AsteriskToken, 42], + [SyntaxKind.AsteriskAsteriskToken, 43], + [SyntaxKind.SlashToken, 44], + [SyntaxKind.PercentToken, 45], + [SyntaxKind.PlusPlusToken, 46], + [SyntaxKind.MinusMinusToken, 47], + [SyntaxKind.LessThanLessThanToken, 48], + [SyntaxKind.GreaterThanGreaterThanToken, 49], + [SyntaxKind.GreaterThanGreaterThanGreaterThanToken, 50], + [SyntaxKind.AmpersandToken, 51], + [SyntaxKind.BarToken, 52], + [SyntaxKind.CaretToken, 53], + [SyntaxKind.ExclamationToken, 54], + [SyntaxKind.TildeToken, 55], + [SyntaxKind.AmpersandAmpersandToken, 56], + [SyntaxKind.BarBarToken, 57], + [SyntaxKind.QuestionToken, 58], + [SyntaxKind.ColonToken, 59], + [SyntaxKind.AtToken, 60], + [SyntaxKind.QuestionQuestionToken, 61], + [SyntaxKind.BacktickToken, 62], + [SyntaxKind.HashToken, 63], + [SyntaxKind.EqualsToken, 64], + [SyntaxKind.PlusEqualsToken, 65], + [SyntaxKind.MinusEqualsToken, 66], + [SyntaxKind.AsteriskEqualsToken, 67], + [SyntaxKind.AsteriskAsteriskEqualsToken, 68], + [SyntaxKind.SlashEqualsToken, 69], + [SyntaxKind.PercentEqualsToken, 70], + [SyntaxKind.LessThanLessThanEqualsToken, 71], + [SyntaxKind.GreaterThanGreaterThanEqualsToken, 72], + [SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, 73], + [SyntaxKind.AmpersandEqualsToken, 74], + [SyntaxKind.BarEqualsToken, 75], + [SyntaxKind.BarBarEqualsToken, 76], + [SyntaxKind.AmpersandAmpersandEqualsToken, 77], + [SyntaxKind.QuestionQuestionEqualsToken, 78], + [SyntaxKind.CaretEqualsToken, 79], + [SyntaxKind.Identifier, 80], + [SyntaxKind.PrivateIdentifier, 81], + [SyntaxKind.BreakKeyword, 83], + [SyntaxKind.CaseKeyword, 84], + [SyntaxKind.CatchKeyword, 85], + [SyntaxKind.ClassKeyword, 86], + [SyntaxKind.ConstKeyword, 87], + [SyntaxKind.ContinueKeyword, 88], + [SyntaxKind.DebuggerKeyword, 89], + [SyntaxKind.DefaultKeyword, 90], + [SyntaxKind.DeleteKeyword, 91], + [SyntaxKind.DoKeyword, 92], + [SyntaxKind.ElseKeyword, 93], + [SyntaxKind.EnumKeyword, 94], + [SyntaxKind.ExportKeyword, 95], + [SyntaxKind.ExtendsKeyword, 96], + [SyntaxKind.FalseKeyword, 97], + [SyntaxKind.FinallyKeyword, 98], + [SyntaxKind.ForKeyword, 99], + [SyntaxKind.FunctionKeyword, 100], + [SyntaxKind.IfKeyword, 101], + [SyntaxKind.ImportKeyword, 102], + [SyntaxKind.InKeyword, 103], + [SyntaxKind.InstanceOfKeyword, 104], + [SyntaxKind.NewKeyword, 105], + [SyntaxKind.NullKeyword, 106], + [SyntaxKind.ReturnKeyword, 107], + [SyntaxKind.SuperKeyword, 108], + [SyntaxKind.SwitchKeyword, 109], + [SyntaxKind.ThisKeyword, 110], + [SyntaxKind.ThrowKeyword, 111], + [SyntaxKind.TrueKeyword, 112], + [SyntaxKind.TryKeyword, 113], + [SyntaxKind.TypeOfKeyword, 114], + [SyntaxKind.VarKeyword, 115], + [SyntaxKind.VoidKeyword, 116], + [SyntaxKind.WhileKeyword, 117], + [SyntaxKind.WithKeyword, 118], + [SyntaxKind.ImplementsKeyword, 119], + [SyntaxKind.InterfaceKeyword, 120], + [SyntaxKind.LetKeyword, 121], + [SyntaxKind.PackageKeyword, 122], + [SyntaxKind.PrivateKeyword, 123], + [SyntaxKind.ProtectedKeyword, 124], + [SyntaxKind.PublicKeyword, 125], + [SyntaxKind.StaticKeyword, 126], + [SyntaxKind.YieldKeyword, 127], + [SyntaxKind.AbstractKeyword, 128], + [SyntaxKind.AccessorKeyword, 129], + [SyntaxKind.AsKeyword, 130], + [SyntaxKind.AssertsKeyword, 131], + [SyntaxKind.AssertKeyword, 132], + [SyntaxKind.AnyKeyword, 133], + [SyntaxKind.AsyncKeyword, 134], + [SyntaxKind.AwaitKeyword, 135], + [SyntaxKind.BooleanKeyword, 136], + [SyntaxKind.ConstructorKeyword, 137], + [SyntaxKind.DeclareKeyword, 138], + [SyntaxKind.GetKeyword, 139], + [SyntaxKind.InferKeyword, 140], + [SyntaxKind.IntrinsicKeyword, 141], + [SyntaxKind.IsKeyword, 142], + [SyntaxKind.KeyOfKeyword, 143], + [SyntaxKind.ModuleKeyword, 144], + [SyntaxKind.NamespaceKeyword, 145], + [SyntaxKind.NeverKeyword, 146], + [SyntaxKind.OutKeyword, 147], + [SyntaxKind.ReadonlyKeyword, 148], + [SyntaxKind.RequireKeyword, 149], + [SyntaxKind.NumberKeyword, 150], + [SyntaxKind.ObjectKeyword, 151], + [SyntaxKind.SatisfiesKeyword, 152], + [SyntaxKind.SetKeyword, 153], + [SyntaxKind.StringKeyword, 154], + [SyntaxKind.SymbolKeyword, 155], + [SyntaxKind.TypeKeyword, 156], + [SyntaxKind.UndefinedKeyword, 157], + [SyntaxKind.UniqueKeyword, 158], + [SyntaxKind.UnknownKeyword, 159], + [SyntaxKind.UsingKeyword, 160], + [SyntaxKind.FromKeyword, 161], + [SyntaxKind.GlobalKeyword, 162], + [SyntaxKind.BigIntKeyword, 163], + [SyntaxKind.OverrideKeyword, 164], + [SyntaxKind.OfKeyword, 165], + [SyntaxKind.QualifiedName, 166], + [SyntaxKind.ComputedPropertyName, 167], + [SyntaxKind.TypeParameter, 168], + [SyntaxKind.Parameter, 169], + [SyntaxKind.Decorator, 170], + [SyntaxKind.PropertySignature, 171], + [SyntaxKind.PropertyDeclaration, 172], + [SyntaxKind.MethodSignature, 173], + [SyntaxKind.MethodDeclaration, 174], + [SyntaxKind.ClassStaticBlockDeclaration, 175], + [SyntaxKind.Constructor, 176], + [SyntaxKind.GetAccessor, 177], + [SyntaxKind.SetAccessor, 178], + [SyntaxKind.CallSignature, 179], + [SyntaxKind.ConstructSignature, 180], + [SyntaxKind.IndexSignature, 181], + [SyntaxKind.TypePredicate, 182], + [SyntaxKind.TypeReference, 183], + [SyntaxKind.FunctionType, 184], + [SyntaxKind.ConstructorType, 185], + [SyntaxKind.TypeQuery, 186], + [SyntaxKind.TypeLiteral, 187], + [SyntaxKind.ArrayType, 188], + [SyntaxKind.TupleType, 189], + [SyntaxKind.OptionalType, 190], + [SyntaxKind.RestType, 191], + [SyntaxKind.UnionType, 192], + [SyntaxKind.IntersectionType, 193], + [SyntaxKind.ConditionalType, 194], + [SyntaxKind.InferType, 195], + [SyntaxKind.ParenthesizedType, 196], + [SyntaxKind.ThisType, 197], + [SyntaxKind.TypeOperator, 198], + [SyntaxKind.IndexedAccessType, 199], + [SyntaxKind.MappedType, 200], + [SyntaxKind.LiteralType, 201], + [SyntaxKind.NamedTupleMember, 202], + [SyntaxKind.TemplateLiteralType, 203], + [SyntaxKind.TemplateLiteralTypeSpan, 204], + [SyntaxKind.ImportType, 205], + [SyntaxKind.ObjectBindingPattern, 206], + [SyntaxKind.ArrayBindingPattern, 207], + [SyntaxKind.BindingElement, 208], + [SyntaxKind.ArrayLiteralExpression, 209], + [SyntaxKind.ObjectLiteralExpression, 210], + [SyntaxKind.PropertyAccessExpression, 211], + [SyntaxKind.ElementAccessExpression, 212], + [SyntaxKind.CallExpression, 213], + [SyntaxKind.NewExpression, 214], + [SyntaxKind.TaggedTemplateExpression, 215], + [SyntaxKind.TypeAssertionExpression, 216], + [SyntaxKind.ParenthesizedExpression, 217], + [SyntaxKind.FunctionExpression, 218], + [SyntaxKind.ArrowFunction, 219], + [SyntaxKind.DeleteExpression, 220], + [SyntaxKind.TypeOfExpression, 221], + [SyntaxKind.VoidExpression, 222], + [SyntaxKind.AwaitExpression, 223], + [SyntaxKind.PrefixUnaryExpression, 224], + [SyntaxKind.PostfixUnaryExpression, 225], + [SyntaxKind.BinaryExpression, 226], + [SyntaxKind.ConditionalExpression, 227], + [SyntaxKind.TemplateExpression, 228], + [SyntaxKind.YieldExpression, 229], + [SyntaxKind.SpreadElement, 230], + [SyntaxKind.ClassExpression, 231], + [SyntaxKind.OmittedExpression, 232], + [SyntaxKind.ExpressionWithTypeArguments, 233], + [SyntaxKind.AsExpression, 234], + [SyntaxKind.NonNullExpression, 235], + [SyntaxKind.MetaProperty, 236], + [SyntaxKind.SyntheticExpression, 237], + [SyntaxKind.SatisfiesExpression, 238], + [SyntaxKind.TemplateSpan, 239], + [SyntaxKind.SemicolonClassElement, 240], + [SyntaxKind.Block, 241], + [SyntaxKind.EmptyStatement, 242], + [SyntaxKind.VariableStatement, 243], + [SyntaxKind.ExpressionStatement, 244], + [SyntaxKind.IfStatement, 245], + [SyntaxKind.DoStatement, 246], + [SyntaxKind.WhileStatement, 247], + [SyntaxKind.ForStatement, 248], + [SyntaxKind.ForInStatement, 249], + [SyntaxKind.ForOfStatement, 250], + [SyntaxKind.ContinueStatement, 251], + [SyntaxKind.BreakStatement, 252], + [SyntaxKind.ReturnStatement, 253], + [SyntaxKind.WithStatement, 254], + [SyntaxKind.SwitchStatement, 255], + [SyntaxKind.LabeledStatement, 256], + [SyntaxKind.ThrowStatement, 257], + [SyntaxKind.TryStatement, 258], + [SyntaxKind.DebuggerStatement, 259], + [SyntaxKind.VariableDeclaration, 260], + [SyntaxKind.VariableDeclarationList, 261], + [SyntaxKind.FunctionDeclaration, 262], + [SyntaxKind.ClassDeclaration, 263], + [SyntaxKind.InterfaceDeclaration, 264], + [SyntaxKind.TypeAliasDeclaration, 265], + [SyntaxKind.EnumDeclaration, 266], + [SyntaxKind.ModuleDeclaration, 267], + [SyntaxKind.ModuleBlock, 268], + [SyntaxKind.CaseBlock, 269], + [SyntaxKind.NamespaceExportDeclaration, 270], + [SyntaxKind.ImportEqualsDeclaration, 271], + [SyntaxKind.ImportDeclaration, 272], + [SyntaxKind.ImportClause, 273], + [SyntaxKind.NamespaceImport, 274], + [SyntaxKind.NamedImports, 275], + [SyntaxKind.ImportSpecifier, 276], + [SyntaxKind.ExportAssignment, 277], + [SyntaxKind.ExportDeclaration, 278], + [SyntaxKind.NamedExports, 279], + [SyntaxKind.NamespaceExport, 280], + [SyntaxKind.ExportSpecifier, 281], + [SyntaxKind.MissingDeclaration, 282], + [SyntaxKind.ExternalModuleReference, 283], + [SyntaxKind.JsxElement, 284], + [SyntaxKind.JsxSelfClosingElement, 285], + [SyntaxKind.JsxOpeningElement, 286], + [SyntaxKind.JsxClosingElement, 287], + [SyntaxKind.JsxFragment, 288], + [SyntaxKind.JsxOpeningFragment, 289], + [SyntaxKind.JsxClosingFragment, 290], + [SyntaxKind.JsxAttribute, 291], + [SyntaxKind.JsxAttributes, 292], + [SyntaxKind.JsxSpreadAttribute, 293], + [SyntaxKind.JsxExpression, 294], + [SyntaxKind.JsxNamespacedName, 295], + [SyntaxKind.CaseClause, 296], + [SyntaxKind.DefaultClause, 297], + [SyntaxKind.HeritageClause, 298], + [SyntaxKind.CatchClause, 299], + [SyntaxKind.ImportAttributes, 300], + [SyntaxKind.ImportAttribute, 301], + [SyntaxKind.PropertyAssignment, 303], + [SyntaxKind.ShorthandPropertyAssignment, 304], + [SyntaxKind.SpreadAssignment, 305], + [SyntaxKind.EnumMember, 306], + [SyntaxKind.SourceFile, 307], + // [SyntaxKind.Bundle, 308], + [SyntaxKind.JSDocTypeExpression, 309], + [SyntaxKind.JSDocNameReference, 310], + // [SyntaxKind.JSDocMemberName, 311], + [SyntaxKind.JSDocAllType, 312], + // [SyntaxKind.JSDocUnknownType, 313], + [SyntaxKind.JSDocNullableType, 314], + [SyntaxKind.JSDocNonNullableType, 315], + [SyntaxKind.JSDocOptionalType, 316], + // [SyntaxKind.JSDocFunctionType, 317], + [SyntaxKind.JSDocVariadicType, 318], + // [SyntaxKind.JSDocNamepathType, 319], + [SyntaxKind.JSDoc, 320], + [SyntaxKind.JSDocText, 321], + [SyntaxKind.JSDocTypeLiteral, 322], + [SyntaxKind.JSDocSignature, 323], + [SyntaxKind.JSDocLink, 324], + [SyntaxKind.JSDocLinkCode, 325], + [SyntaxKind.JSDocLinkPlain, 326], + // [SyntaxKind.JSDocTag, 327], + [SyntaxKind.JSDocAugmentsTag, 328], + [SyntaxKind.JSDocImplementsTag, 329], + // [SyntaxKind.JSDocAuthorTag, 330], + [SyntaxKind.JSDocDeprecatedTag, 331], + // [SyntaxKind.JSDocClassTag, 332], + [SyntaxKind.JSDocPublicTag, 333], + [SyntaxKind.JSDocPrivateTag, 334], + [SyntaxKind.JSDocProtectedTag, 335], + [SyntaxKind.JSDocReadonlyTag, 336], + [SyntaxKind.JSDocOverrideTag, 337], + [SyntaxKind.JSDocCallbackTag, 338], + [SyntaxKind.JSDocOverloadTag, 339], + // [SyntaxKind.JSDocEnumTag, 340], + [SyntaxKind.JSDocParameterTag, 341], + [SyntaxKind.JSDocReturnTag, 342], + [SyntaxKind.JSDocThisTag, 343], + [SyntaxKind.JSDocTypeTag, 344], + [SyntaxKind.JSDocTemplateTag, 345], + [SyntaxKind.JSDocTypedefTag, 346], + [SyntaxKind.JSDocSeeTag, 347], + [SyntaxKind.JSDocPropertyTag, 348], + [SyntaxKind.JSDocThrowsTag, 349], + [SyntaxKind.JSDocSatisfiesTag, 350], + [SyntaxKind.JSDocImportTag, 351], + [SyntaxKind.SyntaxList, 352], + [SyntaxKind.NotEmittedStatement, 353], + [SyntaxKind.PartiallyEmittedExpression, 354], + // [SyntaxKind.CommaListExpression, 355], + [SyntaxKind.SyntheticReferenceExpression, 356], + [SyntaxKind.NotEmittedTypeElement, 357], // New in 5.8.x. Position in 5.8 is 354 and the rest shifts + [SyntaxKind.DeferKeyword, 358], // New in 6.0.3 + [SyntaxKind.Count, 359] + ]); + + const UnknownStableSyntaxKind: number = 9999; + export function getPath(node: Node): number[] { + const path: number[] = []; + while (node !== undefined) { + path.push(KindMap.get(node.kind) ?? UnknownStableSyntaxKind); + node = node.parent; + } + return path; + } + } +} + export default tss; diff --git a/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts b/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts index 2c7454f6d9c995..7b7e2670d3c152 100644 --- a/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts +++ b/extensions/copilot/src/platform/languageContextProvider/common/regionContextProvider.ts @@ -6,10 +6,10 @@ import type * as vscode from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; -export interface LineRange { +export type LineRange = { start: number; end: number; -} +}; export interface Region { kind: string; @@ -17,19 +17,35 @@ export interface Region { range: LineRange; } +export namespace Region { + export function getSpan(region: Region): number { + return region.range.end - region.range.start; + } +} + +export type PathInfo = { + smallest: number[]; + largest?: number[]; +}; + +export type RegionResult = { + regions: Region[]; + paths: PathInfo; +}; + export const IRegionContextProviderService = createServiceIdentifier('IRegionContextProviderService'); export interface IRegionContextProviderService extends vscode.Disposable { readonly _serviceBrand: undefined; - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[]): Promise; - getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise; + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[]): Promise; + getRegions(document: vscode.Uri, languageId: string, ranges: vscode.Range[], requested?: LineRange): Promise; } export class NullRegionContextProviderService implements IRegionContextProviderService { readonly _serviceBrand: undefined; - async getRegions(): Promise { + async getRegions(): Promise { return undefined; } 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/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 33bf50cd90bc45..382d3834a288ad 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -2354,6 +2354,7 @@ function asTreeContextMenuEvent(event: IListContextMenuEv export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { readonly defaultIndent?: number; // Only recommended for compact layouts. Leave unchanged otherwise + readonly paddingBottom?: number; readonly multipleSelectionSupport?: boolean; readonly typeNavigationEnabled?: boolean; readonly typeNavigationMode?: TypeNavigationMode; 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/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/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/browser/agentHostConnectionsService.ts b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts index 90fe66926b0fee..757cdd288e2b0e 100644 --- a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { AgentSession } from '../common/agent.js'; import { IAgentConnection, IAgentHostService } from '../common/agentService.js'; -import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, remoteAgentHostSessionTypeAuthorityPrefix } from '../common/agentHostSessionType.js'; import { agentHostAuthority } from '../common/agentHostUri.js'; import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; @@ -26,6 +26,9 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos private readonly _onDidChangeConnections = this._register(new Emitter()); readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + private readonly _onDidChangeSessionResolution = this._register(new Emitter()); + readonly onDidChangeSessionResolution: Event = this._onDidChangeSessionResolution.event; + private readonly _sessionResolutionPolicies = new Map(); constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -33,10 +36,15 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos ) { super(); - this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._onDidChangeConnections.fire())); + this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._fireConnectionsChanged())); // Ambient (re)start/exit changes whether the ambient connection is ready. - this._register(this._agentHostService.onAgentHostStart(() => this._onDidChangeConnections.fire())); - this._register(this._agentHostService.onAgentHostExit(() => this._onDidChangeConnections.fire())); + this._register(this._agentHostService.onAgentHostStart(() => this._fireConnectionsChanged())); + this._register(this._agentHostService.onAgentHostExit(() => this._fireConnectionsChanged())); + } + + private _fireConnectionsChanged(): void { + this._onDidChangeConnections.fire(); + this._onDidChangeSessionResolution.fire(); } get ambientConnection(): IAgentConnection { @@ -76,6 +84,20 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos return this._remoteAgentHostService.getConnection(address); } + registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable { + if (this._sessionResolutionPolicies.has(authority)) { + throw new Error(`Agent Host session resolution policy already registered for '${authority}'`); + } + this._sessionResolutionPolicies.set(authority, policy); + this._onDidChangeSessionResolution.fire(); + return toDisposable(() => { + if (this._sessionResolutionPolicies.get(authority) === policy) { + this._sessionResolutionPolicies.delete(authority); + this._onDidChangeSessionResolution.fire(); + } + }); + } + resolveSessionResource(sessionResource: URI): IAgentHostSessionResolution | undefined { const scheme = sessionResource.scheme; const rawSessionId = sessionResource.path.substring(1); @@ -83,7 +105,7 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos if (scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) { const provider = scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length); return provider - ? { connection: this._agentHostService, backendSession: AgentSession.uri(provider, rawSessionId) } + ? this._createSessionResolution(AMBIENT_AGENT_HOST_AUTHORITY, this._agentHostService, provider, rawSessionId) : undefined; } @@ -96,13 +118,25 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos const provider = scheme.substring(remoteAgentHostSessionTypeAuthorityPrefix(authority).length); const connection = this.getConnectionByAuthority(authority); if (provider && connection) { - return { connection, backendSession: AgentSession.uri(provider, rawSessionId) }; + return this._createSessionResolution(authority, connection, provider, rawSessionId); } } } return undefined; } + + private _createSessionResolution(authority: string, connection: IAgentConnection, provider: string, rawSessionId: string): IAgentHostSessionResolution { + const policy = this._sessionResolutionPolicies.get(authority); + const alias = policy?.sessionSchemeAlias; + const backendProvider = alias?.ui === provider ? alias.backend : provider; + return { + connection, + connectionAuthority: authority, + backendSession: AgentSession.uri(backendProvider, rawSessionId), + defaultChangesetKind: policy?.defaultChangesetKind, + }; + } } registerSingleton(IAgentHostConnectionsService, AgentHostConnectionsService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts index 0de98617beadd7..8fc06755e59998 100644 --- a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import type { IDisposable } from '../../../base/common/lifecycle.js'; import type { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentConnection } from './agentService.js'; +import type { DefaultChangesetKind } from './changesetUri.js'; /** * Chat-session resource scheme prefix for the window's ambient/local agent @@ -57,28 +59,43 @@ export interface IAgentHostConnectionInfo { /** * The result of resolving a chat-session resource to its backing agent host: - * the owning {@link IAgentConnection} and the canonical backend agent-session - * URI (`:/`) used for protocol operations on that connection. + * the owning {@link IAgentConnection}, its authority, and the canonical backend + * agent-session URI used for protocol operations on that connection. */ export interface IAgentHostSessionResolution { readonly connection: IAgentConnection; + readonly connectionAuthority: string; readonly backendSession: URI; + readonly defaultChangesetKind?: DefaultChangesetKind; +} + +/** Provider-owned policy needed to resolve a workbench session resource back to its host. */ +export interface IAgentHostSessionResolutionPolicy { + readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; + readonly defaultChangesetKind?: DefaultChangesetKind; +} + +/** The UI and backend schemes for a session whose provider and host identities differ. */ +export interface IAgentHostSessionSchemeAlias { + readonly ui: string; + readonly backend: string; } export const IAgentHostConnectionsService = createDecorator('agentHostConnectionsService'); /** - * A thin, read-only facade over the window's ambient agent host + * A facade over the window's ambient agent host * (`IAgentHostService`) and the registry of remote agent hosts * (`IRemoteAgentHostService`), so consumers can enumerate and resolve * {@link IAgentConnection}s without branching on local-vs-remote or - * fanning out over "1 ambient + N remote" themselves. + * fanning out over "1 ambient + N remote" themselves. Session providers may + * register declarative scheme/default-changeset policy so every consumer + * resolves provider-specific session resources consistently. * - * This service deliberately does NOT expose lifecycle/management operations: + * This service deliberately does NOT expose connection lifecycle operations: * ambient-process concerns (restart, inspect, auth-pending) stay on * `IAgentHostService`, and remote-registry mutations (add/remove/reconnect/ - * upgrade) stay on `IRemoteAgentHostService`. This facade only answers - * "which connections exist?" and "give me the connection for X". + * upgrade) stay on `IRemoteAgentHostService`. */ export interface IAgentHostConnectionsService { readonly _serviceBrand: undefined; @@ -86,6 +103,9 @@ export interface IAgentHostConnectionsService { /** Fires when the set of connections changes (ambient lifecycle or remotes added/removed). */ readonly onDidChangeConnections: Event; + /** Fires when connection or provider policy changes can alter session-resource resolution. */ + readonly onDidChangeSessionResolution: Event; + /** * All known connections as `[ambient, ...remotes]`. The ambient entry is * always present with a live `connection`; only remote entries may have @@ -111,6 +131,12 @@ export interface IAgentHostConnectionsService { */ getConnectionByAddress(address: string): IAgentConnection | undefined; + /** + * Registers provider-owned session resolution policy for a connection. + * At most one policy may be registered for an authority. + */ + registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable; + /** * Resolves an agent-host chat-session resource to its owning connection and * backend session URI. Handles both local schemes 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/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 582992adfe0c9e..4eb642465a9ffc 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -129,6 +129,17 @@ export const enum ChangesetKind { Unknown = 'unknown', } +/** Changeset kinds that can represent a session's default changes view. */ +export type DefaultChangesetKind = ChangesetKind.Branch | ChangesetKind.Uncommitted | ChangesetKind.Session; + +/** Selects the configured default changeset, falling back to the first catalogue entry. */ +export function selectDefaultChangeset>( + changesets: readonly T[] | undefined, + defaultKind: DefaultChangesetKind = ChangesetKind.Branch, +): T | undefined { + return changesets?.find(changeset => changeset.changeKind === defaultKind) ?? changesets?.[0]; +} + /** RFC 3986 scheme prefix, e.g. the `ahp-session:` in `ahp-session:/abc`. */ const URI_SCHEME_PREFIX = /^[a-zA-Z][a-zA-Z0-9+.\-]*:/; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 1b27fd2b11236e..ea20300c048aca 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -18,6 +18,7 @@ import type { IProductService } from '../../../product/common/productService.js' import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; import { + MessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, @@ -331,6 +332,33 @@ export function withMessageRequestHiddenFromTranscript(message: Message, hidden: }; } +/** + * Whether `turn` is a hidden system notification the host appended purely to + * carry a message (e.g. an Agent Merge status change). It never reaches the + * provider and never captures a checkpoint, so it can never own file changes + * and must be skipped when resolving a "last turn" for per-turn changes. + * + * A *visible* system notification (a background-agent completion, an Agent + * Merge repair prompt) is a real turn and is deliberately not matched. + */ +export function isHostNoticeTurn(turn: { readonly message: Message }): boolean { + return turn.message.origin.kind === MessageKind.SystemNotification + && (isMessageHiddenFromTranscript(turn.message) || isMessageRequestHiddenFromTranscript(turn.message)); +} + +/** Returns the last turn id that can own file changes, or `undefined` if there is none. */ +export function lastAttributableTurnId(turns: readonly { readonly id: string; readonly message: Message }[] | undefined): string | undefined { + if (!turns) { + return undefined; + } + for (let i = turns.length - 1; i >= 0; i--) { + if (!isHostNoticeTurn(turns[i])) { + return turns[i].id; + } + } + return undefined; +} + /** Whole-turn token consumption attributed to a single model. */ export interface ITurnTokenTotal { readonly model: string; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 139913434eb29f..266bc0cfca037d 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -30,6 +30,7 @@ import { type URI as ProtocolURI, readSessionGitState, isDefaultChatUri, + lastAttributableTurnId, SessionLifecycle, } from '../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; @@ -705,6 +706,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC /** * The single-folder per-turn diff: prefer the checkpoint-ref git diff of the * primary working directory, else fall back to the SDK-tracked aggregator. + * + * Every path that can return an empty list is logged — an empty per-turn + * changeset is otherwise indistinguishable from a turn that changed nothing. */ private async _computeSingleFolderTurnDiffs(session: ProtocolURI, trackedSession: ProtocolURI, db: ISessionDatabase, turnId: string): Promise { const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId); @@ -719,12 +723,19 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC if (fromRefDiffs) { return fromRefDiffs; } + this._logService.warn(`[AgentHostChangesetService] Turn ${session}/${turnId}: git diff ${pair.parent}..${pair.current} produced no result; falling back to tracked file edits, which cannot see terminal-tool edits`); + } else { + this._logService.warn(`[AgentHostChangesetService] Turn ${session}/${turnId}: no working directory resolved; falling back to tracked file edits, which cannot see terminal-tool edits`); } - } else if (pair && pair.parent === pair.current) { + } else if (pair) { // A no-op turn checkpoint reuses the parent ref (so per-turn // diff is empty by construction) — short-circuit to an empty // list instead of asking git for the (empty) diff. + this._logService.debug(`[AgentHostChangesetService] Turn ${session}/${turnId}: end-of-turn tree matches the turn-start tree (${pair.current}); reporting no changes`); return []; + } else { + // Expected for a non-git folder; otherwise checkpoint capture failed. + this._logService.debug(`[AgentHostChangesetService] Turn ${session}/${turnId}: no checkpoint pair; falling back to tracked file edits, which cannot see terminal-tool edits`); } // Fallback: SDK-tracked file_edits aggregator. return computeTurnDiffs(trackedSession, db, this._diffComputeService, turnId); @@ -1469,8 +1480,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * the session's shared working tree. For single-chat sessions this is the * default chat's last turn. For multi-chat sessions it is the last turn of * the most-recently-modified chat (peer-chat turn checkpoints are stored - * under the session URI keyed by their turn id). Returns `undefined` when - * no chat has any turns. + * under the session URI keyed by their turn id). Host notice turns are + * skipped — they capture no checkpoint, so picking one would drop the + * session changeset off its git fast path. */ private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined { const sessionState = this._stateManager.getSessionState(session); @@ -1480,7 +1492,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const chats = sessionState.chats ?? []; if (chats.length <= 1) { - return sessionState.turns.at(-1)?.id; + return lastAttributableTurnId(sessionState.turns); } let bestTurnId: string | undefined; @@ -1489,7 +1501,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const turns = isDefaultChatUri(chat.resource) ? sessionState.turns : this._stateManager.getChatState(chat.resource)?.turns; - const lastTurnId = turns?.at(-1)?.id; + const lastTurnId = lastAttributableTurnId(turns); if (lastTurnId && chat.modifiedAt >= bestModifiedAt) { bestModifiedAt = chat.modifiedAt; bestTurnId = lastTurnId; diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 126bb5a0f8c776..d143d376235390 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -32,6 +32,9 @@ const WORKTREE_REMOVAL_MAX_ATTEMPTS = 5; const WORKTREE_REMOVAL_RETRY_BASE_DELAY_MS = 100; const WORKTREE_REMOVAL_RETRY_MAX_DELAY_MS = 500; +/** Budget for reading one blob; a timeout here drops a diff's original side. */ +const SHOW_BLOB_TIMEOUT_MS = 15_000; + export class AgentHostGitService implements IAgentHostGitService { declare readonly _serviceBrand: undefined; @@ -707,12 +710,16 @@ export class AgentHostGitService implements IAgentHostGitService { return undefined; } - // `git show` exits non-zero when the path didn't exist at that - // ref; `_runGit` swallows that into `undefined` which is exactly - // the contract callers want. + const args = ['show', `${ref}:${repoRelativePath}`]; + this._logService.trace(`[agentHostGitService] > git ${args.join(' ')}`); + + // Callers only get `undefined`, which surfaces as "git blob not found" + // whatever actually went wrong, so log the real reason. return new Promise((resolve) => { - cp.execFile('git', ['show', `${ref}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { + cp.execFile('git', args, { cwd: workingDirectory.fsPath, timeout: SHOW_BLOB_TIMEOUT_MS, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { + // The timeout above is the only thing that kills this process. + this._logService.warn(`[agentHostGitService] > git ${args.join(' ')} failed: ${formatGitError(args, SHOW_BLOB_TIMEOUT_MS, error.killed === true, error, (stderr as Buffer).toString())}`); resolve(undefined); return; } 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 9089c59bf1ff1d..9f571b26446724 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -347,6 +347,13 @@ class ProviderCatalogUnavailableError extends Error { } } +class SubagentTranscriptUnavailableError extends Error { + constructor(chat: string) { + super(`Subagent transcript is not available yet: ${chat}`); + this.name = 'SubagentTranscriptUnavailableError'; + } +} + /** * Reconcile a session's working-directory set from a create-result / * materialization receipt. The resolved receipt is authoritative for the roots @@ -4856,12 +4863,21 @@ export class AgentService extends Disposable implements IAgentService { } private async _resolvePeerChatsForTurnValidation(sessionChannel: string): Promise { + const unavailableSubagentTranscripts = new Set(); while (true) { - const unresolvedChats = this._getUnresolvedPeerChats(sessionChannel); + const unresolvedChats = this._getUnresolvedPeerChats(sessionChannel)?.filter(chat => !unavailableSubagentTranscripts.has(chat)); if (!unresolvedChats) { throw new Error('Cannot validate turn id for unknown session'); } if (unresolvedChats.length === 0) { return; } await Promise.all(unresolvedChats.map(async chat => { - if (!await this._stateManager.resolveChatState(chat)) { throw new Error('Cannot resolve peer chat for turn id validation'); } + try { + if (!await this._stateManager.resolveChatState(chat)) { throw new Error('Cannot resolve peer chat for turn id validation'); } + } catch (error) { + if (!(error instanceof SubagentTranscriptUnavailableError)) { + throw error; + } + unavailableSubagentTranscripts.add(chat); + this._logService.warn(`[AgentService] Cannot validate turn ids against unavailable subagent transcript: ${chat}`); + } })); } } @@ -7183,7 +7199,7 @@ export class AgentService extends Disposable implements IAgentService { private async _resolveRestoredSubagentTurns(agent: IAgent, parentSession: URI, chatUri: string, origin: { readonly kind: ChatOriginKind.Tool; readonly chat: string; readonly toolCallId: string }): Promise { const childTurns = await this._getChatMessages(agent, URI.parse(chatUri), parentSession, origin); if (childTurns.length === 0) { - throw new Error(`Subagent transcript is not available yet: ${chatUri}`); + throw new SubagentTranscriptUnavailableError(chatUri); } return this._interleaveLocalTurns(parentSession.toString(), chatUri, childTurns); } 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 { const local = service.resolveSessionResource(URI.parse('agent-host-copilotcli:/abc123')); assert.strictEqual(local?.connection, ambient); + assert.strictEqual(local?.connectionAuthority, AMBIENT_AGENT_HOST_AUTHORITY); assert.strictEqual(local?.backendSession.toString(), 'copilotcli:/abc123'); const remote = service.resolveSessionResource(URI.parse('remote-myhost-copilotcli:/xyz789')); assert.strictEqual(remote?.connection, remoteConn); + assert.strictEqual(remote?.connectionAuthority, 'myhost'); assert.strictEqual(remote?.backendSession.toString(), 'copilotcli:/xyz789'); // Non-agent-host scheme and unknown remote authority resolve to undefined. assert.strictEqual(service.resolveSessionResource(URI.parse('vscode-chat-editor:/foo')), undefined); assert.strictEqual(service.resolveSessionResource(URI.parse('remote-unknown-copilotcli:/foo')), undefined); }); + + test('applies provider session resolution policy', () => { + const remoteConn = fakeConnection('remote-host'); + const byAddress = new Map([['myhost', remoteConn]]); + const { service } = createService([info('myhost', 'My Remote')], byAddress); + let resolutionChanges = 0; + store.add(service.onDidChangeSessionResolution(() => resolutionChanges++)); + + const registration = store.add(service.registerSessionResolutionPolicy('myhost', { + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + })); + const mapped = service.resolveSessionResource(URI.parse('remote-myhost-copilot:/xyz789')); + registration.dispose(); + const restored = service.resolveSessionResource(URI.parse('remote-myhost-copilot:/xyz789')); + + assert.deepStrictEqual({ + mapped: { + backendSession: mapped?.backendSession.toString(), + defaultChangesetKind: mapped?.defaultChangesetKind, + }, + restored: { + backendSession: restored?.backendSession.toString(), + defaultChangesetKind: restored?.defaultChangesetKind, + }, + resolutionChanges, + }, { + mapped: { + backendSession: 'ahp-session:/xyz789', + defaultChangesetKind: ChangesetKind.Session, + }, + restored: { + backendSession: 'copilot:/xyz789', + defaultChangesetKind: undefined, + }, + resolutionChanges: 2, + }); + }); }); 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/common/changesetUri.test.ts b/src/vs/platform/agentHost/test/common/changesetUri.test.ts index 342c58f1cf4bf1..b16ced59c62721 100644 --- a/src/vs/platform/agentHost/test/common/changesetUri.test.ts +++ b/src/vs/platform/agentHost/test/common/changesetUri.test.ts @@ -27,6 +27,7 @@ import { parseCompareTurnsChangesetUri, parseTurnChangesetUri, resolveChangesetUriTemplate, + selectDefaultChangeset, } from '../../common/changesetUri.js'; suite('changesetUri', () => { @@ -151,6 +152,22 @@ suite('changesetUri', () => { assert.strictEqual(resolveChangesetUriTemplate(`${sessionUri}/`, 'changeset/branch'), `${sessionUri}/changeset/branch`); }); + test('selectDefaultChangeset follows the configured kind and falls back to catalogue order', () => { + const changesets = [ + { label: 'Session', changeKind: ChangesetKind.Session }, + { label: 'Branch', changeKind: ChangesetKind.Branch }, + ]; + assert.deepStrictEqual({ + implicit: selectDefaultChangeset(changesets)?.label, + explicit: selectDefaultChangeset(changesets, ChangesetKind.Session)?.label, + missing: selectDefaultChangeset(changesets, ChangesetKind.Uncommitted)?.label, + }, { + implicit: 'Branch', + explicit: 'Session', + missing: 'Session', + }); + }); + test('predicates match the parser semantics', () => { assert.strictEqual(isChangesetUri(buildSessionChangesetUri(sessionUri)), true); assert.strictEqual(isChangesetUri(buildUncommittedChangesetUri(sessionUri)), true); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index d36874140a29e9..5c0ab8761915d4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -16,7 +16,7 @@ import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { ChangesetStatus, FileEditKind, MessageKind, SessionStatus, buildDefaultChatUri, withMessageRequestHiddenFromTranscript, withSessionGitState, type Changeset, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { META_CHANGES_SUMMARY } from '../../common/agentHostChangesetService.js'; @@ -1430,6 +1430,17 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }; } + /** Polls until `changesetUri` reaches `Ready`. */ + async function waitForChangesetReady(stateManager: AgentHostStateManager, changesetUri: string): Promise { + for (let i = 0; i < 500; i++) { + if (stateManager.getChangesetState(changesetUri)?.status === ChangesetStatus.Ready) { + return; + } + await timeout(1); + } + assert.fail(`changeset ${changesetUri} never reached Ready`); + } + function build(options: { workingDirectories: string[]; git: IAgentHostGitService; @@ -1814,6 +1825,55 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { assert.strictEqual(repoRootCalls, 0, 'single-folder fallback must not resolve repositories'); }); + /** + * A host notice turn captures no checkpoint, so picking it as the session's + * latest turn drops the session changeset onto the edit tracker, which + * cannot see terminal-tool edits. + */ + test('session changeset keeps its git fast path when the chat ends on a host notice turn', async () => { + const git = createNoopGitService(); + git.getRepositoryRoot = async wd => URI.parse(wd.toString()); + const diffCalls: Array<{ fromRef: string; toRef: string }> = []; + git.computeFileDiffsBetweenRefs = async (_wd, opts) => { + diffCalls.push({ fromRef: opts.fromRef, toRef: opts.toRef }); + return [gitDiff('/wd/edited.ts', 3, 1)]; + }; + const checkpoint: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + getBaselineCheckpoint: async () => 'baseline', + // Mirrors production: only the agent's turn has a checkpoint. + getTurnCheckpointPair: async (_session: URI, turnId: string) => + turnId === 'agent-turn' ? { parent: 'agent~p', current: 'agent~c' } : undefined, + }; + const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], git, checkpoint }); + + const chat = buildDefaultChatUri(sessionStr); + for (const turn of [ + { id: 'agent-turn', message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } } }, + { + id: 'notice-turn', + message: withMessageRequestHiddenFromTranscript( + { text: 'Agent Merge is enabled for `feature`.', origin: { kind: MessageKind.SystemNotification } }, + true, + ), + }, + ]) { + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: turn.id, startedAt: new Date(0).toISOString(), message: turn.message }); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: turn.id, duration: 1 }); + } + + svc.refreshSessionChangeset(sessionStr); + await waitForChangesetReady(stateManager, buildSessionChangesetUri(sessionStr)); + + assert.deepStrictEqual({ + diffCalls, + files: stateManager.getChangesetState(buildSessionChangesetUri(sessionStr))?.files.map(file => file.id), + }, { + diffCalls: [{ fromRef: 'baseline', toRef: 'agent~c' }], + files: [URI.file('/wd/edited.ts').toString()], + }); + }); + /** * All-folder branch summary (AC-3). In a multi-folder session the * `summary.changes` chip must reflect EVERY folder's branch delta, computed 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 9a87891adb2b81..ff886d015d829d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9252,11 +9252,10 @@ suite('AgentService (node dispatcher)', () => { test('registers subagent summaries without loading child transcripts until subscription', async () => { class LazySubagentMockAgent extends MockAgent { readonly messageReads: string[] = []; - private returnEmptyChildOnce = true; + childTranscriptAvailable = false; override async getSessionMessages(session: URI): Promise { this.messageReads.push(session.toString()); - if (parseChatUri(session)?.chatId.startsWith('subagent/') && this.returnEmptyChildOnce) { - this.returnEmptyChildOnce = false; + if (parseChatUri(session)?.chatId.startsWith('subagent/') && !this.childTranscriptAvailable) { return []; } return super.getSessionMessages(session); @@ -9304,12 +9303,33 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(service.subscribe(URI.parse(childChatUri), 'child-reader-first'), /Subagent transcript is not available yet/); assert.strictEqual(getStateManager(service).getChatState(childChatUri), undefined); + const envelopePromise = Event.toPromise(Event.filter(service.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + const sendPromise = Event.toPromise(agent.onDidSendMessage); + service.dispatchAction(buildDefaultChatUri(sessionResource), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-after-missing-subagent', + startedAt: '2026-09-02T16:15:03.293Z', + message: { text: 'Start it for me', origin: { kind: MessageKind.User } }, + }, 'client-test', 1); + const [envelope, send] = await Promise.all([envelopePromise, sendPromise]); + agent.childTranscriptAvailable = true; await service.subscribe(URI.parse(childChatUri), 'child-reader-second'); const childState = getStateManager(service).getChatState(childChatUri); - assert.ok(childState); - assert.strictEqual(childState.turns.length, 1); - assert.strictEqual(agent.messageReads.filter(resource => resource === childChatUri).length, 2); - assert.strictEqual(getStateManager(service).getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), undefined); + assert.deepStrictEqual({ + turnRejected: envelope.rejectionReason !== undefined, + parentActiveTurn: getStateManager(service).getChatState(buildDefaultChatUri(sessionResource))?.activeTurn?.id, + sentPrompt: send.prompt, + childTurnCount: childState?.turns.length, + childMessageReads: agent.messageReads.filter(resource => resource === childChatUri).length, + legacyChildSession: getStateManager(service).getSessionState(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')), + }, { + turnRejected: false, + parentActiveTurn: 'turn-after-missing-subagent', + sentPrompt: 'Start it for me', + childTurnCount: 1, + childMessageReads: 3, + legacyChildSession: undefined, + }); }); test('legacy subagent reconstruction replaces only a generic restored title', async () => { 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/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 06b1c942cd0a2a..e5cd4e99dcc043 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -807,6 +807,23 @@ Use the affected provider command with `--grep ""` and tempora - Related investigation: [#325284](https://github.com/microsoft/vscode/pull/325284). - Reproduce: temporarily clear the gate and run the exact title with `scripts\test-integration.bat`. +### Copilot custom subagent without a display name + +A client-contributed custom agent can specify its stable name, description, and prompt without a separate display name. Invoking that agent as a child should run its prompt and return its response to the parent. Instead, the bundled Copilot runtime starts the child and immediately fails it with `failed to assemble custom-agent system prompt: displayName: Required`. The same validation boundary also rejects the SDK's documented `null`/omitted all-tools representation with `tools: Expected array`, so custom agents that follow either optional-field contract cannot run as subagents. + +- Test: `custom agent without a display name completes as a subagent`. +- Scope: Copilot. +- Expected: the child responds with `CUSTOM_AGENT_CHILD_OK` and completes. +- Observed: `subagent.started` is followed by `subagent.failed` before the child makes a model request. +- Gate: live recording with `AGENT_HOST_RUN_KNOWN_ISSUES=1` until the runtime fix is included in the bundled SDK. +- Reproduce: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "custom agent without a display name completes as a subagent" + ``` + ### Mid-turn abort is record-only - Tests: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml new file mode 100644 index 00000000000000..96034db95c2b82 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-restored-parent-accepts-a-new-turn-after-a-custom-subagent-has-no-transcript.yaml @@ -0,0 +1,77 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + - role: assistant + content: + - type: tool_use + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + failed to assemble custom-agent system prompt: displayName: Required + tools: Expected array + response: + content: SETUP_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE". + - role: assistant + content: + - type: tool_use + name: task + input: + name: e2e-display-name-child + prompt: Return the custom child sentinel. + agent_type: e2e-display-name-child + description: Run child sentinel task + mode: sync + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + failed to assemble custom-agent system prompt: displayName: Required + tools: Expected array + - role: assistant + content: SETUP_DONE + - role: user + content: Reply exactly "PARENT_RECOVERED". + response: + content: PARENT_RECOVERED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index f4365f8d07be95..aecccd0db49dbc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -250,6 +250,15 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return action.files.find(file => fileHasBasename(file, basename))!; } + async function waitForEmptyChangeset(channel: string): Promise { + await context.client.waitForNotification(n => + isActionNotification(n, 'changeset/contentChanged') + && getActionEnvelope(n).channel === channel + && (getActionEnvelope(n).action as IContentChangedAction).files.length === 0, + 60_000, + ); + } + async function waitForTurnComplete(sessionUri: string, turnId: string): Promise { const chatUri = buildDefaultChatUri(sessionUri); await context.client.waitForNotification(n => @@ -620,15 +629,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { execSync('git commit -q -m "ignore generated log"', { cwd: workspace }); const sessionUri = await createSessionIn(workspace, 'changeset-ignored'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-ignored', writeFileCommand('ignored.log', 'ignored'), 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); @@ -639,15 +643,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace('ahp-changeset-create-delete-'); const sessionUri = await createSessionIn(workspace, 'changeset-create-delete'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-create-delete', '!node -e "const fs=require(\'fs\');fs.writeFileSync(\'temporary.txt\',\'temporary\');fs.unlinkSync(\'temporary.txt\')"', 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); @@ -658,15 +657,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace('ahp-changeset-edit-restore-'); const sessionUri = await createSessionIn(workspace, 'changeset-edit-restore'); const branchUri = buildBranchChangesetUri(sessionUri); - await context.client.call('subscribe', { channel: branchUri }); - await changesetState(branchUri); - context.client.clearReceived(); - const changed = context.client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri, - 60_000, - ); await runBangTurn(sessionUri, 'turn-changeset-edit-restore', writeFileTwiceBase64Command('seed.txt', 'changed', 'seed\n'), 1); + const changed = waitForEmptyChangeset(branchUri); + await context.client.call('subscribe', { channel: branchUri }); await changed; const state = await changesetState(branchUri); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts index 98ae91a8690bd5..65685e4e0fe09e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/subagentSuite.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mkdtempSync, writeFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { retry } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { AgentHostConfigKey } from '../../../../common/agentHostCustomizationConfig.js'; import { SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType, type ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; import { ResponsePartKind, + ROOT_STATE_URI, ToolCallConfirmationReason, ToolResultContentType, buildDefaultChatUri, @@ -22,12 +25,154 @@ import { type ToolResultContent, type ToolResultSubagentContent, } from '../../../../common/state/sessionState.js'; -import { createRealSession, dispatchTurn } from '../harness/agentHostE2ETestHarness.js'; +import { createRealSession, dispatchTurn, driveTurnToCompletion, getMarkdownResponseText } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs, isWindows } = context; + + function createCustomAgentWorkspace(prefix: string): string { + const workspace = mkdtempSync(join(tmpdir(), prefix)); + const agentsDirectory = join(workspace, '.github', 'agents'); + mkdirSync(agentsDirectory, { recursive: true }); + writeFileSync(join(agentsDirectory, 'display-name-child.agent.md'), [ + '---', + 'name: e2e-display-name-child', + 'description: Returns the custom child sentinel', + 'tools:', + ' - view', + '---', + 'Reply exactly "CUSTOM_AGENT_CHILD_OK". Do not call tools.', + ].join('\n')); + tempDirs.push(workspace); + return workspace; + } + + async function createCustomAgentSession(prefix: string): Promise { + const workspace = createCustomAgentWorkspace(prefix); + const sessionUri = await createRealSession(context.client, config, prefix, createdSessions, URI.file(workspace)); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: 1, + action: { + type: ActionType.RootConfigChanged, + config: { [AgentHostConfigKey.SessionCustomizationDiscoveryMode]: 'scan' }, + }, + }); + return sessionUri; + } + + function subagentChatFromReceived(parentChat: string): string | undefined { + for (const notification of context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallContentChanged'))) { + const envelope = getActionEnvelope(notification); + if (envelope.channel !== parentChat) { + continue; + } + const content = (envelope.action as { content: readonly ToolResultContent[] }).content; + const subagent = content.find((item): item is ToolResultSubagentContent => item.type === ToolResultContentType.Subagent); + if (subagent) { + return subagent.resource; + } + } + return undefined; + } + + function markdownText(state: ChatState | undefined): string { + return state?.turns.flatMap(turn => turn.responseParts) + .filter(part => part.kind === ResponsePartKind.Markdown) + .map(part => part.content) + .join('') ?? ''; + } + + function responsePartIds(turns: ISessionWithDefaultChat['turns']): string[] { + return turns.flatMap(turn => turn.responseParts.flatMap(part => { + const id = Reflect.get(part, 'id'); + return typeof id === 'string' ? [id] : []; + })); + } + + const copilotCustomAgentTest = config.provider === 'copilotcli' && config.supportsSubagents; + + // The bundled runtime currently rejects the SDK's optional displayName field; keep this executable in known-issue recording until that runtime fix ships. + (context.runKnownIssueTests && copilotCustomAgentTest ? test : test.skip)('custom agent without a display name completes as a subagent', async function () { + this.timeout(180_000); + + const sessionUri = await createCustomAgentSession('ahp-custom-agent-display-name-'); + const parentChat = buildDefaultChatUri(sessionUri); + await driveTurnToCompletion( + context.client, + sessionUri, + 'turn-custom-agent-display-name', + 'Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "PARENT_DONE".', + 2, + ); + + const subagentChat = subagentChatFromReceived(parentChat); + assert.ok(subagentChat, 'the parent tool call should expose the custom subagent chat'); + const snapshot = await context.client.call('subscribe', { channel: subagentChat }); + assert.match(markdownText(snapshot.snapshot?.state as ChatState | undefined), /CUSTOM_AGENT_CHILD_OK/); + }); + + (copilotCustomAgentTest ? test : test.skip)('restored parent accepts a new turn after a custom subagent has no transcript', async function () { + this.timeout(240_000); + + const sessionUri = await createCustomAgentSession('ahp-missing-custom-agent-transcript-'); + const parentChat = buildDefaultChatUri(sessionUri); + const setup = await driveTurnToCompletion( + context.client, + sessionUri, + 'turn-create-missing-subagent-transcript', + 'Use the task tool exactly once with agent_type "e2e-display-name-child". Wait for it, then reply exactly "SETUP_DONE".', + 2, + ); + assert.match(setup.responseText, /SETUP_DONE/); + assert.ok(subagentChatFromReceived(parentChat), 'the failed custom subagent should remain in the parent chat catalog'); + + const liveParent = await fetchSessionWithChat(context.client, sessionUri); + const liveResponsePartIds = responsePartIds(liveParent.turns); + assert.ok(liveResponsePartIds.length > 0); + + const unsubscribeParent = () => { + context.client.notify('unsubscribe', { channel: parentChat }); + context.client.notify('unsubscribe', { channel: sessionUri }); + }; + unsubscribeParent(); + + await retry(async () => { + const restored = await fetchSessionWithChat(context.client, sessionUri); + const restoredResponsePartIds = responsePartIds(restored.turns); + if (restoredResponsePartIds.length === liveResponsePartIds.length + && restoredResponsePartIds.every((id, index) => id === liveResponsePartIds[index])) { + unsubscribeParent(); + throw new Error('parent session has not been reconstructed from persisted provider state'); + } + }, 50, 100); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-after-missing-subagent-transcript', 'Reply exactly "PARENT_RECOVERED".', 3); + const started = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/turnStarted')) { + return false; + } + const envelope = getActionEnvelope(n); + return envelope.channel === parentChat + && envelope.action.type === ActionType.ChatTurnStarted + && envelope.action.turnId === 'turn-after-missing-subagent-transcript'; + }, 30_000); + assert.strictEqual(getActionEnvelope(started).rejectionReason, undefined); + await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/turnComplete')) { + return false; + } + const envelope = getActionEnvelope(n); + return envelope.channel === parentChat + && envelope.action.type === ActionType.ChatTurnComplete + && envelope.action.turnId === 'turn-after-missing-subagent-transcript'; + }, 90_000); + assert.match(getMarkdownResponseText(context.client), /PARENT_RECOVERED/); + }); + (config.supportsSubagents ? test : test.skip)('subagent tool calls are routed to the subagent session, not flat in the parent', async function () { this.timeout(180_000); @@ -229,12 +374,6 @@ export function defineSubagentTests(context: IAgentHostE2ETestContext): void { const assistantText = (turns: ISessionWithDefaultChat['turns']): string => turns.map(t => t.responseParts.map(p => p.kind === ResponsePartKind.Markdown ? p.content : '').join('')).join('\n'); - const responsePartIds = (turns: ISessionWithDefaultChat['turns']): string[] => - turns.flatMap(turn => turn.responseParts.flatMap(part => { - const id = Reflect.get(part, 'id'); - return typeof id === 'string' ? [id] : []; - })); - const liveParent = await fetchSessionWithChat(context.client, sessionUri); const liveParentResponsePartIds = responsePartIds(liveParent.turns); assert.ok(liveParentResponsePartIds.length > 0); 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/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 751fbad59c12c8..73be8c56f1b344 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -76,6 +76,8 @@ The durable state and transition catalog lives in [SINGLE_PANE_SCENARIOS.md](SIN Editors must be opened through `IEditorService`. Sessions-specific presentation must not bypass editor service behavior by opening directly on an editor group. +Chat input status-pill composition is owned by the shared workbench `ChatInputPills` and `StandardChatInputPillSources` components. The Agents Window and Agent Host editor/panel surfaces supply observable data adapters and their allowed pill kinds only; ordering, per-kind presentation, visibility, context menus, keyboard behavior, compact layout, and lifecycle rendering must not be reimplemented per surface. + Session providers register internal per-session directories as resource label homes. URI labels render as `/`, and breadcrumbs render the same home label as their root segment. Without a matching home formatter, existing URI-label and breadcrumb behavior is unchanged. ## Custom views 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/menus.ts b/src/vs/sessions/browser/menus.ts index cd278d7e84e11a..5cf11c63ce30d7 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -62,8 +62,6 @@ export const Menus = { SessionsEditorTitle: new MenuId('SessionsEditorTitle'), SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionsEditorTabsBarAddTab: new MenuId('SessionsEditorTabsBarAddTab'), - SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), - /** * Entries merged into the dropdown of the changes button bar's primary * button. A submenu contributed to its `primary` group names a group of 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/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index c4f5d2bc3fb3b1..eaec9061daedba 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; @@ -35,7 +35,19 @@ flex-direction: row; align-items: flex-start; gap: 6px; + height: var(--vscode-spacing-size320, 32px); + margin: 0 calc(-1 * var(--vscode-spacing-size80)); + padding: 0 var(--vscode-spacing-size20); border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); + box-sizing: border-box; +} + +:is(.hc-black, .hc-light) .chat-composite-bar.session-header-bar .chat-composite-bar-header { + border-bottom-color: var(--vscode-contrastBorder); +} + +.agent-sessions-workbench.editor-tabs-compact-height .chat-composite-bar.session-header-bar .chat-composite-bar-header { + height: var(--vscode-spacing-size280, 28px); } /* Main column hosts the title row. */ @@ -45,6 +57,7 @@ gap: 2px; flex: 1 1 auto; min-width: 0; + height: 100%; } /* Title row: title + actions */ @@ -52,7 +65,7 @@ display: flex; align-items: center; gap: 6px; - height: 34px; + height: 100%; } /* Status icon column — sits beside the main column, centered on the title line. @@ -63,7 +76,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - height: 34px; + height: 100%; font-size: var(--vscode-codiconFontSize, 16px); color: var(--session-view-foreground); } @@ -165,11 +178,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 +219,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..ebff580e9daf5c --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sessionsEmptyState.css @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + margin: 0; +} + +.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..a9430c5a61d55d --- /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.$('h2.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/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index e2ad345407b139..0c2234bb598f1f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -3,18 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../base/browser/dom.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, observableValue, transaction } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -28,10 +23,8 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { AGENT_HOST_COMMIT_CHANGESET_OPERATION_ID, AGENT_HOST_PULL_REQUEST_OPERATION_IDS, AGENT_HOST_SYNC_CHANGESET_OPERATION_ID } from '../../../../platform/agentHost/common/agentHostChangesetOperationService.js'; -import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasOpenPullRequestContext, SessionHasWorkspaceContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { SessionHasOpenPullRequestContext, SessionPrimaryPullRequestOperationContext } from '../../../common/contextkeys.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; @@ -53,19 +46,6 @@ class ViewAllChangesAction extends Action2 { title: localize2('agentSessions.changes', 'Changes'), icon: Codicon.diffMultiple, f1: false, - // Metadata pill rendered with live +/- counts, or the counts last shown - // for the session while it has not reported its changes yet. A session - // without a workspace folder (a quick chat) has no Changes editor to - // open, so the pill would be inert — it never joins the pill row. - menu: { - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and( - ContextKeyExpr.or(SessionHasChangesContext, SessionHasCachedChangesContext), - SessionHasWorkspaceContext - ) - }, }); } @@ -242,123 +222,6 @@ class CollapseUnchangedRegionsAction extends Action2 { } registerAction2(CollapseUnchangedRegionsAction); -// --- View All Changes action view item (session header diff stats) - -interface IDiffStats { - readonly files: number; - readonly insertions: number; - readonly deletions: number; - readonly branch: string | undefined; -} - -/** - * Renders the {@link ViewAllChangesAction} as a ` files +insertions -deletions` - * metadata pill. It appends the session's live aggregate diff stats. Activating the item runs the - * action, which opens the multi-file diff editor. - * - * The stats are read from the {@link ISessionContext} so the correct per-session changes - * are shown even when several session views are visible at once. The counts come from the - * session's {@link ISession.changesSummary} when available, falling back to aggregating the - * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's - * top-level {@link IActiveSession.changes} when none is default). - * - * A session reports its changes late, so until it reports any the counts last shown for it - * are taken from the {@link ISessionChangesStatsCache} — the pill is then already there, - * with plausible counts, the moment the session opens. - */ -export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { - - private readonly _diffStatsObs: IObservable; - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, - ) { - super(undefined, action, options); - - this._diffStatsObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); - - const stats = session - ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) - : undefined; - - return { - branch, - files: stats?.files ?? 0, - insertions: stats?.insertions ?? 0, - deletions: stats?.deletions ?? 0, - } satisfies IDiffStats; - }); - - this._register(autorun(reader => { - this._diffStatsObs.read(reader); - this.updateLabel(); - this.updateTooltip(); - this.updateAriaLabel(); - })); - } - - protected override getLabelText(): string { - const { files } = this._diffStatsObs.get(); - return files === 1 - ? localize('agentSessions.changes.file', "{0} file", files) - : localize('agentSessions.changes.files', "{0} files", files); - } - - protected override getAdditionalLabelContent(): Array { - const { insertions, deletions } = this._diffStatsObs.get(); - return [ - $('span.chat-pill-added', undefined, `+${insertions}`), - $('span.chat-pill-removed', undefined, `-${deletions}`), - ]; - } - - protected override getTooltip(): string { - const { branch } = this._diffStatsObs.get(); - return branch - ? localize('agentSessions.viewChanges.tooltip.branch', "View All Changes ({0})", branch) - : localize('agentSessions.viewChanges.tooltip', "View All Changes"); - } - - protected override getAriaLabel(): string { - const { files, insertions, deletions } = this._diffStatsObs.get(); - const filesLabel = files === 1 - ? localize('agentSessions.changes.file', "{0} file", files) - : localize('agentSessions.changes.files', "{0} files", files); - // e.g. "View All Changes (main): 3 files, +10, -4" - return localize('agentSessions.viewChanges.ariaLabel', "{0}: {1}, +{2}, -{3}", this.getTooltip(), filesLabel, insertions, deletions); - } -} - -/** - * Registers the {@link ViewAllChangesActionViewItem} for the diff-stats metadata pill. - */ -class ViewAllChangesActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.viewAllChangesActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, ViewAllChangesAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(ViewAllChangesActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - /** * Remembers the changes pill shown for each visible session so it can be rendered * optimistically the next time that session is opened, before the provider has @@ -588,5 +451,4 @@ export class NewSessionUncommittedChangesetOperationsActionContribution extends registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NewSessionUncommittedChangesetOperationsActionContribution.ID, NewSessionUncommittedChangesetOperationsActionContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 85cd98d397de09..3af91679961cb7 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -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/test/browser/changesActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts index e1d4394b0c4ca9..15e082d42ffb51 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesActions.test.ts @@ -16,46 +16,15 @@ import { Context } from '../../../../../platform/contextkey/browser/contextKeySe import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ActiveEditorContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; -import { SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus, UNCOMMITTED_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../common/changes.js'; import { NewSessionUncommittedChangesetOperationsActionContribution } from '../../browser/changesActions.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; suite('Changes Actions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('changes pill stays out of the pill row for a session without a workspace folder', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionHeaderMeta) - .filter(isIMenuItem) - .find(item => item.command.id === VIEW_SESSION_CHANGES_COMMAND_ID); - - assert.ok(item, 'expected the changes pill on the session metadata menu'); - const evaluate = (state: { changes?: boolean; cachedChanges?: boolean; workspace?: boolean }) => { - const context = new Context(1, null); - context.setValue(SessionHasChangesContext.key, state.changes ?? false); - context.setValue(SessionHasCachedChangesContext.key, state.cachedChanges ?? false); - context.setValue(SessionHasWorkspaceContext.key, state.workspace ?? false); - return item.when?.evaluate(context) ?? false; - }; - - assert.deepStrictEqual({ - folderlessChatWithChanges: evaluate({ changes: true }), - folderlessChatWithCachedChanges: evaluate({ cachedChanges: true }), - workspaceSessionWithChanges: evaluate({ changes: true, workspace: true }), - workspaceSessionWithCachedChanges: evaluate({ cachedChanges: true, workspace: true }), - workspaceSessionWithoutChanges: evaluate({ workspace: true }), - }, { - folderlessChatWithChanges: false, - folderlessChatWithCachedChanges: false, - workspaceSessionWithChanges: true, - workspaceSessionWithCachedChanges: true, - workspaceSessionWithoutChanges: false, - }); - }); - test('draft session contributes uncommitted changeset operations to the editor header', async () => { const invokedOperations: string[] = []; const operations = observableValue('test.operations', [{ diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 0affeda1917ba1..6d2df37212794a 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -296,7 +296,7 @@ export class ChatView extends AbstractChatView { this._banners.setActive(this._isActive); // Floating status pills above the input. - this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar)); + this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar, false, () => this._widget.focusInput())); const updateChatPillsVisibility = (visible: boolean) => { this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); }; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 8b9d5d51bd7d13..e76a8b47a69b8e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -145,6 +145,10 @@ background-color: var(--vscode-agentsChatInput-background) !important; } +.sessions-chat-editor .monaco-editor .mtk1 { + color: var(--vscode-input-foreground); +} + /* Inline decoration highlights for the new-session input editor: slash commands, * onboarding prompt placeholders, `#file:` variable references, and agent-host * completion references. */ @@ -251,6 +255,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 +263,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/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css deleted file mode 100644 index 7e6089f0fcdf36..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Horizontally scrollable status pills above the chat input. */ - -.session-chat-input-toolbar { - width: 100%; - min-width: 0; -} - -.session-chat-input-toolbar-content { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--vscode-spacing-size60); - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40) 0; -} - -.session-chat-input-toolbar-content > .chat-pills, -.session-chat-input-toolbar-content > .session-activity-pill { - flex-shrink: 0; -} - -.session-chat-input-toolbar-content > * { - pointer-events: auto; -} - -.session-chat-input-toolbar.hidden { - display: none; -} - -/* No pill is left to right-click, so the row takes back a slim hit-testable - strip to keep the visibility menu (and with it the hidden pills) reachable. */ -.session-chat-input-toolbar.empty { - pointer-events: auto; -} - -.session-chat-input-toolbar.empty .session-chat-input-toolbar-content { - min-height: var(--vscode-spacing-size120); -} - -/* The floating row is click-through, so the slider shows the scroll position but - cannot be dragged; wheeling over a pill scrolls the row. */ -.session-chat-input-toolbar > .scrollbar > .slider { - background: transparent; -} - -.session-chat-input-toolbar > .scrollbar.horizontal > .slider::before { - content: ''; - position: absolute; - inset: var(--vscode-strokeThickness); - border-radius: var(--vscode-cornerRadius-circle); - background: var(--vscode-scrollbarSlider-background); -} - -.session-chat-input-toolbar > .scrollbar > .slider:hover::before { - background: var(--vscode-scrollbarSlider-hoverBackground); -} - -.session-chat-input-toolbar > .scrollbar > .slider.active::before { - background: var(--vscode-scrollbarSlider-activeBackground); -} 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/browser/newChatVoice.ts b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts index 9d0214507f0a3b..4bbd91e5c37bbb 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts @@ -78,6 +78,10 @@ export interface INewChatVoiceTargetService { registerComposer(composer: INewChatVoiceComposer): IDisposable; /** Promote `composer` to the active voice target. */ setActive(composer: INewChatVoiceComposer): void; + /** Allow the next composer replacement initiated by a voice request. */ + beginVoiceTransition(): IDisposable; + /** Consume a pending voice-initiated composer replacement. */ + consumeVoiceTransition(): boolean; } export class NewChatVoiceTargetService extends Disposable implements INewChatVoiceTargetService { @@ -86,6 +90,8 @@ export class NewChatVoiceTargetService extends Disposable implements INewChatVoi private readonly _composers = new Set(); private readonly _activeComposer = observableValue(this, undefined); readonly activeComposer: IObservable = this._activeComposer; + private _voiceTransitionVersion = 0; + private _pendingVoiceTransition: number | undefined; /** Session resource of the last-focused chat widget (the last-focused input). */ private readonly _focusedSessionResource: IObservable; @@ -136,6 +142,24 @@ export class NewChatVoiceTargetService extends Disposable implements INewChatVoi this._activeComposer.set(composer, undefined); } } + + beginVoiceTransition(): IDisposable { + const version = ++this._voiceTransitionVersion; + this._pendingVoiceTransition = version; + return toDisposable(() => { + if (this._pendingVoiceTransition === version) { + this._pendingVoiceTransition = undefined; + } + }); + } + + consumeVoiceTransition(): boolean { + if (this._pendingVoiceTransition === undefined) { + return false; + } + this._pendingVoiceTransition = undefined; + return true; + } } registerSingleton(INewChatVoiceTargetService, NewChatVoiceTargetService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 2c1aa63dcb0e1b..7f8e152760f39c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -25,7 +25,6 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; -import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js'; @@ -37,24 +36,6 @@ const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarouse /** Action id of the references pill. */ export const SESSION_REFERENCES_PILL_ID = 'sessions.chatPills.references'; -/** - * Presentation of the references pill. References are always summarized: the - * pill answers "what did this session point me at" with a count, rather than - * turning into whichever single reference happens to be recorded. - */ -export const sessionReferencesPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionReferences', - icon: Codicon.bookmark, - title: localize('sessionReferences.title', "References"), - summaryLabel: count => count === 1 - ? localize('sessionReferences.countSingle', "1 Reference") - : localize('sessionReferences.count', "{0} References", count), - summaryAriaLabel: count => count === 1 - ? localize('sessionReferences.showSingle', "Show 1 reference") - : localize('sessionReferences.show', "Show {0} references", count), - singleEntry: ChatPillSingleEntry.Summary, -}; - const artifactIcons: ReadonlyMap = new Map([ [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], [SessionArtifactKind.Issue, Codicon.issues], diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts index 77517404c44eb4..a3c6751c88f975 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts @@ -8,7 +8,6 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; @@ -17,15 +16,6 @@ import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug. const SUBAGENT_LABEL_MAX_LENGTH = 30; -/** Presentation of the subagents pill. */ -export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionBackgroundActivities', - icon: Codicon.agent, - title: localize('backgroundActivities.ariaLabel', "Background Activities"), - summaryLabel: count => localize('backgroundActivities.subagentsSummary', "{0} Subagents", count), - summaryAriaLabel: count => localize('backgroundActivities.showSubagents', "Show {0} subagents", count), -}; - /** * Supplies the background activities of the viewed chat to its pill. Today * those are all of the chat's direct subagents, regardless of status (still diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts index d5dab1934fb970..4c932401b18721 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -10,22 +10,12 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; -import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -/** Presentation of the browsers pill. */ -export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { - widgetId: 'sessionBrowsers', - icon: Codicon.globe, - title: localize('browsers.ariaLabel', "Browsers"), - summaryLabel: count => localize('browsers.activeBrowsers', "{0} Active Browsers", count), - summaryAriaLabel: count => localize('browsers.show', "Show {0} browsers", count), -}; - const NO_URLS: ReadonlySet = new Set(); function urlsEqual(a: ReadonlySet, b: ReadonlySet): boolean { @@ -46,7 +36,7 @@ function urlsEqual(a: ReadonlySet, b: ReadonlySet): boolean { /** Supplies the live browsers of the viewed chat (and its subagents) to its pill. */ export class SessionBrowsersControl extends Disposable { - /** The pill's sections, empty while the user has the pill hidden. */ + /** The pill's sections before the shared controller applies user visibility. */ readonly sections: IObservable; /** The URLs the pill's browsers show, empty while the user has the pill hidden. */ readonly urls: IObservable>; @@ -105,7 +95,7 @@ export class SessionBrowsersControl extends Disposable { }); this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); - this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); + this.sections = allSections; this.urls = derivedOpts>({ owner: this, equalsFn: urlsEqual }, reader => visible.read(reader) ? allUrls.read(reader) : NO_URLS); this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 17285abb69f71b..0507b237ab0df9 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -3,53 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow } from '../../../../base/browser/dom.js'; -import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; -import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; -import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; -import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { SessionArtifacts, sessionArtifactLocation, sessionReferencesPillOptions, SESSION_REFERENCES_PILL_ID } from './sessionArtifacts.js'; -import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatInputPills, StandardChatInputPillSources } from '../../../../workbench/contrib/chat/browser/chatInputPills.js'; +import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js'; +import { SessionCustomizations } from './sessionCustomizations.js'; import { localize } from '../../../../nls.js'; -import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; -import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; -import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js'; -import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; -import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; +import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillResourceLocation, type ChatPillsCompactMode, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { computeAggregateIssueIcon, computeIssueIcon, getPullRequestStatusFromIcon, GitHubIssueState, OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID, type IGitHubIssue } from '../../github/common/types.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { IResolvedSessionPullRequest, SessionPullRequestPresentationModel } from '../../github/browser/pullRequestIconStatus.js'; +import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../../../workbench/contrib/chat/common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IChat } from '../../../services/sessions/common/session.js'; +import { getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; -import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; +import { SessionBrowsersControl } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -import { SessionMetadataPills } from './sessionMetadataPills.js'; import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; -import './media/sessionChatInputToolbar.css'; +import { computePullRequestIcon } from '../../../../workbench/common/chatPullRequest.js'; +import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; +import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { getSessionAgentMergeConfigurationObservable } from '../../../browser/sessionAgentMerge.js'; +import { createIssueHoverElement } from '../../github/browser/issueHover.js'; +import { createPullRequestHoverElement } from '../../github/browser/pullRequestHover.js'; -/** Diff stats for the current turn, from the chat''s last-turn changes. */ -function computeTurnStats(chat: IChat, reader: IReader): IDiffStats { - let files = 0, insertions = 0, deletions = 0; - for (const change of chat.lastTurnChanges?.read(reader) ?? []) { - if (change.isOutsideWorkspace) { - continue; - } - files++; - insertions += change.insertions; - deletions += change.deletions; - } - return { files, insertions, deletions }; -} /** Fake artifacts for the pill debug overlay. */ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { const entries = debugData.markdownFiles.map(name => { @@ -59,33 +51,141 @@ function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): read return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } -/** Action ids of the pills the sessions toolbar hosts itself. */ -export const SESSION_BROWSERS_PILL_ID = 'sessions.chatPills.browsers'; -export const SESSION_SUBAGENTS_PILL_ID = 'sessions.chatPills.subagents'; +function getPullRequestAttention(icon: ThemeIcon, status: IResolvedSessionPullRequest['status']): string | undefined { + if (status.hasFailingChecks && status.hasUnresolvedComments) { + return localize('sessionChatPills.pullRequestFailingChecksAndComments', "failing checks and unresolved review comments"); + } + if (status.hasFailingChecks) { + return localize('sessionChatPills.pullRequestFailingChecks', "failing checks"); + } + if (status.hasUnresolvedComments || icon.id === Codicon.gitPullRequestComment.id) { + return localize('sessionChatPills.pullRequestComments', "unresolved review comments"); + } + if (icon.id === Codicon.gitPullRequestError.id) { + return localize('sessionChatPills.pullRequestAttention', "failing checks or merge conflicts"); + } + return undefined; +} -/** The pill kind a contributed or turn-status action belongs to, if any. */ -export function getSessionChatPillKindForAction(actionId: string): SessionChatPillKind | undefined { - switch (actionId) { - case CHAT_TURN_CHANGES_PILL_ID: - case VIEW_SESSION_CHANGES_COMMAND_ID: - return SessionChatPillKind.Changes; - case CHAT_TURN_ARTIFACT_PILL_ID: - return SessionChatPillKind.Artifacts; - case SESSION_REFERENCES_PILL_ID: - return SessionChatPillKind.References; - case SESSION_CUSTOMIZATIONS_PILL_ID: - return SessionChatPillKind.Customizations; - case OPEN_PULL_REQUEST_ACTION_ID: - return SessionChatPillKind.PullRequests; - case OPEN_ISSUE_ACTION_ID: - return SessionChatPillKind.Issues; - case SESSION_BROWSERS_PILL_ID: - return SessionChatPillKind.Browsers; - case SESSION_SUBAGENTS_PILL_ID: - return SessionChatPillKind.Subagents; - default: - return undefined; +function getGitHubRepositoryHoverData(owner: string, repo: string, openerService: IOpenerService) { + const repository = URI.parse(`https://github.com/${owner}/${repo}`); + return { + repositoryHref: repository.toString(true), + onDidClickRepository: () => { void openerService.open(repository, { openExternal: true }); }, + }; +} + +/** Builds Agents Window pull request pill entries, enriching them when live details are available. */ +export function buildSessionPullRequestSections(pullRequests: readonly IResolvedSessionPullRequest[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] { + const entries = pullRequests.map(({ ref, pullRequest, icon, status }) => { + const title = pullRequest?.title ?? ref.title; + const label = title + ? localize('sessionChatPills.pullRequestWithTitle', "Pull Request #{0}: {1}", ref.number, title) + : localize('sessionChatPills.pullRequest', "Pull Request #{0}", ref.number); + const resolvedIcon = icon ?? computePullRequestIcon('open'); + const attention = getPullRequestAttention(resolvedIcon, status); + const state = pullRequest?.isDraft + ? 'draft' + : pullRequest?.state ?? ref.liveState ?? ref.state ?? getPullRequestStatusFromIcon(resolvedIcon) ?? 'open'; + const stateDescription = state === 'draft' + ? localize('sessionChatPills.pullRequestDraft', "draft") + : attention ?? ( + state === 'merged' + ? localize('sessionChatPills.pullRequestMerged', "merged") + : state === 'closed' + ? localize('sessionChatPills.pullRequestClosed', "closed") + : localize('sessionChatPills.pullRequestOpen', "open") + ); + return { + id: ref.uri.toString(), + label, + pillLabel: `#${ref.number}`, + icon: resolvedIcon, + toolbarActions: [toAction({ + id: `sessionChatPills.copyPullRequest.${ref.owner}.${ref.repo}.${ref.number}`, + label: localize('sessionChatPills.copyPullRequest', "Copy Pull Request URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => clipboardService.writeText(ref.uri.toString(true)), + })], + ...getChatPillResourceLocation(ref.uri, label), + ariaDescription: localize('sessionChatPills.pullRequestDescription', "{0}. {1}", stateDescription, ref.uri.toString(true)), + ...(pullRequest ? { + pillHover: { + element: () => createPullRequestHoverElement({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), + pullRequest, + }), + }, + } : {}), + open: () => { + if (session) { + sessionsService.setActive(session); + } + void commandService.executeCommand(OPEN_PULL_REQUEST_ACTION_ID, { pullRequest: ref }); + }, + } satisfies IChatPillEntry; + }); + return entries.length > 0 ? [{ title: localize('sessionChatPills.pullRequests', "Pull Requests"), entries }] : []; +} + +interface IResolvedSessionIssue { + readonly ref: IGitHubIssueRef; + readonly issue: IGitHubIssue | undefined; +} + +/** Builds Agents Window issue pill entries, enriching them when live details are available. */ +export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] { + const entries = issues.map(({ ref, issue }) => { + const label = issue?.title + ? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, issue.title) + : localize('sessionChatPills.issue', "Issue #{0}", ref.number); + return { + id: ref.uri.toString(), + label, + pillLabel: `#${ref.number}`, + icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), + toolbarActions: [toAction({ + id: `sessionChatPills.copyIssue.${ref.owner}.${ref.repo}.${ref.number}`, + label: localize('sessionChatPills.copyIssue', "Copy Issue URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => clipboardService.writeText(ref.uri.toString(true)), + })], + ...getChatPillResourceLocation(ref.uri, label), + ...(issue ? { + pillHover: { + element: () => createIssueHoverElement({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), + issue, + }), + }, + } : {}), + open: () => { + if (session) { + sessionsService.setActive(session); + } + void commandService.executeCommand(OPEN_ISSUE_ACTION_ID, { issue: ref }); + }, + } satisfies IChatPillEntry; + }); + return entries.length > 0 ? [{ title: localize('sessionChatPills.issues', "Issues"), entries }] : []; +} + +/** Returns the session-scoped changes counts represented by the shared Changes pill. */ +export function computeSessionInputPillStats(session: IActiveSession | undefined, changesStatsCache: ISessionChangesStatsCache, reader: IReader): IDiffStats { + if (session?.worktreePending?.read(reader)) { + return EMPTY_DIFF_STATS; } + const workspace = session?.workspace.read(reader); + const stats = session && workspace + ? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader) + : undefined; + return stats ?? EMPTY_DIFF_STATS; } /** @@ -93,20 +193,15 @@ export function getSessionChatPillKindForAction(actionId: string): SessionChatPi * the row floats over it. Derived from the row's `2px`/`4px` padding here plus a * 22px `.monaco-text-button.small` pill; keep in sync if either changes. */ -export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = 28; +export const SESSION_CHAT_INPUT_TOOLBAR_HEIGHT = CHAT_INPUT_PILLS_ROW_HEIGHT; /** A toolbar for session metadata, active-turn status, and background activity. */ export class SessionChatInputToolbar extends Disposable { readonly element: HTMLElement; - private readonly _content: HTMLElement; - private readonly _scrollable: DomScrollableElement; - private readonly _onDidChangeChatPetPlatform = this._register(new Emitter()); - readonly onDidChangeChatPetPlatform: Event = this._onDidChangeChatPetPlatform.event; - private readonly _onDidChangeVisibility = this._register(new Emitter()); - readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; - private _visible = false; - private readonly _pills: ChatPillsWidget; + readonly onDidChangeChatPetPlatform: Event; + readonly onDidChangeVisibility: Event; + private readonly _inputPills: ChatInputPills; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ private readonly _sessionOverride = observableValue(this, 'unset'); @@ -139,35 +234,32 @@ export class SessionChatInputToolbar extends Disposable { private readonly _customizationSections: IObservable; constructor( + compact: ChatPillsCompactMode, + focusFallback: (() => void) | undefined, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IClipboardService clipboardService: IClipboardService, + @ICommandService commandService: ICommandService, + @IGitHubService gitHubService: IGitHubService, @ISessionsService private readonly _sessionsService: ISessionsService, - @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, + @ISessionChangesService sessionChangesService: ISessionChangesService, + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + @IOpenerService openerService: IOpenerService, + @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this._content = $('.session-chat-input-toolbar-content'); - this._scrollable = this._register(new DomScrollableElement(this._content, { - horizontal: ScrollbarVisibility.Auto, - horizontalScrollbarSize: 6, - scrollYToX: true, - vertical: ScrollbarVisibility.Hidden, - })); - this.element = this._scrollable.getDomNode(); - this.element.classList.add('session-chat-input-toolbar', 'hidden'); - this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => { const debugData = this._debugData.read(reader); if (debugData) { return debugData.stats; } - const chat = this._chat.read(reader); - return chat ? computeTurnStats(chat, reader) : EMPTY_DIFF_STATS; + return computeSessionInputPillStats(this._session.read(reader), changesStatsCache, reader); }); const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); - const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); // The browsers pill already offers the pages it lists, so the artifacts and @@ -182,183 +274,101 @@ export class SessionChatInputToolbar extends Disposable { this._customizationSections = sessionCustomizations.sections; const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); - const model: IChatTurnPillsModel = { - stats: this._diffStats, - artifacts: this._artifactSections, - changesEnabled: pillsEnabled, - artifactsEnabled: pillsEnabled, - openChanges: () => this._debugData.get() ? undefined : this._openChanges(), - }; - - const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model)); - const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session)); - - // Every pill the session currently has data for, before the user's - // per-kind visibility choices are applied. - const candidatePills = derived(reader => { - const turn = turnPills.pills.read(reader); - return [ - ...metadataPills.pills.read(reader), - ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), - ]; + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, constObservable(true))); + const gitHubInfo = derived(this, reader => { + const session = this._session.read(reader); + const workspace = session?.workspace.read(reader); + return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); }); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); - - // `show-file-icons` lets a resource pill paint its themed file icon. - const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); - const sectionPill = (id: string, label: string, sections: IObservable, options: IChatDropdownPillOptions) => { - const action = this._register(new Action(id, label)); - return createChatSectionPill(action, sections, options, resourceLabels, instantiationService); - }; - - // Customization and reference sections are not gated at the source, so gate - // them here the way the two activity controls gate their own. Data presence - // follows the feature gate but not the user's visibility choice, otherwise - // hiding the pill would drop it from the menu that restores it. - const gated = (kind: SessionChatPillKind, source: IObservable) => { - const available = derived(reader => turnStatusPillsEnabled.read(reader) ? source.read(reader) : []); - return { - hasData: derived(reader => getChatPillEntries(available.read(reader)).length > 0), - sections: derived(reader => visibility.isVisible(kind, reader) ? available.read(reader) : []), - }; - }; - const customizations = gated(SessionChatPillKind.Customizations, this._customizationSections); - const references = gated(SessionChatPillKind.References, this._referenceSections); - - // Every section-backed pill lives in the same toolbar, so the whole row is - // one tab stop with arrow-key navigation instead of one stop per pill. - // These follow the candidate pills, which is what puts References directly - // after the artifacts pill: the two read as a pair, what the session made - // and what it points at. - const sectionPills: readonly { readonly pill: IObservable; readonly sections: IObservable }[] = [ - { pill: sectionPill(SESSION_REFERENCES_PILL_ID, localize('sessionChatPills.references', "References"), references.sections, sessionReferencesPillOptions), sections: references.sections }, - { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizations.sections, chatCustomizationPillOptions), sections: customizations.sections }, - { pill: sectionPill(SESSION_BROWSERS_PILL_ID, localize('sessionChatPills.browsers', "Browsers"), this._browsers.sections, sessionBrowsersPillOptions), sections: this._browsers.sections }, - { pill: sectionPill(SESSION_SUBAGENTS_PILL_ID, localize('sessionChatPills.subagents', "Subagents"), this._backgroundActivities.sections, sessionSubagentsPillOptions), sections: this._backgroundActivities.sections }, - ]; - - const pillsModel: IChatPillsModel = { - pills: derived(reader => [ - ...candidatePills.read(reader).filter(pill => { - const kind = getSessionChatPillKindForAction(pill.action.id); - return !kind || visibility.isVisible(kind, reader); - }), - ...sectionPills - .filter(entry => getChatPillEntries(entry.sections.read(reader)).length > 0) - .map(entry => entry.pill.read(reader)), - ]), - context: this._session, - }; - const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); - const pills = this._pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { - actionRunner, - // The row's visibility menu must be reachable by right-clicking a pill, - // not just the empty space beside it. - allowContextMenu: true, - })); - pills.element.classList.add('show-file-icons'); - this._content.appendChild(pills.element); - this._register(pills.onDidChangePills(() => this._onDidChangeChatPetPlatform.fire())); - - // Kinds the session reports data for; the others are listed in a separate group. - const kindsWithData = derived(reader => { - const kinds = new Set(); - for (const pill of candidatePills.read(reader)) { - const kind = getSessionChatPillKindForAction(pill.action.id); - if (kind) { - kinds.add(kind); - } - } - if (this._browsers.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Browsers); - } - if (this._backgroundActivities.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Subagents); - } - if (customizations.hasData.read(reader)) { - kinds.add(SessionChatPillKind.Customizations); - } - if (references.hasData.read(reader)) { - kinds.add(SessionChatPillKind.References); - } - return kinds; + const pullRequestRefs = derived(this, reader => getGitHubPullRequestRefs(gitHubInfo.read(reader))); + const agentMergeConfiguration = derived(this, reader => { + const session = this._session.read(reader); + return session ? getSessionAgentMergeConfigurationObservable(session, sessionsProvidersService, this._configurationService).read(reader) : undefined; }); - this._register(addDisposableListener(this._content, EventType.CONTEXT_MENU, (e: MouseEvent) => { - // The row owns its context menu, so never fall through to a native one. - e.preventDefault(); - e.stopPropagation(); - - const kinds = kindsWithData.get(); - if (kinds.size === 0) { + const pullRequestPresentation = this._register(new SessionPullRequestPresentationModel(pullRequestRefs, agentMergeConfiguration, gitHubService)); + const pullRequestSections = derived(this, reader => buildSessionPullRequestSections(pullRequestPresentation.pullRequests.read(reader), this._session.read(reader), commandService, clipboardService, openerService, this._sessionsService)); + const issueRefs = derived(this, reader => gitHubInfo.read(reader)?.issues ?? []); + const issues = derived(this, reader => issueRefs.read(reader).map(ref => { + const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + return { ref, issue: reference.object.issue.read(reader) }; + })); + const issuesActive = derived(this, reader => pillsEnabled.read(reader) && visibility.isVisible(SessionChatPillKind.Issues, reader)); + this._register(autorun(reader => { + if (!issuesActive.read(reader)) { return; } - - const anchor = new StandardMouseEvent(getWindow(this._content), e); - const targetPill = pills.getPill(e.target as HTMLElement | null); - const targetKind = targetPill ? getSessionChatPillKindForAction(targetPill.action.id) : undefined; - this._contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => { - const menu = getSessionChatPillMenu(kinds, visibility.readHiddenKinds(undefined), targetKind); - const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ - id: `sessions.chatPills.toggle.${entry.kind}`, - label: entry.label, - checked: entry.checked, - run: () => visibility.toggle(entry.kind), - }); - - const groups: IAction[][] = []; - if (menu.hide) { - const hide = menu.hide; - groups.push([toAction({ - id: `sessions.chatPills.hide.${hide.kind}`, - label: hide.label, - run: () => visibility.hide(hide.kind), - })]); + for (const ref of issueRefs.read(reader)) { + const reference = reader.store.add(gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + const model = reference.object; + model.refresh(); + const shouldPoll = derived(this, pollReader => model.issue.read(pollReader)?.state !== GitHubIssueState.Closed); + reader.store.add(autorun(pollReader => { + if (shouldPoll.read(pollReader)) { + pollReader.store.add(model.startPolling()); } - groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); - return Separator.join(...groups); - }, - }); - })); - - const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => { - this._scrollable.scanDomNode(); - this._onDidChangeChatPetPlatform.fire(); - })); - this._register(resizeObserver.observe(this._content)); - this._register(resizeObserver.observe(pills.element)); - this._register(this._scrollable.onScroll(e => { - if (e.scrollLeftChanged) { - this._onDidChangeChatPetPlatform.fire(); + })); } })); - this._register(addDisposableListener(this._content, EventType.FOCUS_IN, () => this._scrollable.scanDomNode())); - - this._register(autorun(reader => { - const anyVisible = pills.isVisible.read(reader); - // Stay rendered while hidden pills have data: in read-only chats the - // input part is only kept alive by a non-hidden persistent child. - const anyHidden = kindsWithData.read(reader).size > 0; - const visible = anyVisible || anyHidden; - this.element.classList.toggle('hidden', !visible); - // With no pill left to right-click, the row itself has to carry the - // visibility menu or the hidden pills could never be restored. - this.element.classList.toggle('empty', !anyVisible); - if (this._visible !== visible) { - this._visible = visible; - this._onDidChangeVisibility.fire(visible); + const issueSections = derived(this, reader => buildSessionIssueSections(issues.read(reader), this._session.read(reader), commandService, clipboardService, openerService, this._sessionsService)); + const issueIcon = derived(this, reader => { + const resolved = issues.read(reader); + if (resolved.length === 1) { + const issue = resolved[0].issue; + return issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined); } - this._scrollable.scanDomNode(); + return computeAggregateIssueIcon(resolved.map(({ issue }) => issue)); + }); + const changesLabel = derived(this, reader => { + const workspace = this._session.read(reader)?.workspace.read(reader); + const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); + return branch + ? localize('sessionChatPills.allChangesOnBranch', "All Changes ({0})", branch) + : localize('sessionChatPills.allChanges', "All Changes"); + }); + const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { + changes: { + stats: this._diffStats, + label: changesLabel, + open: () => { + const session = this._session.get(); + if (!session || this._debugData.get()) { + return; + } + layoutService.revealEditorPartExplicitly(); + void sessionChangesService.openChangesEditor(session.resource, { changesetSelection: { kind: 'id', id: undefined } }); + }, + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestPresentation.icon }, + issues: { sections: issueSections, icon: issueIcon }, + artifacts: { sections: this._artifactSections }, + references: { sections: this._referenceSections }, + customizations: { sections: this._customizationSections }, + browsers: { sections: this._browsers.sections }, + subagents: { sections: this._backgroundActivities.sections }, + }, SESSION_CHAT_PILL_KINDS)); + const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); + this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { + debugName: 'SessionChatInputToolbar.content', + compact, + enabled: pillsEnabled, + sources: constObservable(sources.sources), + offeredKinds: SESSION_CHAT_PILL_KINDS, + context: this._session, + actionRunner, + focusFallback, })); + this.element = this._inputPills.element; + this.element.classList.add('session-chat-input-toolbar'); + this.onDidChangeChatPetPlatform = this._inputPills.onDidChange; + this.onDidChangeVisibility = this._inputPills.onDidChangeVisibility; } get visible(): boolean { - return this._visible; + return this._inputPills.visible; } getChatPetPlatformElements(): readonly HTMLElement[] { - return this._pills.getPillElements(); + return this._inputPills.getPillElements(); } /** @@ -403,13 +413,4 @@ export class SessionChatInputToolbar extends Disposable { return active?.chats.read(reader).some(c => isEqual(c.resource, chatResource)) ? active : undefined; } - private _openChanges(): void { - const chat = this._chat.get(); - if (!chat) { - return; - } - - this._chatResponseFileChangesService.openChangesForRequest(chat.resource, undefined, { isLastTurn: true }); - } - } diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts index c14e893a4f7bf0..817e6b7c77dfe1 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -13,7 +13,6 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { ISessionChatCustomization, ISessionFolder, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; @@ -22,20 +21,6 @@ import type { IActiveSession } from '../../../services/sessions/common/sessionsM /** Action id of the customizations pill. */ export const SESSION_CUSTOMIZATIONS_PILL_ID = 'sessions.chatPills.customizations'; -/** Presentation of the customizations pill. */ -export const chatCustomizationPillOptions: IChatDropdownPillOptions = { - widgetId: 'chatCustomizations', - icon: Codicon.bookmark, - title: localize('chatCustomizations.title', "Customizations"), - summaryLabel: count => count === 1 - ? localize('chatCustomizations.countSingle', "1 Customization") - : localize('chatCustomizations.count', "{0} Customizations", count), - summaryAriaLabel: count => count === 1 - ? localize('chatCustomizations.showSingle', "Show 1 customization") - : localize('chatCustomizations.show', "Show {0} customizations", count), - singleEntry: ChatPillSingleEntry.Summary, -}; - const customizationIcons: ReadonlyMap = new Map([ [SessionCustomizationKind.Agent, Codicon.robot], [SessionCustomizationKind.Skill, Codicon.lightbulb], diff --git a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts deleted file mode 100644 index a86d658fcce767..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { getWindow } from '../../../../base/browser/dom.js'; -import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; -import { Event } from '../../../../base/common/event.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { IMenuService, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { ChatPillActionViewItem, IChatPill } from '../../../../workbench/browser/chatPills.js'; -import { Menus } from '../../../browser/menus.js'; -import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; -import { ISessionChangesStatsCache } from '../../../services/sessions/common/sessionChangesStatsCache.js'; - -/** Adapts the session metadata menu to observable chat-pill descriptors. */ -export class SessionMetadataPills extends Disposable { - - readonly pills: IObservable; - - private readonly _scopedInstantiationService: IInstantiationService; - - constructor( - container: HTMLElement, - session: IObservable, - @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, - @IContextKeyService contextKeyService: IContextKeyService, - @IInstantiationService instantiationService: IInstantiationService, - @IMenuService menuService: IMenuService, - @ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache, - ) { - super(); - - const scopedContextKeyService = this._register(contextKeyService.createScoped(container)); - this._scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( - [IContextKeyService, scopedContextKeyService], - [ISessionContext, new SessionContext(session)], - ))); - - this._register(autorun(reader => { - setSessionContextKeys(session.read(reader), scopedContextKeyService, reader, changesStatsCache); - })); - - const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true })); - const menuSignal = observableSignalFromEvent(this, Event.any( - menu.onDidChange, - Event.filter(this._actionViewItemService.onDidChange, menuId => menuId === Menus.SessionHeaderMeta), - )); - this.pills = derived(this, reader => { - menuSignal.read(reader); - return menu.getActions({ shouldForwardArgs: true }).flatMap(([group, actions]) => { - if (group !== 'navigation') { - return []; - } - return actions.map(action => ({ - action, - createActionViewItem: (options: IActionViewItemOptions) => { - const provider = this._actionViewItemService.lookUp( - Menus.SessionHeaderMeta, - action instanceof SubmenuItemAction ? action.item.submenu.id : action.id, - ); - return provider?.(action, options, this._scopedInstantiationService, getWindow(container).vscodeWindowId) - ?? this._scopedInstantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); - }, - } satisfies IChatPill)); - }); - }); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index a19980965ed8f2..46980c64880e5c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -81,8 +81,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.archiveSession', "To archive or mark one or more sessions as done, focus them in the Sessions list and invoke Archive or Mark as Done{0}.", ``)); content.push(localize('sessionsChat.deleteSession', "To permanently delete a session, open its context menu and choose Delete. This is destructive and cannot be undone.")); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); - content.push(localize('sessionsChat.viewAllChanges', "The session header shows the diff stats (lines added and removed) as a button. Activate it to open the multi-file diff editor for all of the session's changes{0}.", '')); - content.push(localize('sessionsChat.openPullRequest', "When the session is associated with a GitHub pull request, the session header shows the pull request number as a button. Activate it to open the pull request on GitHub{0}.", '')); + content.push(localize('sessionsChat.viewAllChanges', "Status pills above the chat input include the session's diff stats (lines added and removed). Activate the Changes pill to open the multi-file diff editor for all of the session's changes{0}.", '')); + content.push(localize('sessionsChat.openPullRequest', "When the session is associated with GitHub pull requests, a status pill above the chat input shows the pull request number or count. Activate it to open a single pull request or choose from the associated pull requests{0}.", '')); content.push(localize('sessionsChat.filesView', "Focus the Files Explorer view{0}.", '')); content.push(localize('sessionsChat.sessionsView', "Focus the Chat Sessions view{0}.", '')); content.push(localize('sessionsChat.customizations', "Focus the Chat Customizations view{0}.", ``)); diff --git a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts index 4dfe117efd3675..c5011ea2a1ecf7 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts @@ -3,21 +3,69 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; +import { IVoiceSessionController, VoiceNewSessionPreparationResult } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceInputUtils.js'; import { IVoiceModelSelectionResult, resolveVoiceModel } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, inheritableSessionTarget, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTINEL } from './newChatVoice.js'; +export async function prepareNewVoiceSession( + text: string, + sessionsService: ISessionsService, + sessionsManagementService: ISessionsManagementService, + voiceSessionController: IVoiceSessionController, + hasActiveComposer: () => boolean, + beginVoiceTransition: () => IDisposable, + logService: ILogService, +): Promise { + const activeSession = sessionsService.activeSession.get(); + const isQuickChat = activeSession?.isQuickChat?.get() ?? false; + const folderUri = isQuickChat ? undefined : activeSession?.workspace.get()?.uri; + const previousTarget = voiceSessionController.targetSession.get(); + const previousHadDraftTarget = voiceSessionController.hasDraftTarget.get(); + const restoreVoiceTarget = () => previousHadDraftTarget + ? voiceSessionController.setDraftTarget() + : voiceSessionController.setTargetSession(previousTarget); + const fail = (): VoiceNewSessionPreparationResult => { + restoreVoiceTarget(); + return 'failed'; + }; + voiceSessionController.setDraftTarget(); + const transition = beginVoiceTransition(); + try { + const result = await sessionsService.openNewSession({ + folderUri, + ...inheritableSessionTarget(sessionsManagementService, activeSession, folderUri), + }); + if (folderUri) { + if (!result.session) { + return fail(); + } + if (text.trim()) { + await sessionsManagementService.sendNewChatRequest(result.session, { query: text }); + return 'sent'; + } + return 'prepared'; + } + return sessionsService.activeSession.get() === undefined && hasActiveComposer() ? 'prepared' : fail(); + } catch (error) { + logService.error('Failed to prepare a new session for Voice Mode:', error); + return fail(); + } finally { + transition.dispose(); + } +} + /** * Bridges {@link IVoiceSessionController} to Agents window chat surfaces. * The shared controller uses `_chat.voice.*` commands; Agents hosts chats @@ -26,6 +74,7 @@ import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTI * Commands are registered only while `agents.voice.enabled` is set: * - `_chat.voice.acceptInput` injects transcribed text into the focused chat widget. * - `_chat.voice.getCurrentSession` reports the active session's chat resource. + * - `_chat.voice.prepareNewSession` creates a provider-backed draft for a new-session request. * - `_chat.voice.switchToSession` activates the session that owns a chat resource. * - `_chat.voice.activateSession` narrates a session's pending voice item on demand. */ @@ -42,6 +91,7 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, + @ILogService private readonly logService: ILogService, ) { super(); @@ -99,6 +149,18 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo return this.chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource?.toString(); })); + this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', (_accessor, text: string) => + prepareNewVoiceSession( + text, + this.sessionsService, + this.sessionsManagementService, + this.voiceSessionController, + () => !!this._activeComposerTarget(), + () => this.newChatVoiceTargetService.beginVoiceTransition(), + this.logService, + ) + )); + this._commandDisposables.add(CommandsRegistry.registerCommand('_chat.voice.selectModel', (_accessor, requestedModel: string): IVoiceModelSelectionResult => { const composer = this._activeComposerTarget(); const widget = composer ? undefined : this._activeSessionWidget() ?? this.chatWidgetService.lastFocusedWidget; @@ -344,10 +406,12 @@ export class SessionsVoiceNewComposerContribution extends Disposable implements voiceComposerCaptured = true; return; } - // A different welcome composer took over while voice is connected: the - // connection is bound to the previous surface and can't route here. - if (activeComposer && activeComposer !== voiceComposer && !activeComposer.routesWhileSessionActive) { - voiceSessionController.disconnect('internal'); + if (activeComposer && activeComposer !== voiceComposer) { + if (newChatVoiceTargetService.consumeVoiceTransition()) { + voiceComposer = activeComposer; + } else if (!activeComposer.routesWhileSessionActive) { + voiceSessionController.disconnect('internal'); + } } })); } 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 3271a229ba0adc..e5a1b5916e6477 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -99,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'; @@ -129,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'); @@ -147,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/newChatInputPaste.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts index ee0165d19cd72d..2177a38e162ff7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInputPaste.test.ts @@ -16,7 +16,6 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { DocumentPasteTriggerKind, ICustomEdit } from '../../../../../editor/common/languages.js'; -import { TrackedRangeStickiness } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { TestCodeEditorService } from '../../../../../editor/test/browser/editorTestServices.js'; @@ -26,8 +25,9 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { ChatDynamicVariableModel, dynamicVariableDecorationType } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; +import { ChatDynamicVariableModel } from '../../../../../workbench/contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { registerChatInputReferenceDecorationType } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.js'; import { PasteTextProvider, pastedTextArtifactDefaultMinLength } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { IChatRequestVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; @@ -65,9 +65,7 @@ suite('NewChatInputPasteTarget', () => { const uri = URI.from({ scheme: Schemas.sessionsChatInput, path: 'paste-test' }); const textModel = store.add(createTextModel('', null, undefined, uri)); const codeEditorService = store.add(new TestCodeEditorService(new TestThemeService())); - store.add(codeEditorService.registerDecorationType('test', dynamicVariableDecorationType, { - rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - })); + store.add(registerChatInputReferenceDecorationType(codeEditorService)); const editor = store.add(createTestCodeEditor(textModel, { serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), })); 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/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts index 90a05c5d3fe150..50c133463193f9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -271,11 +271,24 @@ suite('SessionBrowsersControl', () => { assert.deepStrictEqual({ visible: [...createControl({ browsers }, store).control.urls.get()], - hidden: [...createControl({ browsers, visible: false }, store).control.urls.get()], + hidden: { + urls: [...createControl({ browsers, visible: false }, store).control.urls.get()], + sections: sections(createControl({ browsers, visible: false }, store).control), + }, disabled: [...createControl({ browsers, enabled: false }, store).control.urls.get()], }, { visible: ['https://example.com/docs', 'https://preview.test/'], - hidden: [], + hidden: { + urls: [], + sections: [{ + title: 'Browsers', + entries: [ + { label: 'Docs', icon: 'globe' }, + { label: 'Subagent Preview', icon: 'globe' }, + { label: 'Blank', icon: 'globe' }, + ], + }], + }, disabled: [], }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index f8743c0f92e48f..0eb26479dae37e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -4,36 +4,183 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { isManagedHoverTooltipHTMLElement } from '../../../../../base/browser/ui/hover/hover.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, derived } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; -import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; -import { SessionChatPillKind } from '../../common/sessionChatPills.js'; -import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; -import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import type { IChatPillEntry } from '../../../../../workbench/browser/chatPills.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; +import { type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; +import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats } from '../../browser/sessionChatInputToolbar.js'; suite('SessionChatInputToolbar', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('maps turn-status and hosted pill actions onto togglable pill kinds', () => { - assert.deepStrictEqual([ - getSessionChatPillKindForAction(CHAT_TURN_CHANGES_PILL_ID), - getSessionChatPillKindForAction(VIEW_SESSION_CHANGES_COMMAND_ID), - getSessionChatPillKindForAction(CHAT_TURN_ARTIFACT_PILL_ID), - getSessionChatPillKindForAction(SESSION_CUSTOMIZATIONS_PILL_ID), - getSessionChatPillKindForAction(OPEN_PULL_REQUEST_ACTION_ID), - getSessionChatPillKindForAction(OPEN_ISSUE_ACTION_ID), - getSessionChatPillKindForAction(SESSION_BROWSERS_PILL_ID), - getSessionChatPillKindForAction(SESSION_SUBAGENTS_PILL_ID), - ], [ - SessionChatPillKind.Changes, - SessionChatPillKind.Changes, - SessionChatPillKind.Artifacts, - SessionChatPillKind.Customizations, - SessionChatPillKind.PullRequests, - SessionChatPillKind.Issues, - SessionChatPillKind.Browsers, - SessionChatPillKind.Subagents, - ]); + test('uses session-scoped changes rather than the last turn', () => { + const session = upcastPartial({ + sessionId: 'provider:session', + workspace: constObservable(upcastPartial({ folders: [] })), + changesets: constObservable([]), + changes: constObservable([{ + modifiedUri: URI.file('/session-change.ts'), + insertions: 10, + deletions: 4, + }]), + }); + const cache = upcastPartial({ + get: () => ({ files: 2, insertions: 8, deletions: 3 }), + }); + const stats = derived(reader => computeSessionInputPillStats(session, cache, reader)); + const pendingSession = upcastPartial({ + ...session, + worktreePending: constObservable(true), + }); + const pendingStats = derived(reader => computeSessionInputPillStats(pendingSession, cache, reader)); + + assert.deepStrictEqual({ + session: stats.get(), + pendingWorktree: pendingStats.get(), + }, { + session: { + files: 1, + insertions: 10, + deletions: 4, + }, + pendingWorktree: { + files: 0, + insertions: 0, + deletions: 0, + }, + }); + }); + + test('adds rich GitHub hovers only when live details are available', async () => { + const commandService = upcastPartial({ executeCommand: async () => undefined }); + const clipboardService = upcastPartial({ writeText: async () => { } }); + const openerService = upcastPartial({ open: async () => true }); + const sessionsService = upcastPartial({ setActive: () => { } }); + const pullRequestRef: IGitHubPullRequestRef = { + owner: 'microsoft', + repo: 'vscode', + number: 332982, + uri: URI.parse('https://github.com/microsoft/vscode/pull/332982'), + }; + const pullRequest: IGitHubPullRequest = { + number: pullRequestRef.number, + title: 'Restore rich pill hovers', + body: 'Provides detailed pull request context.', + state: GitHubPullRequestState.Open, + author: { login: 'octocat', avatarUrl: '' }, + headRef: 'feature/rich-hover', + headSha: 'abc123', + baseRef: 'main', + isDraft: false, + createdAt: '2026-09-03T09:00:00Z', + updatedAt: '2026-09-03T10:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', + }; + const issueRef: IGitHubIssueRef = { + owner: 'microsoft', + repo: 'vscode', + number: 42, + uri: URI.parse('https://github.com/microsoft/vscode/issues/42'), + }; + const issue: IGitHubIssue = { + number: issueRef.number, + title: 'Rich issue hover', + body: 'Provides detailed issue context.', + state: GitHubIssueState.Open, + stateReason: undefined, + author: { login: 'octocat', avatarUrl: '' }, + createdAt: '2026-09-03T09:00:00Z', + updatedAt: '2026-09-03T10:00:00Z', + closedAt: undefined, + }; + const pullRequestEntry = buildSessionPullRequestSections( + [{ ref: pullRequestRef, pullRequest, icon: Codicon.gitPullRequest, status: {} }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const unresolvedPullRequestEntry = buildSessionPullRequestSections( + [{ ref: pullRequestRef, pullRequest: undefined, icon: Codicon.gitPullRequest, status: {} }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const issueEntry = buildSessionIssueSections( + [{ ref: issueRef, issue }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + const unresolvedIssueEntry = buildSessionIssueSections( + [{ ref: issueRef, issue: undefined }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; + + const renderHover = async (entry: IChatPillEntry | undefined) => { + if (!isManagedHoverTooltipHTMLElement(entry?.pillHover)) { + return undefined; + } + return await entry.pillHover.element(CancellationToken.None); + }; + const pullRequestHover = await renderHover(pullRequestEntry); + const issueHover = await renderHover(issueEntry); + + assert.deepStrictEqual({ + pullRequest: { + className: pullRequestHover?.className, + repository: pullRequestHover?.querySelector('.sessions-pr-hover-repository')?.textContent, + title: pullRequestHover?.querySelector('.sessions-pr-hover-title')?.textContent, + description: pullRequestHover?.querySelector('.sessions-pr-hover-description-content')?.textContent, + branches: [...pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch') ?? []].map(element => element.textContent), + unresolvedHover: unresolvedPullRequestEntry?.pillHover, + }, + issue: { + className: issueHover?.className, + repository: issueHover?.querySelector('.sessions-issue-hover-repository')?.textContent, + title: issueHover?.querySelector('.sessions-issue-hover-title')?.textContent, + description: issueHover?.querySelector('.sessions-issue-hover-description-content')?.textContent, + unresolvedHover: unresolvedIssueEntry?.pillHover, + }, + }, { + pullRequest: { + className: 'sessions-pr-hover', + repository: 'microsoft/vscode', + title: 'Restore rich pill hovers', + description: 'Provides detailed pull request context.', + branches: ['main', 'feature/rich-hover'], + unresolvedHover: undefined, + }, + issue: { + className: 'sessions-issue-hover', + repository: 'microsoft/vscode#42', + title: 'Rich issue hover', + description: 'Provides detailed issue context.', + unresolvedHover: undefined, + }, + }); }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts index f2915ab8098062..72dbc9878f1ecc 100644 --- a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts @@ -6,14 +6,17 @@ import assert from 'assert'; import { Event } from '../../../../../base/common/event.js'; import { constObservable, ISettableObservable, 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'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; -import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { INewChatVoiceComposer, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; -import { SessionsVoiceNewComposerContribution } from '../../browser/voiceBridge.contribution.js'; +import { prepareNewVoiceSession, SessionsVoiceNewComposerContribution } from '../../browser/voiceBridge.contribution.js'; suite('SessionsVoiceNewComposerContribution', () => { @@ -33,12 +36,14 @@ suite('SessionsVoiceNewComposerContribution', () => { function createController(isConnected: ISettableObservable, isConnecting = constObservable(false)) { let disconnectCount = 0; + const hasDraftTarget = observableValue('hasDraftTarget', false); const controller = new class extends mock() { override readonly isConnected = isConnected; override readonly isConnecting = isConnecting; + override readonly hasDraftTarget = hasDraftTarget; override disconnect(): void { disconnectCount++; } }; - return { controller, getDisconnectCount: () => disconnectCount }; + return { controller, hasDraftTarget, getDisconnectCount: () => disconnectCount }; } function createTarget(): NewChatVoiceTargetService { @@ -69,6 +74,41 @@ suite('SessionsVoiceNewComposerContribution', () => { assert.strictEqual(getDisconnectCount(), 1); }); + test('keeps voice connected when voice creates a fresh session composer', () => { + const target = disposables.add(createTarget()); + const isConnected = observableValue('isConnected', false); + const { controller, getDisconnectCount } = createController(isConnected); + + const a = composer(); + disposables.add(target.registerComposer(a)); + isConnected.set(true, undefined); + disposables.add(new SessionsVoiceNewComposerContribution(controller, target)); + + const transition = target.beginVoiceTransition(); + const b = composer(); + disposables.add(target.registerComposer(b)); + transition.dispose(); + + assert.strictEqual(getDisconnectCount(), 0); + }); + + test('disconnects for an unrelated composer even when voice owns a draft', () => { + const target = disposables.add(createTarget()); + const isConnected = observableValue('isConnected', false); + const { controller, hasDraftTarget, getDisconnectCount } = createController(isConnected); + + const a = composer(); + disposables.add(target.registerComposer(a)); + hasDraftTarget.set(true, undefined); + isConnected.set(true, undefined); + disposables.add(new SessionsVoiceNewComposerContribution(controller, target)); + + const b = composer(); + disposables.add(target.registerComposer(b)); + + assert.strictEqual(getDisconnectCount(), 1); + }); + test('disconnects when a fresh welcome composer takes over a connecting voice session', () => { const target = disposables.add(createTarget()); const isConnected = observableValue('isConnected', false); @@ -116,4 +156,116 @@ suite('SessionsVoiceNewComposerContribution', () => { assert.strictEqual(getDisconnectCount(), 0); }); + + test('creates and sends a voice-requested session without waiting for its composer', async () => { + const workspace = new class extends mock() { + override readonly uri = URI.file('/workspace'); + }(); + const activeSession = new class extends mock() { + override readonly workspace = constObservable(workspace); + override readonly isQuickChat = constObservable(false); + }(); + const chat = new class extends mock() { }(); + const createdSession = new class extends mock() { + override readonly mainChat = constObservable(chat); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override async openNewSession() { + return { session: createdSession, trustDeclined: false }; + } + }(); + const sent: { session: ISession; query: string }[] = []; + const sessionsManagementService = new class extends mock() { + override isNewSessionTargetAvailable(): boolean { return false; } + override async sendNewChatRequest(session: ISession, options: { query: string }): Promise { + sent.push({ session, query: options.query }); + } + }(); + const targetSession = observableValue('targetSession', URI.parse('agent-host-copilot:/existing')); + const hasDraftTarget = observableValue('hasDraftTarget', false); + const voiceSessionController = new class extends mock() { + override readonly targetSession = targetSession; + override readonly hasDraftTarget = hasDraftTarget; + override setDraftTarget(): void { + targetSession.set(undefined, undefined); + hasDraftTarget.set(true, undefined); + } + override setTargetSession(resource: URI | undefined): void { + hasDraftTarget.set(false, undefined); + targetSession.set(resource, undefined); + } + }(); + + const result = await prepareNewVoiceSession( + 'refactor the upload service', + sessionsService, + sessionsManagementService, + voiceSessionController, + () => false, + () => ({ dispose() { } }), + new NullLogService(), + ); + + assert.deepStrictEqual({ + result, + hasDraftTarget: hasDraftTarget.get(), + sent, + }, { + result: 'sent', + hasDraftTarget: true, + sent: [{ session: createdSession, query: 'refactor the upload service' }], + }); + }); + + test('restores the previous voice target when new-session preparation is declined', async () => { + const previousTarget = URI.parse('agent-host-copilot:/existing'); + const workspace = new class extends mock() { + override readonly uri = URI.file('/workspace'); + }(); + const activeSession = new class extends mock() { + override readonly workspace = constObservable(workspace); + override readonly isQuickChat = constObservable(false); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession = constObservable(activeSession); + override async openNewSession() { + return { session: undefined, trustDeclined: true }; + } + }(); + const targetSession = observableValue('targetSession', previousTarget); + const hasDraftTarget = observableValue('hasDraftTarget', false); + const voiceSessionController = new class extends mock() { + override readonly targetSession = targetSession; + override readonly hasDraftTarget = hasDraftTarget; + override setDraftTarget(): void { + targetSession.set(undefined, undefined); + hasDraftTarget.set(true, undefined); + } + override setTargetSession(resource: URI | undefined): void { + hasDraftTarget.set(false, undefined); + targetSession.set(resource, undefined); + } + }(); + + const result = await prepareNewVoiceSession( + 'refactor the upload service', + sessionsService, + new class extends mock() { }(), + voiceSessionController, + () => false, + () => ({ dispose() { } }), + new NullLogService(), + ); + + assert.deepStrictEqual({ + result, + targetSession: targetSession.get()?.toString(), + hasDraftTarget: hasDraftTarget.get(), + }, { + result: 'failed', + targetSession: previousTarget.toString(), + hasDraftTarget: false, + }); + }); }); 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/issueActions.ts b/src/vs/sessions/contrib/github/browser/issueActions.ts index 5900c93e9927fe..50ffc23f741d0c 100644 --- a/src/vs/sessions/contrib/github/browser/issueActions.ts +++ b/src/vs/sessions/contrib/github/browser/issueActions.ts @@ -3,55 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IManagedHoverContent, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { $ } from '../../../../base/browser/dom.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { arrayEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -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'; -import { SessionHasIssuesContext } from '../../../common/contextkeys.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IGitHubIssueRef, ISession } from '../../../services/sessions/common/session.js'; -import { computeAggregateIssueIcon, computeIssueIcon, GitHubIssueState, IGitHubIssue, OPEN_ISSUE_ACTION_ID } from '../common/types.js'; -import { IGitHubService } from './githubService.js'; -import { createIssueHoverElement } from './issueHover.js'; -import { GitHubReferenceList, IGitHubReferenceListEntry } from './githubReferenceList.js'; - -/** A session issue paired with its live details, once they have been fetched. */ -interface IResolvedSessionIssue { - readonly ref: IGitHubIssueRef; - readonly issue: IGitHubIssue | undefined; -} - -interface IIssueListEntry extends IGitHubReferenceListEntry { - readonly owner: string; - readonly repo: string; - readonly uri: URI; -} - -// --- Open Issue action +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { OPEN_ISSUE_ACTION_ID } from '../common/types.js'; const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; const openIssueWebviewPath = '/open-issue-webview'; @@ -82,13 +46,6 @@ class OpenIssueAction extends Action2 { title: localize2('agentSessions.openIssue', 'Open Issue'), icon: Codicon.issues, f1: false, - // Metadata pill shown after pull requests. - menu: [{ - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 2, - when: SessionHasIssuesContext - }], }); } @@ -97,7 +54,6 @@ class OpenIssueAction extends Action2 { const sessionsService = accessor.get(ISessionsService); const extensionService = accessor.get(IExtensionService); const urlService = accessor.get(IURLService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const issue = isIssueActionContext(target) ? target.issue : getSessionIssues(target)[0]; if (!issue) { @@ -128,10 +84,6 @@ function getSessionIssues(session: ISession | undefined): readonly IGitHubIssueR return session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.issues ?? []; } -/** - * Copies the URL of an issue. Invoked with an {@link IssueActionContext} from the issue pill, - * so the issue that was hovered or picked is the one that gets copied. - */ class CopyIssueUrlAction extends Action2 { static readonly ID = 'workbench.agentSessions.action.copyIssueUrl'; @@ -146,7 +98,6 @@ class CopyIssueUrlAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | IssueActionContext): Promise { const clipboardService = accessor.get(IClipboardService); const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const issue = isIssueActionContext(target) ? target.issue : getSessionIssues(target)[0]; if (!issue) { @@ -157,262 +108,3 @@ class CopyIssueUrlAction extends Action2 { } } registerAction2(CopyIssueUrlAction); - -// --- Open Issue action view item - -/** - * Renders the GitHub issues a session references as a single metadata pill. - * - * A session that references one issue shows `#` and hovers to the issue's details. - * A session that references several shows ` issues` and opens a picker on click, since the - * pill then stands for a set rather than a single target. Either way the icon reflects the - * aggregate live state: open wins over closed, and closed-as-completed wins over - * closed as not planned. - * - * The issues are read from the {@link ISessionContext} so the correct per-session issues are - * shown even when several session views are visible at once. - */ -export class OpenIssueActionViewItem extends ChatPillActionViewItem { - - private readonly _issueRefsObs: IObservable; - private readonly _issuesObs: IObservable; - private readonly _issueList = this._register(new MutableDisposable>()); - private _issuePickerVisible = false; - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ICommandService private readonly _commandService: ICommandService, - @IGitHubService private readonly _gitHubService: IGitHubService, - @IOpenerService private readonly _openerService: IOpenerService, - @IHoverService private readonly _hoverService: IHoverService, - ) { - super(undefined, action, options); - - this._issueRefsObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number) - }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.issues ?? []; - }); - - this._issuesObs = derived(reader => this._issueRefsObs.read(reader).map(ref => { - const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); - return { ref, issue: reference.object.issue.read(reader) }; - })); - - // Keep the issue models warm for as long as the pill is rendered so the icon - // reflects the live state. This autorun depends only on the issue *identities*, - // so a state change does not release and re-acquire every model. - this._register(autorun(reader => { - for (const ref of this._issueRefsObs.read(reader)) { - const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); - const model = reference.object; - model.refresh(); - - // A closed issue is effectively final, so it is only fetched once. Gate the - // repeating loop on a stable boolean so poll results don't toggle it. - const shouldPoll = derived(this, pollReader => model.issue.read(pollReader)?.state !== GitHubIssueState.Closed); - reader.store.add(autorun(pollReader => { - if (shouldPoll.read(pollReader)) { - pollReader.store.add(model.startPolling()); - } - })); - } - })); - - this._register(autorun(reader => { - const issues = this._issuesObs.read(reader); - this._issueList.value?.update(this._getIssueListEntries(issues)); - this.updateLabel(); - this.updateTooltip(); - })); - } - - protected override hasOpenDropdown(): boolean { - return this._issuePickerVisible; - } - - protected override onDidClickButton(): void { - if (this.hasOpenDropdown()) { - this._hoverService.hideHover(true); - return; - } - - const issues = this._issuesObs.get(); - if (issues.length > 1) { - this._showIssuePicker(issues); - return; - } - - super.onDidClickButton(); - } - - protected override getIconElement(): HTMLElement | undefined { - const icon = this._computeIcon(); - const iconElement = $(`span.chat-pill-icon${ThemeIcon.asCSSSelector(icon)}`, { 'aria-hidden': 'true' }); - if (icon.color) { - // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon - // { color: inherit !important }`, so the glyph reflects the live issue state color. - iconElement.style.setProperty('color', asCssVariable(icon.color.id), 'important'); - } - return iconElement; - } - - protected override getLabelText(): string { - const issues = this._issuesObs.get(); - if (issues.length === 0) { - return ''; - } - return issues.length === 1 - ? `#${issues[0].ref.number}` - : localize('agentSessions.openIssue.count', "{0} issues", issues.length); - } - - protected override getHoverContents(): IManagedHoverContent | undefined { - const issues = this._issuesObs.get(); - if (issues.length !== 1) { - return this.getTooltip(); - } - - const { ref, issue } = issues[0]; - return { - element: () => createIssueHoverElement({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - repositoryHref: this._getRepositoryUri(ref).toString(true), - issue, - onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(ref), { openExternal: true }), - }), - }; - } - - protected override getHoverOptions(): IManagedHoverOptions | undefined { - const issues = this._issuesObs.get(); - if (issues.length !== 1) { - return undefined; - } - - const ref = issues[0].ref; - return { - actions: [{ - commandId: CopyIssueUrlAction.ID, - label: localize('agentSessions.issueHover.copyLink', "Copy Link"), - iconClass: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyIssueLink(ref), - }], - }; - } - - protected override getTooltip(): string { - const issues = this._issuesObs.get(); - if (issues.length > 1) { - return localize('agentSessions.openIssue.tooltipMany', "Show the {0} Issues Referenced by This Session", issues.length); - } - const number = issues[0]?.ref.number; - return number !== undefined - ? localize('agentSessions.openIssue.tooltipWithNumber', "Open Issue #{0}", number) - : localize('agentSessions.openIssue.tooltip', "Open Issue"); - } - - private _computeIcon(): ThemeIcon { - const issues = this._issuesObs.get(); - if (issues.length === 1) { - const issue = issues[0].issue; - return issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined); - } - return computeAggregateIssueIcon(issues.map(({ issue }) => issue)); - } - - private _copyIssueLink(ref: IGitHubIssueRef): void { - this._commandService.executeCommand(CopyIssueUrlAction.ID, new IssueActionContext(ref)); - } - - /** - * Shows the referenced issues below the pill. A sticky hover is used rather than a - * context menu because menu items render their icon on the label element, which would - * lose the per-issue state color. - */ - private _showIssuePicker(issues: readonly IResolvedSessionIssue[]): void { - const target = this.button?.element; - if (!target) { - return; - } - - const list = this._issueList.value = new GitHubReferenceList(this._getIssueListEntries(issues), entry => { - this._hoverService.hideHover(); - this.actionRunner.run(this._action, new IssueActionContext(entry)); - }); - - this._issuePickerVisible = true; - const hover = this._hoverService.showInstantHover({ - content: list.element, - target, - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true, hideOnKeyDown: true }, - appearance: { showPointer: false, skipFadeInAnimation: true }, - trapFocus: true, - onDidHide: () => { - this._issuePickerVisible = false; - if (this._issueList.value === list) { - this._issueList.clear(); - } - }, - }, true); - if (!hover) { - this._issuePickerVisible = false; - this._issueList.clear(); - } - } - - private _getIssueListEntries(issues: readonly IResolvedSessionIssue[]): readonly IIssueListEntry[] { - return issues.map(({ ref, issue }) => ({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - title: issue?.title, - icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), - uri: ref.uri, - toolbarActions: [toAction({ - id: CopyIssueUrlAction.ID, - label: localize('agentSessions.issueList.copyLink', "Copy Issue Link"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyIssueLink(ref), - })], - })); - } - - private _getRepositoryUri(ref: IGitHubIssueRef): URI { - return URI.parse(`https://github.com/${ref.owner}/${ref.repo}`); - } -} - -/** - * Registers the {@link OpenIssueActionViewItem} for the issue metadata pill. - */ -class OpenIssueActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.openIssueActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenIssueAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(OpenIssueActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -registerWorkbenchContribution2(OpenIssueActionViewItemContribution.ID, OpenIssueActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/issueHover.ts b/src/vs/sessions/contrib/github/browser/issueHover.ts index 5a264e130bdb1e..cd572fceae2cdd 100644 --- a/src/vs/sessions/contrib/github/browser/issueHover.ts +++ b/src/vs/sessions/contrib/github/browser/issueHover.ts @@ -17,7 +17,7 @@ export interface IIssueHoverData { readonly repo: string; readonly number: number; readonly repositoryHref: string; - readonly issue: IGitHubIssue | undefined; + readonly issue: IGitHubIssue; readonly onDidClickRepository?: () => void; } @@ -39,14 +39,14 @@ export function createIssueHoverElement(data: IIssueHoverData): HTMLElement { }; } - const date = formatIssueDate(data.issue?.createdAt); + const date = formatIssueDate(data.issue.createdAt); if (date) { append(header, $('span.sessions-issue-hover-date', undefined, localize('agentSessions.issueHover.onDate', "on {0}", date))); } - append(hoverElement, $('.sessions-issue-hover-title', undefined, data.issue?.title || localize('agentSessions.issueHover.titleFallback', "Issue #{0}", data.number))); + append(hoverElement, $('.sessions-issue-hover-title', undefined, data.issue.title || localize('agentSessions.issueHover.titleFallback', "Issue #{0}", data.number))); - const body = data.issue?.body.trim() || localize('agentSessions.issueHover.bodyFallback', "No description provided."); + const body = data.issue.body.trim() || localize('agentSessions.issueHover.bodyFallback', "No description provided."); const description = append(hoverElement, $('.sessions-issue-hover-description')); append(description, $('.sessions-issue-hover-description-content', undefined, body)); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index f6e92582361788..f0a153eeff94b9 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -3,71 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IManagedHoverContent, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { $ } from '../../../../base/browser/dom.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { arrayEquals } from '../../../../base/common/equals.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { 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'; import { SessionHasPullRequestContext } from '../../../common/contextkeys.js'; -import { getAgentMergeAwarePullRequestIcon, getSessionAgentMergeConfigurationObservable, ISessionAgentMergeConfiguration } from '../../../browser/sessionAgentMerge.js'; -import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { getGitHubPullRequestRefs, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; -import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus, OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; -import { IGitHubService } from './githubService.js'; -import { GitHubReferenceList, IGitHubReferenceListEntry } from './githubReferenceList.js'; -import { createPullRequestHoverElement } from './pullRequestHover.js'; -import { IPullRequestIconCache } from './pullRequestIconCache.js'; -import { computePullRequestIconStatus } from './pullRequestIconStatus.js'; - -interface IResolvedSessionPullRequest { - readonly ref: IGitHubPullRequestRef; - readonly pullRequest: IGitHubPullRequest | undefined; - readonly icon: ThemeIcon | undefined; - readonly status: IPullRequestIconStatus; -} - -interface IPullRequestIdentity { - readonly owner: string; - readonly repo: string; - readonly number: number; -} - -interface IPullRequestListEntry extends IGitHubReferenceListEntry { - readonly owner: string; - readonly repo: string; - readonly uri: URI; -} - -// --- Open Pull Request action - -const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; -const openPullRequestWebviewPath = '/open-pull-request-webview'; +import { OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; class PullRequestActionContext { constructor(readonly pullRequest: IGitHubPullRequestRef) { } @@ -95,13 +43,7 @@ class OpenPullRequestAction extends Action2 { title: localize2('agentSessions.openPullRequest', "Open Pull Request"), icon: Codicon.gitPullRequest, f1: false, - // Metadata pill that summarizes the session's pull requests. menu: [{ - id: Menus.SessionHeaderMeta, - group: 'navigation', - order: 1, - when: SessionHasPullRequestContext - }, { id: Menus.SessionItemContextMenu, group: '2_pullRequest', order: 0, @@ -112,38 +54,18 @@ class OpenPullRequestAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); if (!pullRequest) { 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); -// --- Copy Pull Request URL action - function getSessionPullRequest(session: ISession | undefined): IGitHubPullRequestRef | undefined { const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); return getGitHubPullRequestRefs(gitHubInfo)[0]; @@ -169,7 +91,6 @@ class CopyPullRequestUrlAction extends Action2 { override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const clipboardService = accessor.get(IClipboardService); const sessionsService = accessor.get(ISessionsService); - const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); const pullRequest = isPullRequestActionContext(target) ? target.pullRequest : getSessionPullRequest(target); if (!pullRequest) { @@ -180,335 +101,3 @@ class CopyPullRequestUrlAction extends Action2 { } } registerAction2(CopyPullRequestUrlAction); - -// --- Open Pull Request action view item (session header pull request pill) - -/** - * Renders the session's pull requests as a single header pill and opens a picker for history. - */ -export class OpenPullRequestActionViewItem extends ChatPillActionViewItem { - - private readonly _pullRequestRefsObs: IObservable; - private readonly _pullRequestIdentitiesObs: IObservable; - private readonly _pullRequestsObs: IObservable; - private readonly _agentMergeConfiguration: IObservable; - private readonly _icon: IObservable; - private readonly _pullRequestList = this._register(new MutableDisposable>()); - - constructor( - action: MenuItemAction, - options: IActionViewItemOptions, - @ISessionContext sessionContext: ISessionContext, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IConfigurationService configurationService: IConfigurationService, - @ICommandService private readonly _commandService: ICommandService, - @IGitHubService private readonly _gitHubService: IGitHubService, - @IPullRequestIconCache private readonly _pullRequestIconCache: IPullRequestIconCache, - @IOpenerService private readonly _openerService: IOpenerService, - @IHoverService private readonly _hoverService: IHoverService, - ) { - super(undefined, action, options); - - this._agentMergeConfiguration = derived(this, reader => { - const session = sessionContext.session.read(reader); - return session ? getSessionAgentMergeConfigurationObservable(session, sessionsProvidersService, configurationService).read(reader) : undefined; - }); - this._pullRequestRefsObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => - x.owner === y.owner && - x.repo === y.repo && - x.number === y.number && - isEqual(x.uri, y.uri) && - (x.icon === y.icon || (!!x.icon && !!y.icon && ThemeIcon.isEqual(x.icon, y.icon)))) - }, reader => { - const session = sessionContext.session.read(reader); - const workspace = session?.workspace.read(reader); - const gitHubInfo = workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); - return getGitHubPullRequestRefs(gitHubInfo); - }); - - this._pullRequestIdentitiesObs = derivedOpts({ - owner: this, - equalsFn: (a, b) => arrayEquals(a, b, (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number) - }, reader => this._pullRequestRefsObs.read(reader).map(({ owner, repo, number }) => ({ owner, repo, number }))); - - this._pullRequestsObs = derived(reader => this._pullRequestRefsObs.read(reader).map((ref, index) => { - const reference = reader.store.add(this._gitHubService.createPullRequestModelReference(ref.owner, ref.repo, ref.number)); - const pullRequest = reference.object.pullRequest.read(reader); - const status = pullRequest ? computePullRequestIconStatus(reader, this._gitHubService, ref.owner, ref.repo, pullRequest) : {}; - const icon = pullRequest - ? computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state, status) - : this._pullRequestIconCache.get(ref.uri.toString()) ?? ref.icon ?? (index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined); - if (pullRequest && icon) { - this._pullRequestIconCache.set(ref.uri.toString(), icon); - } - return { - ref, - pullRequest, - icon, - status, - }; - })); - this._icon = derived(this, reader => { - const agentMerge = this._agentMergeConfiguration.read(reader); - const icons = this._pullRequestsObs.read(reader).map(pullRequest => - pullRequest.icon ? getAgentMergeAwarePullRequestIcon(pullRequest.icon, agentMerge, pullRequest.status) : undefined); - return getHighestPriorityPullRequestIcon(icons) ?? Codicon.gitPullRequest; - }); - - this._register(autorun(reader => { - for (const identity of this._pullRequestIdentitiesObs.read(reader)) { - const reference = reader.store.add(this._gitHubService.createPullRequestModelReference(identity.owner, identity.repo, identity.number)); - const model = reference.object; - model.refresh(); - - const shouldPoll = derived(this, pollReader => { - const state = model.pullRequest.read(pollReader)?.state; - return state === undefined || state === GitHubPullRequestState.Open; - }); - reader.store.add(autorun(pollReader => { - if (shouldPoll.read(pollReader)) { - pollReader.store.add(model.startPolling()); - } - })); - - reader.store.add(autorun(statusReader => { - const pullRequest = model.pullRequest.read(statusReader); - if (!pullRequest || pullRequest.isDraft || pullRequest.state !== GitHubPullRequestState.Open) { - return; - } - - const ciReference = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(identity.owner, identity.repo, identity.number, pullRequest.headSha)); - ciReference.object.refresh(); - statusReader.store.add(ciReference.object.startPolling()); - - const reviewThreadsReference = statusReader.store.add(this._gitHubService.createPullRequestReviewThreadsModelReference(identity.owner, identity.repo, identity.number)); - reviewThreadsReference.object.refresh(); - statusReader.store.add(reviewThreadsReference.object.startPolling()); - })); - } - })); - - this._register(autorun(reader => { - const pullRequests = this._pullRequestsObs.read(reader); - this._icon.read(reader); - this._pullRequestList.value?.update(this._getPullRequestListEntries(pullRequests)); - this.updateLabel(); - this.updateTooltip(); - })); - } - - protected override hasOpenDropdown(): boolean { - return !!this._pullRequestList.value; - } - - protected override onDidClickButton(): void { - if (this.hasOpenDropdown()) { - this._hoverService.hideHover(true); - return; - } - - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length > 1) { - this._showPullRequestPicker(pullRequests); - return; - } - - super.onDidClickButton(); - } - - protected override getIconElement(): HTMLElement | undefined { - const icon = this._icon.get(); - const iconElement = $(`span.chat-pill-icon${ThemeIcon.asCSSSelector(icon)}`, { 'aria-hidden': 'true' }); - if (icon.color) { - // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon - // { color: inherit !important }`, so the glyph reflects the live PR state color. - iconElement.style.setProperty('color', asCssVariable(icon.color.id), 'important'); - } - return iconElement; - } - - protected override getLabelText(): string { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length === 0) { - return ''; - } - return pullRequests.length === 1 - ? `#${pullRequests[0].ref.number}` - : localize('agentSessions.openPullRequest.count', "{0} Pull Requests", pullRequests.length); - } - - protected override getHoverContents(): IManagedHoverContent | undefined { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length !== 1) { - return this.getTooltip(); - } - - const { ref, pullRequest } = pullRequests[0]; - return { - element: () => createPullRequestHoverElement({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - repositoryHref: this._getRepositoryUri(ref).toString(true), - pullRequest, - onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(ref), { openExternal: true }), - }), - }; - } - - protected override getHoverOptions(): IManagedHoverOptions | undefined { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length !== 1) { - return undefined; - } - - const ref = pullRequests[0].ref; - return { - actions: [{ - commandId: CopyPullRequestUrlAction.ID, - label: localize('agentSessions.pullRequestHover.copyLink', "Copy Link"), - iconClass: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyPullRequestLink(ref), - }], - }; - } - - protected override getTooltip(): string { - const pullRequests = this._pullRequestsObs.get(); - if (pullRequests.length > 1) { - return localize('agentSessions.openPullRequest.tooltipMany', "Show the {0} Pull Requests Associated with This Session", pullRequests.length); - } - const number = pullRequests[0]?.ref.number; - return number !== undefined - ? localize('agentSessions.openPullRequest.tooltipWithNumber', "Open Pull Request #{0}", number) - : localize('agentSessions.openPullRequest.tooltip', "Open Pull Request"); - } - - private _copyPullRequestLink(ref: IGitHubPullRequestRef): void { - this._commandService.executeCommand(CopyPullRequestUrlAction.ID, new PullRequestActionContext(ref)); - } - - private _showPullRequestPicker(pullRequests: readonly IResolvedSessionPullRequest[]): void { - const target = this.button?.element; - if (!target) { - return; - } - - const list = this._pullRequestList.value = new GitHubReferenceList(this._getPullRequestListEntries(pullRequests), entry => { - this._hoverService.hideHover(); - this.actionRunner.run(this._action, new PullRequestActionContext(entry)); - }); - list.element.onkeydown = event => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - this._hoverService.hideHover(); - } - }; - - const hover = this._hoverService.showInstantHover({ - content: list.element, - target, - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true, hideOnKeyDown: false }, - appearance: { showPointer: false, skipFadeInAnimation: true }, - trapFocus: true, - onDidHide: () => { - if (this._pullRequestList.value === list) { - this._pullRequestList.clear(); - } - }, - }, true); - if (!hover) { - this._pullRequestList.clear(); - } - } - - private _getRepositoryUri(ref: { readonly owner: string; readonly repo: string }): URI { - return URI.parse(`https://github.com/${ref.owner}/${ref.repo}`); - } - - private _getPullRequestListEntries(pullRequests: readonly IResolvedSessionPullRequest[]): readonly IPullRequestListEntry[] { - return pullRequests.map(({ ref, pullRequest, icon, status }) => ({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - title: pullRequest?.title, - icon: icon ?? Codicon.gitPullRequest, - uri: ref.uri, - ariaLabel: getPullRequestAriaLabel(ref, pullRequest, status), - toolbarActions: [toAction({ - id: CopyPullRequestUrlAction.ID, - label: localize('agentSessions.pullRequestList.copyLink', "Copy Pull Request Link"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._copyPullRequestLink(ref), - })], - })); - } -} - -function getPullRequestAriaLabel(ref: IGitHubPullRequestRef, pullRequest: IGitHubPullRequest | undefined, status: IPullRequestIconStatus): string { - let kind: string; - if (pullRequest?.isDraft) { - kind = localize('agentSessions.pullRequestList.draft', "Draft Pull Request"); - } else { - switch (pullRequest?.state) { - case GitHubPullRequestState.Open: - kind = localize('agentSessions.pullRequestList.open', "Open Pull Request"); - break; - case GitHubPullRequestState.Merged: - kind = localize('agentSessions.pullRequestList.merged', "Merged Pull Request"); - break; - case GitHubPullRequestState.Closed: - kind = localize('agentSessions.pullRequestList.closed', "Closed Pull Request"); - break; - default: - kind = localize('agentSessions.pullRequestList.pullRequest', "Pull Request"); - } - } - - const baseLabel = pullRequest?.title - ? localize('agentSessions.pullRequestList.labelWithTitle', "{0} #{1}: {2}", kind, ref.number, pullRequest.title) - : localize('agentSessions.pullRequestList.label', "{0} #{1}", kind, ref.number); - - let attention: string | undefined; - if (status.hasFailingChecks && status.hasUnresolvedComments) { - attention = localize('agentSessions.pullRequestList.failingChecksAndUnresolvedComments', "failing checks and unresolved comments"); - } else if (status.hasFailingChecks) { - attention = localize('agentSessions.pullRequestList.failingChecks', "failing checks"); - } else if (status.hasUnresolvedComments) { - attention = localize('agentSessions.pullRequestList.unresolvedComments', "unresolved comments"); - } - - return attention - ? localize('agentSessions.pullRequestList.labelWithAttention', "{0}, {1}", baseLabel, attention) - : baseLabel; -} - -/** - * Registers the {@link OpenPullRequestActionViewItem} for the pull-request metadata pill. - */ -class OpenPullRequestActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.openPullRequestActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Announce the factory after registration so existing metadata pills re-render. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenPullRequestAction.ID, (action, options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(OpenPullRequestActionViewItem, action, options); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -registerWorkbenchContribution2(OpenPullRequestActionViewItemContribution.ID, OpenPullRequestActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/pullRequestHover.ts b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts index d09b65699e74c8..2b74d06a62d887 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestHover.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestHover.ts @@ -17,7 +17,7 @@ export interface IPullRequestHoverData { readonly repo: string; readonly number: number; readonly repositoryHref: string; - readonly pullRequest: IGitHubPullRequest | undefined; + readonly pullRequest: IGitHubPullRequest; readonly onDidClickRepository?: () => void; } @@ -39,21 +39,21 @@ export function createPullRequestHoverElement(data: IPullRequestHoverData): HTML }; } - const date = formatPullRequestDate(data.pullRequest?.createdAt); + const date = formatPullRequestDate(data.pullRequest.createdAt); if (date) { append(header, $('span.sessions-pr-hover-date', undefined, localize('agentSessions.pullRequestHover.onDate', "on {0}", date))); } - append(hoverElement, $('.sessions-pr-hover-title', undefined, data.pullRequest?.title || localize('agentSessions.pullRequestHover.titleFallback', "Pull Request #{0}", data.number))); + append(hoverElement, $('.sessions-pr-hover-title', undefined, data.pullRequest.title || localize('agentSessions.pullRequestHover.titleFallback', "Pull Request #{0}", data.number))); - const body = data.pullRequest?.body.trim() || localize('agentSessions.pullRequestHover.bodyFallback', "No description provided."); + const body = data.pullRequest.body.trim() || localize('agentSessions.pullRequestHover.bodyFallback', "No description provided."); const description = append(hoverElement, $('.sessions-pr-hover-description')); append(description, $('.sessions-pr-hover-description-content', undefined, body)); const branchRow = append(hoverElement, $('.sessions-pr-hover-branches')); - appendBranchPill(branchRow, data.pullRequest?.baseRef || localize('agentSessions.pullRequestHover.baseFallback', "target")); + appendBranchPill(branchRow, data.pullRequest.baseRef || localize('agentSessions.pullRequestHover.baseFallback', "target")); append(branchRow, $('span.sessions-pr-hover-branch-arrow', undefined, '\u2190')); - appendBranchPill(branchRow, data.pullRequest?.headRef || localize('agentSessions.pullRequestHover.headFallback', "source")); + appendBranchPill(branchRow, data.pullRequest.headRef || localize('agentSessions.pullRequestHover.headFallback', "source")); return hoverElement; } diff --git a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts index 32d7f44126418e..72102ee49afe85 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestIconStatus.ts @@ -3,13 +3,59 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IReaderWithStore } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, IReaderWithStore } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; +import { getAgentMergeAwarePullRequestIcon, ISessionAgentMergeConfiguration } from '../../../browser/sessionAgentMerge.js'; +import { getHighestPriorityPullRequestIcon, IGitHubPullRequestRef } from '../../../services/sessions/common/session.js'; import { computePullRequestIcon, GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { IPullRequestIconCache } from './pullRequestIconCache.js'; +export interface IResolvedSessionPullRequest { + readonly ref: IGitHubPullRequestRef; + readonly pullRequest: IGitHubPullRequest | undefined; + readonly icon: ThemeIcon | undefined; + readonly status: IPullRequestIconStatus; +} + +/** + * Resolves live pull-request presentation data shared by session surfaces. + * Polling is owned centrally by the GitHub polling contribution. + */ +export class SessionPullRequestPresentationModel extends Disposable { + + readonly pullRequests: IObservable; + readonly icon: IObservable; + + constructor( + pullRequestRefs: IObservable, + agentMergeConfiguration: IObservable, + gitHubService: IGitHubService, + ) { + super(); + + this.pullRequests = derived(this, reader => pullRequestRefs.read(reader).map((ref, index) => { + const reference = reader.store.add(gitHubService.createPullRequestModelReference(ref.owner, ref.repo, ref.number)); + const pullRequest = reference.object.pullRequest.read(reader); + const status = pullRequest ? computePullRequestIconStatus(reader, gitHubService, ref.owner, ref.repo, pullRequest) : {}; + const icon = pullRequest + ? computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state, status) + : ref.icon ?? (index === 0 ? computePullRequestIcon(GitHubPullRequestState.Open) : undefined); + return { + ref, + pullRequest, + status, + icon: icon ? getAgentMergeAwarePullRequestIcon(icon, agentMergeConfiguration.read(reader), status) : undefined, + }; + })); + this.icon = derived(this, reader => { + const icons = this.pullRequests.read(reader).map(pullRequest => pullRequest.icon); + return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon(GitHubPullRequestState.Open); + }); + } +} + /** * Reads the live {@link IPullRequestIconStatus} for a pull request from the shared * CI and review-thread models. The status only refines open, non-draft pull requests diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 494505089490af..f88873c7996662 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -5,6 +5,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js'; +import { computePullRequestIcon, type ChatPullRequestState, type IPullRequestIconStatus } from '../../../../workbench/common/chatPullRequest.js'; + +export { computePullRequestIcon, type IPullRequestIconStatus }; export const OPEN_PULL_REQUEST_ACTION_ID = 'workbench.agentSessions.action.openPullRequest'; export const OPEN_ISSUE_ACTION_ID = 'workbench.agentSessions.action.openIssue'; @@ -148,48 +151,8 @@ export interface IGitHubPullRequestReview { readonly submittedAt: string; } -/** - * Additional live status used to refine the icon of an open pull request. - */ -export interface IPullRequestIconStatus { - /** Whether the pull request has merge conflicts. */ - readonly hasMergeConflicts?: boolean; - /** Whether the pull request has at least one failing CI check. */ - readonly hasFailingChecks?: boolean; - /** Whether the pull request has at least one unresolved review comment thread. */ - readonly hasUnresolvedComments?: boolean; -} - -/** - * Compute the PR status icon from a state value. - * Accepts both the `GitHubPullRequestState` enum values and the - * metadata-only `'draft'` value the extension writes to session metadata. - * - * For open (non-draft) pull requests the optional {@link IPullRequestIconStatus} - * refines the icon: a failing CI check shows an error variant (orange), while an - * unresolved review comment shows a comment variant (using the open PR green). - */ -export function computePullRequestIcon(state: GitHubPullRequestState | 'draft', status?: IPullRequestIconStatus): ThemeIcon { - switch (state) { - case GitHubPullRequestState.Merged: - return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; - case GitHubPullRequestState.Closed: - return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; - case 'draft': - return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; - case GitHubPullRequestState.Open: - if (status?.hasMergeConflicts || status?.hasFailingChecks) { - return { ...Codicon.gitPullRequestError, color: themeColorFromId('charts.orange') }; - } - if (status?.hasUnresolvedComments) { - return { ...Codicon.gitPullRequestComment, color: themeColorFromId('charts.green') }; - } - return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; - } -} - /** Coarse pull request state, recoverable from the icon carried on session GitHub info. */ -export type PullRequestStatus = 'open' | 'closed' | 'merged' | 'draft'; +export type PullRequestStatus = ChatPullRequestState; /** * Inverse of {@link computePullRequestIcon}: recovers the coarse pull request diff --git a/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts b/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts deleted file mode 100644 index 0e059b2628e5e1..00000000000000 --- a/src/vs/sessions/contrib/github/test/browser/githubReferenceActionViewItems.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { addDisposableListener, EventType } from '../../../../../base/browser/dom.js'; -import { mainWindow } from '../../../../../base/browser/window.js'; -import { Action } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ChatPillActionViewItem } from '../../../../../workbench/browser/chatPills.js'; -import { getAgentMergeAwarePullRequestIcon, ISessionAgentMergeConfiguration } from '../../../../browser/sessionAgentMerge.js'; -import { OpenIssueActionViewItem } from '../../browser/issueActions.js'; -import { OpenPullRequestActionViewItem } from '../../browser/pullRequestActions.js'; - -interface IIssueViewItemTestHarness { - _issuePickerVisible: boolean; - readonly _issuesObs: { get(): readonly object[] }; - readonly _hoverService: { hideHover(force?: boolean): void }; - hasOpenDropdown(): boolean; - _showIssuePicker(issues: readonly object[]): void; -} - -interface IPullRequestViewItemTestHarness { - _pullRequestList: object | undefined; - readonly _pullRequestsObs: { get(): readonly { readonly icon?: ThemeIcon }[] }; - readonly _icon?: { get(): ThemeIcon }; - readonly _hoverService: { hideHover(force?: boolean): void }; - hasOpenDropdown(): boolean; - _showPullRequestPicker(pullRequests: readonly object[]): void; -} - -const openIssueViewItemOnDidClickButton = Reflect.get(OpenIssueActionViewItem.prototype, 'onDidClickButton') as (this: IIssueViewItemTestHarness) => void; -const openPullRequestViewItemOnDidClickButton = Reflect.get(OpenPullRequestActionViewItem.prototype, 'onDidClickButton') as (this: IPullRequestViewItemTestHarness) => void; -const openPullRequestViewItemGetIconElement = Reflect.get(OpenPullRequestActionViewItem.prototype, 'getIconElement') as (this: IPullRequestViewItemTestHarness) => HTMLElement; - -class TestDropdownMetaActionViewItem extends ChatPillActionViewItem { - - dropdownVisible = true; - opened = 0; - closed = 0; - - protected override hasOpenDropdown(): boolean { - return this.dropdownVisible; - } - - protected override onDidClickButton(): void { - if (this.dropdownVisible) { - this.dropdownVisible = false; - this.closed++; - } else { - this.dropdownVisible = true; - this.opened++; - } - } -} - -suite('GitHub Reference Action View Items', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('preserves an open dropdown for primary activation while allowing secondary dismissal', () => { - const store = new DisposableStore(); - const container = mainWindow.document.createElement('div'); - mainWindow.document.body.appendChild(container); - - try { - const action = store.add(new Action('test', 'Test')); - const viewItem = store.add(new TestDropdownMetaActionViewItem(undefined, action, {})); - viewItem.render(container); - let ancestorMouseDowns = 0; - store.add(addDisposableListener(container, EventType.MOUSE_DOWN, () => ancestorMouseDowns++)); - store.add(addDisposableListener(mainWindow.document, EventType.MOUSE_DOWN, () => viewItem.dropdownVisible = false)); - const button = container.querySelector('.chat-pill-button')!; - - button.dispatchEvent(new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 0 })); - button.dispatchEvent(new MouseEvent(EventType.CLICK, { bubbles: true })); - - const afterPrimaryClick = { - ancestorMouseDowns, - opened: viewItem.opened, - closed: viewItem.closed, - dropdownVisible: viewItem.dropdownVisible, - }; - viewItem.dropdownVisible = true; - button.dispatchEvent(new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 2 })); - - assert.deepStrictEqual({ - afterPrimaryClick, - ancestorMouseDowns, - dropdownVisible: viewItem.dropdownVisible, - }, { - afterPrimaryClick: { - ancestorMouseDowns: 1, - opened: 0, - closed: 1, - dropdownVisible: false, - }, - ancestorMouseDowns: 2, - dropdownVisible: false, - }); - } finally { - store.dispose(); - container.remove(); - } - }); - - test('clicking an open issues list closes it instead of reopening it', () => { - const events: string[] = []; - const harness: IIssueViewItemTestHarness = { - _issuePickerVisible: false, - _issuesObs: { get: () => [{}, {}] }, - _hoverService: { hideHover: force => events.push(`hide:${force}`) }, - hasOpenDropdown() { - return this._issuePickerVisible; - }, - _showIssuePicker() { - events.push('show'); - this._issuePickerVisible = true; - }, - }; - - openIssueViewItemOnDidClickButton.call(harness); - openIssueViewItemOnDidClickButton.call(harness); - - assert.deepStrictEqual(events, ['show', 'hide:true']); - }); - - test('clicking an open pull request list closes it instead of reopening it', () => { - const events: string[] = []; - const harness: IPullRequestViewItemTestHarness = { - _pullRequestList: undefined, - _pullRequestsObs: { get: () => [{}, {}] }, - _hoverService: { hideHover: force => events.push(`hide:${force}`) }, - hasOpenDropdown() { - return !!this._pullRequestList; - }, - _showPullRequestPicker() { - events.push('show'); - this._pullRequestList = {}; - }, - }; - - openPullRequestViewItemOnDidClickButton.call(harness); - openPullRequestViewItemOnDidClickButton.call(harness); - - assert.deepStrictEqual(events, ['show', 'hide:true']); - }); - - test('Agent Merge shows the open pull request icon instead of blocker variants', () => { - const agentMerge = (overrides: Partial = {}): ISessionAgentMergeConfiguration => ({ - enabled: true, - actions: { - addressReviews: true, - fixCI: true, - resolveConflicts: true, - mergePullRequest: 'never', - mergeMethod: 'auto', - replyAttribution: true, - ...overrides, - }, - }); - let icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge(), { hasFailingChecks: true }); - const harness: IPullRequestViewItemTestHarness = { - _pullRequestList: undefined, - _pullRequestsObs: { get: () => [{ icon }] }, - _icon: { get: () => icon }, - _hoverService: { hideHover() { } }, - hasOpenDropdown: () => false, - _showPullRequestPicker() { }, - }; - const iconId = () => [...openPullRequestViewItemGetIconElement.call(harness).classList] - .find(className => className.startsWith('codicon-git-pull-request')); - - const failingCI = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge({ addressReviews: false }), { hasFailingChecks: true, hasUnresolvedComments: true }); - const unhandledReviewAlongsideCI = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge(), {}); - const unknownBlocker = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestError, agentMerge({ fixCI: false }), { hasFailingChecks: true }); - const failingCIDisabled = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestComment, agentMerge()); - const reviewComments = iconId(); - icon = getAgentMergeAwarePullRequestIcon(Codicon.gitPullRequestComment, agentMerge({ addressReviews: false })); - - assert.deepStrictEqual({ - failingCI, - unhandledReviewAlongsideCI, - unknownBlocker, - failingCIDisabled, - reviewComments, - reviewsDisabled: iconId(), - }, { - failingCI: 'codicon-git-pull-request', - unhandledReviewAlongsideCI: 'codicon-git-pull-request-error', - unknownBlocker: 'codicon-git-pull-request-error', - failingCIDisabled: 'codicon-git-pull-request-error', - reviewComments: 'codicon-git-pull-request', - reviewsDisabled: 'codicon-git-pull-request-comment', - }); - }); -}); 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..53b4affd69a224 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts @@ -5,23 +5,28 @@ 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 { mock } from '../../../../../base/test/common/mock.js'; +import { Disposable, toDisposable } 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 { defaultAgentMergeConfiguration } from '../../../../../platform/agentHost/common/agentMerge.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'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import '../../browser/pullRequestActions.js'; +import { SessionPullRequestPresentationModel } from '../../browser/pullRequestIconStatus.js'; +import { IGitHubService } from '../../browser/githubService.js'; +import { GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest } from '../../common/types.js'; +import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullRequestRefs?: readonly IGitHubPullRequestRef[]): ISession { const workspaceUri = URI.from({ scheme: 'test', path: '/workspace' }); @@ -54,31 +59,123 @@ 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; } } suite('Pull Request Actions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('shared presentation model applies Agent Merge to live pull request status', () => { + const pullRequest = upcastPartial({ + number: 1, + title: 'Fix pills', + state: GitHubPullRequestState.Open, + headSha: 'abc123', + isDraft: false, + }); + const pullRequestModel = upcastPartial({ + pullRequest: constObservable(pullRequest), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const ciModel = upcastPartial({ + overallStatus: constObservable(GitHubCIOverallStatus.Failure), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const reviewThreadsModel = upcastPartial({ + reviewThreads: constObservable([]), + refresh: async () => { }, + startPolling: () => Disposable.None, + }); + const gitHubService = upcastPartial({ + createPullRequestModelReference: () => ({ object: pullRequestModel, dispose: () => { } }), + createPullRequestCIModelReference: () => ({ object: ciModel, dispose: () => { } }), + createPullRequestReviewThreadsModelReference: () => ({ object: reviewThreadsModel, dispose: () => { } }), + }); + const model = store.add(new SessionPullRequestPresentationModel( + constObservable([{ + owner: 'microsoft', + repo: 'vscode', + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + }]), + constObservable({ + enabled: true, + actions: { + ...defaultAgentMergeConfiguration, + fixCI: true, + resolveConflicts: false, + addressReviews: false, + }, + }), + gitHubService, + )); + + assert.deepStrictEqual({ + entryIcon: model.pullRequests.get()[0].icon?.id, + summaryIcon: model.icon.get().id, + }, { + entryIcon: Codicon.gitPullRequest.id, + summaryIcon: Codicon.gitPullRequest.id, + }); + }); + + test('shared presentation model does not own polling', () => { + let refreshCount = 0; + let pollingStartCount = 0; + let pollingStopCount = 0; + const pullRequestModel = upcastPartial({ + pullRequest: constObservable(undefined), + refresh: async () => { refreshCount++; }, + startPolling: () => { + pollingStartCount++; + return toDisposable(() => pollingStopCount++); + }, + }); + const gitHubService = upcastPartial({ + createPullRequestModelReference: () => ({ object: pullRequestModel, dispose: () => { } }), + }); + const pullRequestRefs = observableValue('pullRequestActions.refs', [{ + owner: 'microsoft', + repo: 'vscode', + number: 1, + uri: URI.parse('https://github.com/microsoft/vscode/pull/1'), + icon: Codicon.gitPullRequest, + }]); + const model = store.add(new SessionPullRequestPresentationModel(pullRequestRefs, constObservable(undefined), gitHubService)); + + model.pullRequests.get(); + pullRequestRefs.set([{ + ...pullRequestRefs.get()[0], + icon: Codicon.gitPullRequestDone, + title: 'Updated title', + }], undefined); + model.pullRequests.get(); + + assert.deepStrictEqual({ + refreshCount, + pollingStartCount, + pollingStopCount, + entryIcon: model.pullRequests.get()[0].icon?.id, + }, { + refreshCount: 0, + pollingStartCount: 0, + pollingStopCount: 0, + entryIcon: Codicon.gitPullRequestDone.id, + }); + }); test('Open Pull Request and Copy Pull Request URL are contributed to a dedicated context menu group', () => { const items = MenuRegistry.getMenuItems(Menus.SessionItemContextMenu) @@ -137,63 +234,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 +275,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/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index 8a2b09fd85f3a8..139d4f861d2eb7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -74,7 +74,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { this._watchProviders(this._sessionsProvidersService.getProviders()); } - render(container: HTMLElement): void { + render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); this._containerElement = container; @@ -104,6 +104,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { })); this._updateTrigger(); + return trigger; } private _watchProviders(providers: readonly ISessionsProvider[]): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 35a78f96715ced..fd38a742ac6634 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -13,26 +13,18 @@ import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { isMultiRootSession } from '../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; -import { AGENT_MERGE_CHANGESET_ID, resolveChangesetUriTemplate } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { AGENT_MERGE_CHANGESET_ID, ChangesetKind, resolveChangesetUriTemplate, selectDefaultChangeset } from '../../../../../platform/agentHost/common/changesetUri.js'; import { isAgentMergeMessage } from '../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; import { ChangesetOperationTargetKind } from '../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, ChangesetOperationStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { buildDefaultChatUri, ChangesetStatus, Changeset, MessageKind, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildDefaultChatUri, ChangesetStatus, Changeset, isHostNoticeTurn, lastAttributableTurnId, MessageKind, StateComponents, TurnState, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ISessionChangeset, ISessionChangesetCapabilities, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; import { isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { changesetFileToChange } from './agentHostDiffs.js'; import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; -const enum ChangesetKind { - Branch = 'branch', - Uncommitted = 'uncommitted', - Session = 'session', - Turn = 'turn', - Compare = 'compare-turns', -} - export interface IAgentHostChangeset extends Changeset { /** * Optional authoritative changes. `undefined` falls back to the changeset @@ -92,8 +84,7 @@ export function createChangesets( const sessionChangesets: ISessionChangeset[] = []; - const defaultKind = options.defaultChangesetKind ?? ChangesetKind.Branch; - const defaultChangeset = changesets.find(c => c.changeKind === defaultKind) ?? changesets[0]; + const defaultChangeset = selectDefaultChangeset(changesets, options.defaultChangesetKind); for (const catalogueEntry of changesets) { const isDefault = catalogueEntry === defaultChangeset; @@ -566,7 +557,10 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { // Prefer the in-progress turn so the "last turn" reflects streaming // edits live; once it completes it moves into `turns` under the same // id, so the tracked changeset transitions seamlessly. - return chatState.activeTurn?.id ?? chatState.turns?.at(-1)?.id; + if (chatState.activeTurn && !isHostNoticeTurn(chatState.activeTurn)) { + return chatState.activeTurn.id; + } + return lastAttributableTurnId(chatState.turns); }); // Last turn changes diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 194dd3ab455e3c..bee227e2999fe8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -1227,12 +1227,13 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { } interface IConfigPickerWidget extends IDisposable { - render(container: HTMLElement): void; + render(container: HTMLElement): HTMLElement | void; showPicker?(anchor: HTMLElement, onHide?: () => void): boolean | void; } export class PickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { private _compact = false; + private _focusableElement: HTMLElement | undefined; constructor(private readonly _picker: IConfigPickerWidget, disposable?: IDisposable) { super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); @@ -1243,10 +1244,40 @@ export class PickerActionViewItem extends BaseActionViewItem implements IChatInp override render(container: HTMLElement): void { this.element = container; - this._picker.render(container); + this._focusableElement = this._picker.render(container) ?? undefined; container.classList.toggle('compact-picker', this._compact); } + override focus(): void { + if (this._focusableElement) { + this._focusableElement.focus(); + } else { + super.focus(); + } + } + + override isFocused(): boolean { + return this._focusableElement + ? this._focusableElement === dom.getActiveElement() + : super.isFocused(); + } + + override blur(): void { + if (this._focusableElement) { + this._focusableElement.blur(); + } else { + super.blur(); + } + } + + override setFocusable(focusable: boolean): void { + if (this._focusableElement) { + this.element?.removeAttribute('tabindex'); + } else { + super.setFocusable(focusable); + } + } + isCompact(): boolean { return this._compact; } 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/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 0384d32808dfc3..5037fadd3b1b23 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -354,7 +354,13 @@ suite('Agent Host Session Config Picker', () => { test('picker action view items expose responsive compact state', () => { let pickerAnchor: HTMLElement | undefined; const item = store.add(new PickerActionViewItem({ - render: () => { }, + render: container => { + const trigger = document.createElement('a'); + trigger.classList.add('action-label'); + trigger.tabIndex = 0; + container.appendChild(trigger); + return trigger; + }, showPicker: anchor => { pickerAnchor = anchor; return true; @@ -362,6 +368,8 @@ suite('Agent Host Session Config Picker', () => { dispose: () => { }, })); const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); const overflowAnchor = document.createElement('button'); item.render(container); const expanded = { @@ -370,16 +378,28 @@ suite('Agent Host Session Config Picker', () => { }; item.setCompact(true); + item.setFocusable(true); + item.focus(); item.show(overflowAnchor); const compact = { compact: item.isCompact(), className: container.classList.contains('compact-picker'), usesOverflowAnchor: pickerAnchor === overflowAnchor, + wrapperTabIndex: container.tabIndex, + tabbableDescendants: container.querySelectorAll('[tabindex="0"]').length, + triggerFocused: item.isFocused(), }; assert.deepStrictEqual({ expanded, compact }, { expanded: { compact: false, className: false }, - compact: { compact: true, className: true, usesOverflowAnchor: true }, + compact: { + compact: true, + className: true, + usesOverflowAnchor: true, + wrapperTabIndex: -1, + tabbableDescendants: 1, + triggerFocused: true, + }, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 56ec50ca917ca5..cb033f3541df18 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -5942,6 +5942,89 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('Last Turn Changes ignores a trailing host notice turn', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const workingDirectory = URI.file('/repo'); + agentHost.addSession(createSession('notice-turn-changes', { summary: 'Notice Turn Changes', workingDirectory })); + const activeSession = observableValue('activeSession', undefined); + const provider = createProvider(disposables, agentHost, undefined, { activeSession }); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions().find(candidate => candidate.title.get() === 'Notice Turn Changes'); + assert.ok(session); + activeSession.set(session as IActiveSession, undefined); + assert.ok(session instanceof AgentHostSessionAdapter); + + const sessionUri = AgentSession.uri('copilotcli', 'notice-turn-changes').toString(); + const chatUri = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('notice-turn-changes', 'copilotcli', { + provider: 'copilotcli', + title: 'Notice Turn Changes', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat: chatUri, + chats: [{ + resource: chatUri, + title: 'Default', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date(0).toISOString(), + }], + workingDirectories: [workingDirectory.toString()], + }); + session.updateChangesets([{ + label: 'Last Turn Changes', + uriTemplate: `${sessionUri}/changeset/turn/{turnId}`, + changeKind: 'turn', + }]); + + const changedFile = URI.file('/repo/edited.ts'); + agentHost.setChangesetState(`${sessionUri}/changeset/turn/agent-turn`, { + status: ChangesetStatus.Ready, + files: [{ + id: changedFile.toString(), + edit: { + after: { uri: changedFile.toString(), content: { uri: changedFile.toString() } }, + diff: { added: 3, removed: 1 }, + }, + }], + }); + agentHost.setChangesetState(`${sessionUri}/changeset/turn/notice-turn`, { status: ChangesetStatus.Ready, files: [] }); + + // The agent's turn, followed by a hidden Agent Merge notice turn. + agentHost.setChatState(chatUri, { + resource: chatUri, + title: 'Default', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date().toISOString(), + turns: [{ + id: 'agent-turn', + message: { text: 'Edit edited.ts', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }, { + id: 'notice-turn', + message: { + text: '\nAgent Merge is enabled for `feature`.', + origin: { kind: MessageKind.SystemNotification }, + }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }], + }); + + const changeset = session.changesets.get()?.find(candidate => candidate.id === TURN_CHANGES_CHANGESET_ID); + assert.deepStrictEqual({ + isEnabled: changeset?.isEnabled.get(), + changes: changeset?.changes.get().map(change => isIChatSessionFileChange2(change) ? change.uri.toString() : change.modifiedUri.toString()), + }, { + isEnabled: true, + changes: [changedFile.toString()], + }); + })); + test('registers provider-neutral resource label homes for quick chats and provider state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const claudeHome = URI.file('/home/test/.agent/chats/claude-session'); const rootHome = URI.file('/'); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts index 50b3bb0ea14523..643c6e10b3322b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts @@ -204,7 +204,7 @@ suite('Open Agent Host State File', () => { const connectionsService = new class extends mock() { override resolveSessionResource(session: URI) { calls.resolved.push(session.toString()); - return { connection, backendSession }; + return { connection, connectionAuthority: 'local', backendSession }; } }(); const editorService = new class extends mock() { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 49c7d2ab6f285a..aecff61bf5893f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -47,7 +47,8 @@ import { IAgentHostFilterService } from '../../../../services/agentHostFilter/co import { IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionSchemeAlias, IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; +import { IAgentHostSessionSchemeAlias } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; import { CloudSandboxSessionsProvider } from './cloudSandboxSessionsProvider.js'; import { IRemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; import { createCloudSandboxConnectionCustomization, isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; @@ -59,7 +60,7 @@ const LOG_PREFIX = '[CloudSandboxAgentHost]'; * Mission Control creates every sandbox session as `ahp-session:/` while the host advertises the * `copilot` agent, so the two schemes name the same session. */ -const SANDBOX_SESSION_SCHEME_ALIAS: ISessionSchemeAlias = { +const SANDBOX_SESSION_SCHEME_ALIAS: IAgentHostSessionSchemeAlias = { ui: CLOUD_SANDBOX_AGENT_PROVIDER, backend: CLOUD_SANDBOX_SESSION_SCHEME, }; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 5cb5124e969233..9a8a93f1f4cda8 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -18,6 +18,7 @@ import { agentHostUri } from '../../../../../platform/agentHost/common/agentHost import { AGENT_HOST_SCHEME, agentHostAuthority, type AgentHostUriMapper, fromAgentHostUri, toAgentHostContentUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService, type IAgentHostSessionSchemeAlias } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { IRemoteAgentHostService, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; @@ -80,7 +81,7 @@ export interface IRemoteAgentHostSessionsProviderConfig { * the cloud sandbox host does (sessions are `ahp-session:/` while the agent is `copilot`). * The provider derives both directions from this pair, so they cannot drift apart. */ - readonly sessionSchemeAlias?: ISessionSchemeAlias; + readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; /** * Suppresses the `[host]` suffix that otherwise disambiguates this host's workspaces from * identically-named ones on other hosts. Set by hosts whose label names a task rather than a @@ -103,13 +104,6 @@ export interface IRemoteAgentHostSessionsProviderConfig { * The two names a session goes by when the host's session scheme differs from its agent provider. * The raw session id is shared, so only the scheme is translated. */ -export interface ISessionSchemeAlias { - /** Scheme the UI routes by — the agent provider (e.g. `copilot`). */ - readonly ui: string; - /** Scheme the host's session registry is keyed by (e.g. `ahp-session`). */ - readonly backend: string; -} - /** * Sessions provider for a remote agent host connection. A thin subclass of * {@link BaseAgentHostSessionsProvider} that adds the connection-lifecycle @@ -189,7 +183,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _connectionAuthority: string; private readonly _connectOnDemand: (() => Promise) | undefined; private readonly _disconnectOnDemand: (() => Promise) | undefined; - private readonly _sessionSchemeAlias: ISessionSchemeAlias | undefined; + private readonly _sessionSchemeAlias: IAgentHostSessionSchemeAlias | undefined; private readonly _omitHostFromWorkspaceLabel: boolean; private readonly _workspaceTypeIcon: ThemeIcon | undefined; private readonly _defaultChangesetKind: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; @@ -213,6 +207,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @INotificationService private readonly _notificationService: INotificationService, @IStorageService storageService: IStorageService, @IAgentHostService private readonly _localAgentHostService: IAgentHostService, + @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -237,6 +232,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._omitHostFromWorkspaceLabel = config.omitHostFromWorkspaceLabel === true; this._workspaceTypeIcon = config.workspaceTypeIcon; this._defaultChangesetKind = config.defaultChangesetKind; + if (this._sessionSchemeAlias || this._defaultChangesetKind) { + this._register(agentHostConnectionsService.registerSessionResolutionPolicy(this._connectionAuthority, { + sessionSchemeAlias: this._sessionSchemeAlias, + defaultChangesetKind: this._defaultChangesetKind, + })); + } this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.autoConnect = config.autoConnect; 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/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 3bc1ebb5f511a4..630048ad813d3b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -8,12 +8,13 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService, type IAgentHostSessionResolutionPolicy, type IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -241,7 +242,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; sessionSchemeAlias?: IAgentHostSessionSchemeAlias; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; sessionResolutionPolicies?: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }>; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -268,6 +269,12 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); instantiationService.stub(IAgentHostService, overrides?.localAgentHostService ?? new class extends mock() { }()); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + registerSessionResolutionPolicy: (authority, policy) => { + overrides?.sessionResolutionPolicies?.push({ authority, policy }); + return Disposable.None; + }, + })); instantiationService.stub(IProgressService, {}); instantiationService.stub(ILabelService, overrides?.labelService ?? new MockLabelService()); instantiationService.stub(ILogService, new NullLogService()); @@ -297,6 +304,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host', omitHostFromWorkspaceLabel: overrides?.omitHostFromWorkspaceLabel, workspaceTypeIcon: overrides?.workspaceTypeIcon, + sessionSchemeAlias: overrides?.sessionSchemeAlias, defaultChangesetKind: overrides?.defaultChangesetKind, devContainerWorktreeScope: overrides?.devContainerWorktreeScope, }; @@ -385,6 +393,24 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [My Host]'); }); + test('registers provider-owned session resolution policy', () => { + const policies: Array<{ authority: string; policy: IAgentHostSessionResolutionPolicy }> = []; + createProvider(disposables, connection, { + address: 'sandbox.example', + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + sessionResolutionPolicies: policies, + }); + + assert.deepStrictEqual(policies, [{ + authority: agentHostAuthority('sandbox.example'), + policy: { + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + }, + }]); + }); + test('session types update when the host advertises additional agents', () => { const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: false }); assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [ 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/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 7002c36acdef20..150141410cd5fd 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../base/common/codicons.js'; import { arrayEquals } from '../../../../base/common/equals.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IObservable, IReader } from '../../../../base/common/observable.js'; @@ -12,8 +11,11 @@ import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { getHighestPriorityPullRequestIcon } from '../../../../workbench/common/chatPullRequest.js'; import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +export { getHighestPriorityPullRequestIcon }; + export interface ISessionType { /** Unique identifier (e.g., 'copilot-cli', 'copilot-cloud', 'agent-host-claude'). */ readonly id: string; @@ -388,32 +390,6 @@ export function getGitHubPullRequestRefs(gitHubInfo: IGitHubInfo | undefined): r }]; } -const pullRequestIconPriority = new Map([ - [Codicon.gitPullRequestError.id, 6], - [Codicon.gitPullRequestComment.id, 5], - [Codicon.gitPullRequest.id, 4], - [Codicon.gitPullRequestDraft.id, 3], - [Codicon.gitPullRequestDone.id, 2], - [Codicon.gitPullRequestClosed.id, 1], -]); - -/** Returns the most important status icon across a session's pull requests. */ -export function getHighestPriorityPullRequestIcon(icons: readonly (ThemeIcon | undefined)[]): ThemeIcon | undefined { - let result: ThemeIcon | undefined; - let resultPriority = -1; - for (const icon of icons) { - if (!icon) { - continue; - } - const priority = pullRequestIconPriority.get(icon.id) ?? 0; - if (priority > resultPriority) { - result = icon; - resultPriority = priority; - } - } - return result; -} - /** A GitHub issue referenced by a session. */ export interface IGitHubIssueRef { /** GitHub repository owner of the issue. */ 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 4cc39dd79ef7f3..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'; @@ -222,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; 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..c0e74d1bd52714 --- /dev/null +++ b/src/vs/sessions/test/browser/editorPart.test.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +// eslint-disable-next-line local/code-import-patterns +import { createBrowserWelcome } from '../../../workbench/contrib/browserView/browser/browserWelcome.js'; +import { renderSessionsEmptyState } from '../../browser/parts/sessionsEmptyState.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 a semantic heading for shared empty states', () => { + const host = appendElement(mainWindow.document.body, 'agent-sessions-workbench'); + + try { + const container = renderSessionsEmptyState(host, 'Files', 'Select a file from the Files view'); + const title = container.querySelector('.sessions-empty-state-title'); + const description = container.querySelector('.sessions-empty-state-description'); + assert.ok(title && description); + + assert.deepStrictEqual({ + contentChildren: Array.from(container.children, element => element.className), + title: { + tagName: title.tagName, + textContent: title.textContent, + margin: mainWindow.getComputedStyle(title).margin, + }, + description: { + textContent: description.textContent, + }, + }, { + contentChildren: ['sessions-empty-state-title', 'sessions-empty-state-description'], + title: { + tagName: 'H2', + textContent: 'Files', + margin: '0px', + }, + description: { + textContent: 'Select a file from the Files view', + }, + }); + } finally { + host.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 browserRoot = appendElement(editorPart, 'browser-root'); + + try { + const container = createBrowserWelcome('Browser', 'Use Add Element to Chat to reference UI elements in chat prompts.'); + browserRoot.appendChild(container); + + const content = container.querySelector('.browser-welcome-content'); + const icon = content?.querySelector('.browser-welcome-icon'); + const title = content?.querySelector('.browser-welcome-title'); + const subtitle = content?.querySelector('.browser-welcome-subtitle'); + assert.ok(content && icon && title && subtitle); + + const contentStyle = mainWindow.getComputedStyle(content); + const titleStyle = mainWindow.getComputedStyle(title); + const subtitleStyle = mainWindow.getComputedStyle(subtitle); + + assert.deepStrictEqual({ + containerChildren: Array.from(container.children, element => element.className), + contentChildren: Array.from(content.children, element => element.className), + gap: contentStyle.gap, + iconDisplay: mainWindow.getComputedStyle(icon).display, + title: { + tagName: title.tagName, + textContent: title.textContent, + color: titleStyle.color, + fontSize: titleStyle.fontSize, + fontWeight: titleStyle.fontWeight, + margin: titleStyle.margin, + padding: titleStyle.padding, + }, + subtitle: { + textContent: subtitle.textContent, + color: subtitleStyle.color, + fontSize: subtitleStyle.fontSize, + fontWeight: subtitleStyle.fontWeight, + margin: subtitleStyle.margin, + padding: subtitleStyle.padding, + }, + }, { + containerChildren: ['browser-welcome-content'], + contentChildren: ['browser-welcome-icon', 'browser-welcome-title', 'browser-welcome-subtitle'], + gap: '4px', + iconDisplay: 'none', + title: { + tagName: 'H2', + textContent: 'Browser', + color: 'rgb(204, 204, 204)', + fontSize: '13px', + fontWeight: '600', + margin: '0px', + padding: '0px', + }, + subtitle: { + textContent: 'Use Add Element to Chat to reference UI elements in chat prompts.', + color: 'rgb(157, 157, 157)', + fontSize: '13px', + fontWeight: '400', + margin: '0px', + padding: '0px', + }, + }); + } finally { + workbench.remove(); + } + }); + +}); diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 1392a046e484af..e59cbee45a630f 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -122,6 +122,72 @@ suite('Sessions - SessionHeader', () => { }); }); + test('matches the editor tab strip geometry and high contrast separator', () => { + const { header } = createHarness(disposables); + const container = header.element.parentElement!; + container.classList.add('agent-sessions-workbench'); + container.style.setProperty('--vscode-spacing-size20', '2px'); + container.style.setProperty('--vscode-spacing-size80', '8px'); + container.style.setProperty('--vscode-spacing-size100', '10px'); + container.style.setProperty('--vscode-contrastBorder', 'rgb(1, 2, 3)'); + container.style.width = '420px'; + mainWindow.document.body.appendChild(container); + + try { + const headerRow = header.element.querySelector('.chat-composite-bar-header')!; + const getGeometry = () => { + const barBounds = header.element.getBoundingClientRect(); + const headerBounds = headerRow.getBoundingClientRect(); + return { + barHeight: mainWindow.getComputedStyle(header.element).height, + headerHeight: mainWindow.getComputedStyle(headerRow).height, + headerInset: headerBounds.left - barBounds.left, + barPaddingInline: mainWindow.getComputedStyle(header.element).paddingInline, + headerPaddingInline: mainWindow.getComputedStyle(headerRow).paddingInline, + hasCompactClass: container.classList.contains('editor-tabs-compact-height'), + }; + }; + + const defaultGeometry = getGeometry(); + container.classList.add('editor-tabs-compact-height'); + const compactGeometry = getGeometry(); + container.classList.remove('editor-tabs-compact-height'); + const restoredGeometry = getGeometry(); + container.classList.add('hc-black'); + const highContrastSeparatorColor = mainWindow.getComputedStyle(headerRow).borderBottomColor; + + assert.deepStrictEqual({ defaultGeometry, compactGeometry, restoredGeometry, highContrastSeparatorColor }, { + defaultGeometry: { + barHeight: '32px', + headerHeight: '32px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '2px', + hasCompactClass: false, + }, + compactGeometry: { + barHeight: '28px', + headerHeight: '28px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '2px', + hasCompactClass: true, + }, + restoredGeometry: { + barHeight: '32px', + headerHeight: '32px', + headerInset: 2, + barPaddingInline: '10px', + headerPaddingInline: '2px', + hasCompactClass: false, + }, + highContrastSeparatorColor: 'rgb(1, 2, 3)', + }); + } finally { + container.remove(); + } + }); + test('reports whether the inline rename could be started', () => { const renameable = createHarness(disposables, { supportsMultipleChats: false, supportsRename: true }); const notRenameable = createHarness(disposables); 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/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index d03a5c11880b72..f7b82f298f7069 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -8,7 +8,7 @@ import { MenuId, MenuRegistry, registerAction2, Action2 } from '../../../platfor import { Categories } from '../../../platform/action/common/actionCommonCategories.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { alert } from '../../../base/browser/ui/aria/aria.js'; -import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, Parts, Position, ZenModeSettings, positionToString } from '../../services/layout/browser/layoutService.js'; +import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, ModernUIDensity, Parts, Position, ZenModeSettings, positionToString } from '../../services/layout/browser/layoutService.js'; import { ServicesAccessor, IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { isWindows, isLinux, isWeb, isMacintosh, isNative } from '../../../base/common/platform.js'; @@ -54,6 +54,9 @@ const panelAlignmentJustifyIcon = registerIcon('panel-align-justify', Codicon.la const quickInputAlignmentTopIcon = registerIcon('quickInputAlignmentTop', Codicon.arrowUp, localize('quickInputAlignmentTop', "Represents quick input alignment set to the top")); const quickInputAlignmentCenterIcon = registerIcon('quickInputAlignmentCenter', Codicon.circle, localize('quickInputAlignmentCenter', "Represents quick input alignment set to the center")); +const layoutDensityDefaultIcon = registerIcon('layout-density-default-icon', Codicon.layoutDensityDefault, localize('layoutDensityDefaultIcon', "Represents the default layout density")); +const layoutDensityCompactIcon = registerIcon('layout-density-compact-icon', Codicon.layoutDensityCompact, localize('layoutDensityCompactIcon', "Represents the compact layout density")); + const fullscreenIcon = registerIcon('fullscreen', Codicon.screenFull, localize('fullScreenIcon', "Represents full screen")); const centerLayoutIcon = registerIcon('centerLayoutIcon', Codicon.layoutCentered, localize('centerLayoutIcon', "Represents centered layout mode")); const zenModeIcon = registerIcon('zenMode', Codicon.target, localize('zenModeIcon', "Represents zen mode")); @@ -1385,6 +1388,13 @@ const QuickInputActions: CustomizeLayoutItem[] = [ CreateOptionLayoutItem('workbench.action.alignQuickInputCenter', QuickInputAlignmentContextKey.isEqualTo('center'), localize('center', "Center"), quickInputAlignmentCenterIcon), ]; +const ModernUIEnabledContext = ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI}`, true); + +const LayoutDensityActions: CustomizeLayoutItem[] = [ + CreateOptionLayoutItem(`workbench.action.setLayoutDensity.${ModernUIDensity.Default}`, ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI_DENSITY}`, ModernUIDensity.Default), localize('layoutDensityDefault', "Default"), layoutDensityDefaultIcon), + CreateOptionLayoutItem(`workbench.action.setLayoutDensity.${ModernUIDensity.Compact}`, ContextKeyExpr.equals(`config.${LayoutSettings.MODERN_UI_DENSITY}`, ModernUIDensity.Compact), localize('layoutDensityCompact', "Compact"), layoutDensityCompactIcon), +]; + const MiscLayoutOptions: CustomizeLayoutItem[] = [ CreateOptionLayoutItem('workbench.action.toggleFullScreen', IsMainWindowFullscreenContext, localize('fullscreen', "Full Screen"), fullscreenIcon), CreateOptionLayoutItem('workbench.action.toggleZenMode', InEditorZenModeContext, localize('zenMode', "Zen Mode"), zenModeIcon), @@ -1392,11 +1402,14 @@ const MiscLayoutOptions: CustomizeLayoutItem[] = [ ]; const LayoutContextKeySet = new Set(); -for (const { active } of [...ToggleVisibilityActions, ...MoveSideBarActions, ...AlignPanelActions, ...QuickInputActions, ...MiscLayoutOptions]) { +for (const { active } of [...ToggleVisibilityActions, ...MoveSideBarActions, ...AlignPanelActions, ...QuickInputActions, ...LayoutDensityActions, ...MiscLayoutOptions]) { for (const key of active.keys()) { LayoutContextKeySet.add(key); } } +for (const key of ModernUIEnabledContext.keys()) { + LayoutContextKeySet.add(key); +} /** * Matches the title bar's `editorActionsEnabled` getter: true when editor @@ -1484,6 +1497,17 @@ registerAction2(class CustomizeLayoutAction extends Action2 { ] }; }; + const layoutDensityItems: QuickPickItem[] = []; + if (ModernUIEnabledContext.evaluate(contextKeyService.getContext(null))) { + layoutDensityItems.push( + { + type: 'separator', + label: localize('layoutDensity', "Layout Density") + }, + ...LayoutDensityActions.map(toQuickPickItem) + ); + } + return [ { type: 'separator', @@ -1505,6 +1529,7 @@ registerAction2(class CustomizeLayoutAction extends Action2 { label: localize('quickOpen', "Quick Input Position") }, ...QuickInputActions.map(toQuickPickItem), + ...layoutDensityItems, { type: 'separator', label: localize('layoutModes', "Modes"), @@ -1593,6 +1618,7 @@ registerAction2(class CustomizeLayoutAction extends Action2 { resetSetting('workbench.sideBar.location'); resetSetting('workbench.statusBar.visible'); resetSetting('workbench.panel.defaultLocation'); + resetSetting(LayoutSettings.MODERN_UI_DENSITY); if (!isMacintosh || !isNative) { resetSetting('window.menuBarVisibility'); diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index 8414c0b0bada9d..0620e78ae89198 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -5,6 +5,7 @@ import { $ } from '../../base/browser/dom.js'; import { IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; +import type { IManagedHoverContent, IManagedHoverOptions } from '../../base/browser/ui/hover/hover.js'; import { IAction } from '../../base/common/actions.js'; import { Codicon } from '../../base/common/codicons.js'; import { onUnexpectedError } from '../../base/common/errors.js'; @@ -43,7 +44,7 @@ export interface IChatDropdownPillOptions { /** Identifies the pill's dropdown to the action widget service. */ readonly widgetId: string; /** Icon of the summary shown for several entries. */ - readonly icon: ThemeIcon; + readonly icon: ThemeIcon | IObservable; /** Accessible name of the dropdown. */ readonly title: string; /** Summary label, e.g. `3 Artifacts`. */ @@ -95,17 +96,51 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } private _dropdownVisible = false; + private _entries: readonly IChatPillEntry[] = []; + private _summaryIcon: ThemeIcon | undefined; protected override renderContent(): void { this._register(autorun(reader => { - this._sections.read(reader); - this.updateLabel(); - this.updateTooltip(); - this.updateAriaLabel(); - this._updatePopupState(); + const previous = this._getPresentation(); + this._entries = getChatPillEntries(this._sections.read(reader)); + this._summaryIcon = ThemeIcon.isThemeIcon(this._pillOptions.icon) ? this._pillOptions.icon : this._pillOptions.icon.read(reader); + const current = this._getPresentation(); + if (previous.label !== current.label || previous.summarized !== current.summarized || !iconsEqual(previous.icon, current.icon)) { + this.updateLabel(); + } + if (previous.hoverContent !== current.hoverContent) { + this.updateTooltip(); + } + if (previous.ariaLabel !== current.ariaLabel) { + this.updateAriaLabel(); + } + if (previous.ariaDescription !== current.ariaDescription) { + this._updateAriaDescription(current.ariaDescription); + } + if (previous.summarized !== current.summarized) { + this._updatePopupState(); + } + if (this._dropdownVisible) { + if (current.summarized) { + this._actionWidgetService.updateItems(this._getDropdownItems()); + } else { + this._actionWidgetService.hide(); + } + } })); } + private _getPresentation(): { readonly summarized: boolean; readonly icon: ThemeIcon | undefined; readonly label: string; readonly hoverContent: IManagedHoverContent; readonly ariaLabel: string | undefined; readonly ariaDescription: string | undefined } { + return { + summarized: this.isSummarized, + icon: this.isSummarized ? this._summaryIcon : this.entries.at(0)?.icon, + label: this.getLabelText(), + hoverContent: this.getHoverContents(), + ariaLabel: this.getAriaLabel(), + ariaDescription: this.isSummarized ? undefined : this.entries.at(0)?.ariaDescription, + }; + } + /** * A summarized pill opens a listbox, so it has to advertise the popup and * whether it is open. A single-entry pill activates directly and must not. @@ -124,6 +159,18 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } } + private _updateAriaDescription(description: string | undefined): void { + const element = this.button?.element; + if (!element) { + return; + } + if (description) { + element.setAttribute('aria-description', description); + } else { + element.removeAttribute('aria-description'); + } + } + /** Whether the pill stands for its entries rather than showing a single one. */ protected get isSummarized(): boolean { const entries = this.entries; @@ -131,11 +178,11 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { } protected get entries(): readonly IChatPillEntry[] { - return getChatPillEntries(this._sections.get()); + return this._entries; } protected override getIconElement(): HTMLElement | undefined { - const icon = this.isSummarized ? this._pillOptions.icon : this.entries.at(0)?.icon; + const icon = this.isSummarized ? this._summaryIcon : this.entries.at(0)?.icon; return icon ? this.createIconElement(icon) : undefined; } @@ -153,7 +200,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { protected override getLabelText(): string { return this.isSummarized ? this._pillOptions.summaryLabel(this.entries.length) - : this.entries.at(0)?.label ?? ''; + : this.entries.at(0)?.pillLabel ?? this.entries.at(0)?.label ?? ''; } protected override getAdditionalLabelContent(): Array { @@ -168,12 +215,31 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return entry?.tooltip ?? entry?.label ?? this._pillOptions.title; } + protected override getHoverContents(): IManagedHoverContent { + return this.isSummarized + ? super.getHoverContents() + : this.entries.at(0)?.pillHover ?? super.getHoverContents(); + } + protected override getAriaLabel(): string | undefined { return this.isSummarized ? this._pillOptions.summaryAriaLabel(this.entries.length) : this.entries.at(0)?.ariaLabel ?? super.getAriaLabel(); } + protected override getHoverOptions(): IManagedHoverOptions | undefined { + const toolbarActions = this.isSummarized ? undefined : this.entries.at(0)?.toolbarActions; + return toolbarActions?.length ? { + trapFocus: true, + actions: toolbarActions.map(action => ({ + commandId: action.id, + label: action.label, + iconClass: action.class, + run: () => { void action.run(); }, + })), + } : undefined; + } + protected override onDidClickButton(): void { if (!this.isSummarized) { this.openEntry(this.entries.at(0)); @@ -204,26 +270,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { return; } - const items: IActionListItem[] = []; - for (const section of sections) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - items.push({ kind: ActionListItemKind.Header, label: section.title, group: { title: section.title } }); - for (const entry of section.entries) { - items.push({ - kind: ActionListItemKind.Action, - label: entry.label, - group: { title: '', ...(entry.icon ? { icon: entry.icon } : {}) }, - ...(entry.resource ? { iconClasses: getIconClasses(this._modelService, this._languageService, entry.resource, FileKind.FILE) } : {}), - ...(entry.toolbarActions?.length ? { toolbarActions: [...entry.toolbarActions] } : {}), - ariaDescription: entry.ariaDescription, - hover: entry.hover, - item: entry, - }); - } - } - + const items = this._getDropdownItems(sections); const delegate: IActionListDelegate = { onSelect: entry => { this._actionWidgetService.hide(); @@ -232,7 +279,9 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { onHide: () => { this._dropdownVisible = false; this._updatePopupState(); - trigger.focus(); + if (trigger.isConnected) { + trigger.focus(); + } }, }; this._dropdownVisible = true; @@ -252,6 +301,40 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { { minWidth: 240, maxWidth: 460, widgetClassName: 'show-file-icons' }, ); } + + private _getDropdownItems(sections = this._sections.get().filter(section => section.entries.length > 0)): IActionListItem[] { + const items: IActionListItem[] = []; + for (const section of sections) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ kind: ActionListItemKind.Header, label: section.title, group: { title: section.title } }); + for (const entry of section.entries) { + items.push({ + kind: ActionListItemKind.Action, + label: entry.label, + group: { title: '', ...(entry.icon ? { icon: entry.icon } : {}) }, + ...(entry.resource ? { iconClasses: getIconClasses(this._modelService, this._languageService, entry.resource, FileKind.FILE) } : {}), + ...(entry.toolbarActions?.length ? { toolbarActions: [...entry.toolbarActions] } : {}), + ariaDescription: entry.ariaDescription, + hover: entry.hover, + item: entry, + }); + } + } + return items; + } + + override dispose(): void { + if (this._dropdownVisible) { + this._actionWidgetService.hide(true); + } + super.dispose(); + } +} + +function iconsEqual(first: ThemeIcon | undefined, second: ThemeIcon | undefined): boolean { + return first === second || (!!first && !!second && ThemeIcon.isEqual(first, second)); } /** @@ -275,8 +358,16 @@ export function createChatSectionPill( return entry?.resource ? entry : undefined; }); const isResource = derived(reader => !!singleResourceEntry.read(reader)); + const resourcePill: IChatPill = { + action, + createActionViewItem: viewItemOptions => new ChatResourcePillActionViewItem(action, viewItemOptions, singleResourceEntry, resourceLabels), + }; + const dropdownPill: IChatPill = { + action, + createActionViewItem: viewItemOptions => instantiationService.createInstance(ChatDropdownPillActionViewItem, action, viewItemOptions, sections, options), + }; return derived(reader => isResource.read(reader) - ? { action, createActionViewItem: viewItemOptions => new ChatResourcePillActionViewItem(action, viewItemOptions, singleResourceEntry, resourceLabels) } - : { action, createActionViewItem: viewItemOptions => instantiationService.createInstance(ChatDropdownPillActionViewItem, action, viewItemOptions, sections, options) }); + ? resourcePill + : dropdownPill); } diff --git a/src/vs/workbench/browser/chatPills.ts b/src/vs/workbench/browser/chatPills.ts index 784537fe2381fa..c8601a174b3d2f 100644 --- a/src/vs/workbench/browser/chatPills.ts +++ b/src/vs/workbench/browser/chatPills.ts @@ -3,16 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, EventType, reset } from '../../base/browser/dom.js'; +import { $, addDisposableListener, DisposableResizeObserver, EventHelper, EventType, isHTMLElement, reset } from '../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../base/browser/keyboardEvent.js'; +import { mainWindow, type CodeWindow } from '../../base/browser/window.js'; import { IActionViewItem } from '../../base/browser/ui/actionbar/actionbar.js'; import { BaseActionViewItem, IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../base/browser/ui/button/button.js'; +import type { IManagedHoverContent } from '../../base/browser/ui/hover/hover.js'; +import { DomScrollableElement } from '../../base/browser/ui/scrollbar/scrollableElement.js'; import { ToolBar } from '../../base/browser/ui/toolbar/toolbar.js'; import { IAction, IActionRunner } from '../../base/common/actions.js'; +import { disposableTimeout } from '../../base/common/async.js'; import { Emitter, Event } from '../../base/common/event.js'; +import { MarkdownString } from '../../base/common/htmlContent.js'; +import { KeyCode } from '../../base/common/keyCodes.js'; import { isMacintosh } from '../../base/common/platform.js'; -import { Disposable } from '../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../base/common/lifecycle.js'; import { autorun, derived, IObservable } from '../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../base/common/scrollable.js'; import { ThemeIcon } from '../../base/common/themables.js'; import { URI } from '../../base/common/uri.js'; import { localize } from '../../nls.js'; @@ -41,6 +49,8 @@ export interface IChatPillsModel { export interface IChatPillEntry { readonly id: string; readonly label: string; + /** Short label used when this entry renders as the pill itself. */ + readonly pillLabel?: string; readonly icon?: ThemeIcon; /** Renders the entry with its resource's themed file icon. */ readonly resource?: URI; @@ -54,6 +64,8 @@ export interface IChatPillEntry { readonly hover?: IActionListItemHover; /** Tooltip for the pill when this is the only entry. */ readonly tooltip?: string; + /** Rich hover content for the pill when this is the only entry. */ + readonly pillHover?: IManagedHoverContent; open(): void; } @@ -63,6 +75,17 @@ export interface IChatPillSection { readonly entries: readonly IChatPillEntry[]; } +/** Describes a pill entry's target while keeping its accessible name action-oriented. */ +export function getChatPillResourceLocation(uri: URI, label: string, ariaLabel = localize('chatPills.open', "Open {0}", label)): Pick { + const value = uri.toString(true); + return { + ariaDescription: value, + ariaLabel, + hover: { content: new MarkdownString().appendText(value) }, + tooltip: value, + }; +} + export function getChatPillEntries(sections: readonly IChatPillSection[]): readonly IChatPillEntry[] { return sections.flatMap(section => section.entries); } @@ -77,6 +100,169 @@ export interface IChatPillsWidgetOptions { readonly allowContextMenu?: boolean; } +/** + * The floating row's rendered height: 2px/4px vertical padding around a 22px + * small button. Hosts reserve this much transcript space while the row is shown. + */ +export const CHAT_INPUT_PILLS_ROW_HEIGHT = 28; + +export type ChatPillsCompactMode = boolean | 'auto'; + +export interface IChatPillsRowOptions { + /** Collapses pills to their icons and uses tighter spacing while preserving full accessible labels and tooltips. */ + readonly compact?: ChatPillsCompactMode; + /** Window that owns the row. Required when rendering in an auxiliary window. */ + readonly targetWindow?: CodeWindow; +} + +/** Shared horizontally scrollable row for pills mounted above a chat input. */ +export class ChatPillsRow extends Disposable { + + readonly element: HTMLElement; + readonly content: HTMLElement; + + private readonly _scrollable: DomScrollableElement; + private readonly _resizeObserver: DisposableResizeObserver; + private readonly _mutationObserver: MutationObserver | undefined; + private _expandedContentWidth: number | undefined; + private _isLayouting = false; + private readonly _onDidChangeLayout = this._register(new Emitter()); + readonly onDidChangeLayout: Event = this._onDidChangeLayout.event; + private readonly _onDidRequestContextMenu = this._register(new Emitter()); + readonly onDidRequestContextMenu: Event = this._onDidRequestContextMenu.event; + private readonly _pendingFocus = this._register(new MutableDisposable()); + + constructor(debugName: string, options?: IChatPillsRowOptions) { + super(); + + const targetWindow = options?.targetWindow ?? mainWindow; + this.content = $('.chat-pills-row-content'); + this._scrollable = this._register(new DomScrollableElement(this.content, { + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize: 6, + scrollYToX: true, + vertical: ScrollbarVisibility.Hidden, + })); + this.element = this._scrollable.getDomNode(); + this.element.classList.add('chat-pills-row'); + const compactMode = options?.compact ?? false; + this.element.classList.toggle('compact', compactMode === true); + + this._resizeObserver = this._register(new DisposableResizeObserver(debugName, entries => { + if (entries.some(entry => entry.target !== this.content)) { + this._expandedContentWidth = undefined; + } + this.layout(); + }, targetWindow)); + this._register(this._resizeObserver.observe(this.content)); + if (compactMode === 'auto') { + this._mutationObserver = new targetWindow.MutationObserver(() => { + this._expandedContentWidth = undefined; + this.layout(); + }); + this._observeMutations(); + this._register({ dispose: () => this._mutationObserver?.disconnect() }); + } else { + this._mutationObserver = undefined; + } + this._register(this._scrollable.onScroll(event => { + if (event.scrollLeftChanged) { + this._onDidChangeLayout.fire(); + } + })); + this._register(addDisposableListener(this.content, EventType.FOCUS_IN, () => this.scanDomNode())); + this._register(addDisposableListener(this.content, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + const target = isHTMLElement(event.target) ? event.target : this.content; + const activatesEmptyRow = target === this.content && (keyboardEvent.keyCode === KeyCode.Enter || keyboardEvent.keyCode === KeyCode.Space); + if (activatesEmptyRow + || keyboardEvent.keyCode === KeyCode.ContextMenu + || (keyboardEvent.shiftKey && keyboardEvent.keyCode === KeyCode.F10)) { + EventHelper.stop(event, true); + this._onDidRequestContextMenu.fire(target); + } + })); + } + + observe(element: HTMLElement): void { + this._register(this._resizeObserver.observe(element)); + this.layout(); + } + + layout(): void { + if (this._isLayouting || !this.element.isConnected) { + return; + } + + this._isLayouting = true; + this._mutationObserver?.disconnect(); + try { + if (this._mutationObserver) { + const availableWidth = this.element.getBoundingClientRect().width; + if (this._expandedContentWidth === undefined || !this.element.classList.contains('compact')) { + const wasCompact = this.element.classList.contains('compact'); + this.element.classList.remove('compact'); + const expandedContentWidth = [...this.content.children].reduce((width, child) => { + return isHTMLElement(child) ? Math.max(width, child.offsetLeft + child.offsetWidth) : width; + }, 0); + if (expandedContentWidth > 0 || this.content.children.length === 0) { + this._expandedContentWidth = expandedContentWidth; + } + this.element.classList.toggle('compact', wasCompact); + } + this.element.classList.toggle('compact', availableWidth > 0 && this._expandedContentWidth !== undefined && this._expandedContentWidth > availableWidth + 1); + } + this.scanDomNode(); + this._onDidChangeLayout.fire(); + } finally { + this._observeMutations(); + this._isLayouting = false; + } + } + + scanDomNode(): void { + this._scrollable.scanDomNode(); + } + + private _observeMutations(): void { + this._mutationObserver?.observe(this.content, { + attributes: true, + attributeFilter: ['class', 'hidden', 'style'], + characterData: true, + childList: true, + subtree: true, + }); + } + + setEmpty(empty: boolean, ariaLabel: string): void { + this.element.classList.toggle('empty', empty); + if (empty) { + this.content.tabIndex = 0; + this.content.setAttribute('role', 'button'); + this.content.setAttribute('aria-label', ariaLabel); + this.content.setAttribute('aria-haspopup', 'menu'); + } else { + this.content.removeAttribute('tabindex'); + this.content.removeAttribute('role'); + this.content.removeAttribute('aria-label'); + this.content.removeAttribute('aria-haspopup'); + } + } + + restoreFocus(getPillElements: () => readonly HTMLElement[], fallback?: () => void): void { + this._pendingFocus.value = disposableTimeout(() => { + const pill = getPillElements().at(0); + if (pill) { + pill.focus(); + } else if (this.element.classList.contains('empty')) { + this.content.focus(); + } else { + fallback?.(); + } + }); + } +} + /** * A reusable horizontal toolbar whose pill set and action context are observable. */ @@ -86,12 +272,12 @@ export class ChatPillsWidget extends Disposable { readonly isVisible: IObservable; private readonly _onDidChangePills = this._register(new Emitter()); readonly onDidChangePills: Event = this._onDidChangePills.event; + private readonly _onDidRemoveFocusedPill = this._register(new Emitter()); + readonly onDidRemoveFocusedPill: Event = this._onDidRemoveFocusedPill.event; - private readonly _toolbar: ToolBar; + private readonly _toolbar: ChatPillsToolBar; private _pillByAction = new Map(); private _pills: readonly IChatPill[] = []; - private _pillViewItems: ChatPillActionViewItemBase[] = []; - constructor( model: IChatPillsModel, options: IChatPillsWidgetOptions | undefined, @@ -100,15 +286,12 @@ export class ChatPillsWidget extends Disposable { super(); this.element = $('.chat-pills.hidden'); - this._toolbar = this._register(new ToolBar(this.element, contextMenuService, { + this._toolbar = this._register(new ChatPillsToolBar(this.element, contextMenuService, { ariaLabel: options?.ariaLabel ?? localize('chatPills.ariaLabel', "Chat status"), actionRunner: options?.actionRunner, allowContextMenu: options?.allowContextMenu, actionViewItemProvider: (action, viewItemOptions) => { const viewItem = this._pillByAction.get(action)?.createActionViewItem?.(viewItemOptions) ?? new ChatPillActionViewItem(undefined, action, viewItemOptions); - if (viewItem instanceof ChatPillActionViewItemBase) { - this._pillViewItems.push(viewItem); - } return viewItem; }, })); @@ -120,9 +303,32 @@ export class ChatPillsWidget extends Disposable { this._toolbar.context = model.context?.read(reader); const pillsChanged = pills.length !== this._pills.length || pills.some((pill, index) => pill !== this._pills[index]); if (pillsChanged) { + const focusedPill = this._pills.find((_pill, index) => { + const viewItem = this._toolbar.getItemViewItem(index); + return viewItem instanceof ChatPillActionViewItemBase && viewItem.isFocused(); + }); + const expandedPill = focusedPill ? undefined : this._pills.find((_pill, index) => { + const viewItem = this._toolbar.getItemViewItem(index); + return viewItem instanceof ChatPillActionViewItemBase && viewItem.buttonElement?.getAttribute('aria-expanded') === 'true'; + }); + const focusOwner = focusedPill ?? expandedPill; + const focusedAction = focusOwner?.action; + const focusedIndexBeforeUpdate = focusOwner ? this._pills.indexOf(focusOwner) : -1; + const previousPills = this._pills; this._pills = pills; - this._pillViewItems = []; - this._toolbar.setActions(pills.map(pill => pill.action)); + this._toolbar.setPills(previousPills, pills); + const expandedPillPreserved = expandedPill && pills.includes(expandedPill); + if (!expandedPillPreserved) { + let focusedIndex = focusedAction ? pills.findIndex(pill => pill.action === focusedAction) : -1; + if (focusedIndex < 0 && focusedIndexBeforeUpdate >= 0 && pills.length > 0) { + focusedIndex = Math.min(focusedIndexBeforeUpdate, pills.length - 1); + } + if (focusedIndex >= 0) { + this._toolbar.focus(focusedIndex); + } else if (focusedIndexBeforeUpdate >= 0) { + this._onDidRemoveFocusedPill.fire(); + } + } } this.element.classList.toggle('hidden', pills.length === 0); if (pillsChanged) { @@ -133,7 +339,14 @@ export class ChatPillsWidget extends Disposable { /** Returns the rendered button for each pill. */ getPillElements(): readonly HTMLElement[] { - return this._pillViewItems.flatMap(viewItem => viewItem.buttonElement ? [viewItem.buttonElement] : []); + const elements: HTMLElement[] = []; + for (let index = 0; index < this._toolbar.getItemsLength(); index++) { + const viewItem = this._toolbar.getItemViewItem(index); + if (viewItem instanceof ChatPillActionViewItemBase && viewItem.buttonElement) { + elements.push(viewItem.buttonElement); + } + } + return elements; } /** @@ -150,6 +363,48 @@ export class ChatPillsWidget extends Disposable { } } +/** Updates only the changed middle of a pill toolbar so stable pills keep their DOM and focus. */ +class ChatPillsToolBar extends ToolBar { + setPills(previous: readonly IChatPill[], next: readonly IChatPill[]): void { + let prefix = 0; + while (prefix < previous.length && prefix < next.length && previous[prefix] === next[prefix]) { + prefix++; + } + + let suffix = 0; + while (suffix < previous.length - prefix + && suffix < next.length - prefix + && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) { + suffix++; + } + + for (let index = previous.length - suffix - 1; index >= prefix; index--) { + this.actionBar.pull(index); + } + for (let index = prefix; index < next.length - suffix; index++) { + this.actionBar.push(next[index].action, { icon: true, label: false, index }); + } + let focusedIndex = -1; + for (let index = 0; index < this.getItemsLength(); index++) { + const viewItem = this.getItemViewItem(index); + if (viewItem instanceof ChatPillActionViewItemBase && viewItem.isFocused()) { + focusedIndex = index; + break; + } + } + let focusableSet = false; + for (let index = 0; index < this.getItemsLength(); index++) { + const viewItem = this.getItemViewItem(index); + if (!(viewItem instanceof BaseActionViewItem)) { + continue; + } + const focusable: boolean = focusedIndex >= 0 ? index === focusedIndex : !focusableSet && viewItem.isEnabled(); + viewItem.setFocusable(focusable); + focusableSet ||= focusable; + } + } +} + /** Opaque base so a pill never shows the content it floats over; `chatPills.css` tints it. */ const chatPillBackground = asCssVariableWithDefault('chat.list.background', asCssVariable(buttonSecondaryBackground)); @@ -275,9 +530,7 @@ export abstract class ChatPillActionViewItemBase extends BaseActionViewItem { } } -/** - * Compact `icon + label` rendering, the default for chat pill actions. - */ +/** The default `icon + label` rendering for chat pill actions. */ export class ChatPillActionViewItem extends ChatPillActionViewItemBase { constructor(context: unknown, action: IAction, options: IActionViewItemOptions) { diff --git a/src/vs/workbench/browser/chatResourcePill.ts b/src/vs/workbench/browser/chatResourcePill.ts index 27df706b631449..3b3ae934dbc9aa 100644 --- a/src/vs/workbench/browser/chatResourcePill.ts +++ b/src/vs/workbench/browser/chatResourcePill.ts @@ -3,11 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $ } from '../../base/browser/dom.js'; import { IActionViewItemOptions } from '../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../base/browser/ui/button/button.js'; import { IAction } from '../../base/common/actions.js'; +import { Codicon } from '../../base/common/codicons.js'; import { onUnexpectedError } from '../../base/common/errors.js'; import { IObservable, autorun } from '../../base/common/observable.js'; +import { ThemeIcon } from '../../base/common/themables.js'; import { localize } from '../../nls.js'; import { FileKind } from '../../platform/files/common/files.js'; import { ChatPillActionViewItemBase, type IChatPillEntry } from './chatPills.js'; @@ -32,6 +35,7 @@ export class ChatResourcePillActionViewItem extends ChatPillActionViewItemBase { } protected override renderContent(button: Button): void { + button.element.appendChild($(`span.chat-pill-icon.chat-resource-pill-compact-icon${ThemeIcon.asCSSSelector(Codicon.file)}`, { 'aria-hidden': 'true' })); const label = this._register(this._resourceLabels.create(button.element)); this._register(autorun(reader => { const entry = this._entry.read(reader); @@ -40,6 +44,11 @@ export class ChatResourcePillActionViewItem extends ChatPillActionViewItemBase { } this.updateTooltip(); this.updateAriaLabel(); + if (entry?.ariaDescription) { + button.element.setAttribute('aria-description', entry.ariaDescription); + } else { + button.element.removeAttribute('aria-description'); + } })); } diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index c82a6dff8bb6f0..ac98af9b7ff6f1 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -132,6 +132,10 @@ padding-left: var(--vscode-spacing-size20); } +.chat-pill-item .monaco-button.chat-resource-pill-button .chat-resource-pill-compact-icon { + display: none; +} + .chat-resource-pill-button .monaco-icon-label { align-items: center; min-width: 0; @@ -164,3 +168,93 @@ background-repeat: no-repeat; background-position: center center; } + +/* Horizontally scrollable status pills above a chat input. */ +.chat-pills-row { + width: 100%; + min-width: 0; +} + +.chat-pills-row-content { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--vscode-spacing-size60); + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40) 0; +} + +.chat-pills-row-content > .chat-pills { + flex-shrink: 0; +} + +.chat-pills-row-content > * { + pointer-events: auto; +} + +.chat-pills-row.hidden { + display: none; +} + +.chat-pills-row.compact .chat-pills-row-content, +.chat-pills-row.compact .chat-pills .monaco-action-bar .actions-container { + gap: var(--vscode-spacing-size40); +} + +.chat-pills-row.compact .chat-pill-item .monaco-button.chat-pill-button { + gap: var(--vscode-spacing-size20); + padding-left: var(--vscode-spacing-size40); + padding-right: var(--vscode-spacing-size40); +} + +/* Compact rows are truly collapsed rather than merely tighter: all current + pill renderers keep their identifying icon while labels, counters and dropdown + chevrons remain available through the button's tooltip and accessible name. */ +.chat-pills-row.compact .chat-pill-label, +.chat-pills-row.compact .monaco-animated-counter, +.chat-pills-row.compact .chat-pill-chevron, +.chat-pills-row.compact .chat-resource-pill-button .monaco-icon-label { + display: none; +} + +.chat-pills-row.compact .chat-pill-item .monaco-button.chat-resource-pill-button .chat-resource-pill-compact-icon { + display: inline-flex; +} + +/* Keep the visibility menu reachable when every pill with data is hidden. */ +.chat-pills-row.empty { + pointer-events: auto; +} + +.chat-pills-row.empty .chat-pills-row-content { + min-height: var(--vscode-spacing-size120); +} + +.chat-pills-row.empty .chat-pills-row-content:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); + border-radius: var(--vscode-cornerRadius-small); +} + +/* The floating row is click-through, so its slider is informational only. */ +.chat-pills-row > .scrollbar > .slider { + background: transparent; +} + +.chat-pills-row > .scrollbar.horizontal > .slider::before { + content: ''; + position: absolute; + inset: var(--vscode-strokeThickness); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-scrollbarSlider-background); +} + +.chat-pills-row > .scrollbar > .slider:hover::before { + background: var(--vscode-scrollbarSlider-hoverBackground); +} + +.chat-pills-row > .scrollbar > .slider.active::before { + background: var(--vscode-scrollbarSlider-activeBackground); +} diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index d1cd8916f9dd3f..20d856e1078b23 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -307,18 +307,26 @@ /* * Center the full-size action targets in the card's lane (see `--modern-ui-activitybar-lane`). * The card's two 1px borders shrink its content box, so they come off the lane before it is - * halved — otherwise the items sit 1px off-center. + * halved — otherwise the items sit 1px off-center. Avoid a transform here so fixed menu + * descendants remain viewport-positioned instead of being contained and clipped by the rail. */ .monaco-workbench.floating-panels .part.activitybar > .content { - transform: translateX(calc((var(--modern-ui-activitybar-lane) - 2px) / 2)); + position: relative; + left: calc((var(--modern-ui-activitybar-lane) - 2px) / 2); } /* * Inset the first and last item from the card's ends by the same amount as the sides. The * horizontal inset is half the lane, of which the card's border already contributes one - * stroke, so the remainder is what these margins have to supply. Overrides the fixed values - * in `modernUI/browser/media/padding.css`, which predate the lane. + * stroke. The compact menu reserves its inset as padding so its measured height includes it; + * the composite bar uses the same value as its leading margin. Overrides the fixed values in + * `modernUI/browser/media/padding.css`, which predate the lane. */ +.monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .menubar.compact { + height: calc(var(--activity-bar-action-height, 36px) + (var(--modern-ui-activitybar-lane) - 2px) / 2); + padding-top: calc((var(--modern-ui-activitybar-lane) - 2px) / 2); +} + .monaco-workbench.floating-panels .part.activitybar:not(.top):not(.bottom) > .content > .composite-bar { margin-top: calc(var(--modern-ui-activitybar-lane) / 2 - var(--vscode-strokeThickness)); } 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/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/browser/parts/editor/editorTypePicker.ts b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts index 071604e8d7c414..ba5a156f5aa4ed 100644 --- a/src/vs/workbench/browser/parts/editor/editorTypePicker.ts +++ b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts @@ -4,13 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; -import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, SideBySideEditor, isDiffEditorInput, isEditorInputWithDiffResources } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorResolverService, RegisteredEditorInfo } from '../../../services/editor/common/editorResolverService.js'; +import { EditorMatches, IEditorResolverService, isUnconfiguredUniversalOptionalEditorMatch, RegisteredEditorInfo } from '../../../services/editor/common/editorResolverService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID } from './editorCommands.js'; @@ -24,13 +23,14 @@ export interface IAvailableEditorTypes { readonly originalResource?: URI; readonly modifiedResource?: URI; readonly currentId: string; + readonly editorMatches: EditorMatches; readonly editors: RegisteredEditorInfo[]; } /** * Determines the editors available for the given active editor's resource. Returns `undefined` when * there is nothing meaningful to switch between: no resource, only the default text editor, or an - * exclusive editor (e.g. the hex editor, for which `getEditors` returns an empty list). + * exclusive editor (e.g. the hex editor). */ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undefined, editorResolverService: IEditorResolverService, hiddenEditorIds?: readonly string[]): IAvailableEditorTypes | undefined { const standardDiffResources = isDiffEditorInput(activeEditor) ? { @@ -44,12 +44,14 @@ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undef } const currentId = activeEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id; const hiddenEditorIdSet = new Set(hiddenEditorIds); - const editors = editorResolverService.getEditors(resource, { - excludeUnconfiguredUniversalOptionalEditors: true, - currentEditorId: currentId, + const editorMatches = editorResolverService.getEditorMatches(resource, { isDiffEditor: !!diffResources, - }).filter(editor => editor.id === currentId || !hiddenEditorIdSet.has(editor.id)); - if (editors.length <= 1) { + }); + const editors = editorMatches.matches + .filter(match => !isUnconfiguredUniversalOptionalEditorMatch(match) || match.editor.id === currentId) + .map(match => match.editor) + .filter(editor => editor.id === currentId || !hiddenEditorIdSet.has(editor.id)); + if (editorMatches.hasExclusiveMatch || editors.length <= 1) { return undefined; } return { @@ -58,6 +60,7 @@ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undef originalResource: diffResources?.original, modifiedResource: diffResources?.modified, currentId, + editorMatches, editors }; } @@ -84,7 +87,8 @@ export function createEditorTypeActions( commandService: ICommandService, editorService: IEditorService ): IAction[] { - const glob = `*${extUri.extname(available.resource)}`; + const defaultRule = available.editorMatches.defaultRule; + const glob = defaultRule.associationPattern; // Show the contributing extension in parentheses, but only for extension-provided editors. // Built-in providers share this localized label, so their (redundant) source is omitted. @@ -106,15 +110,14 @@ export function createEditorTypeActions( // Persist the chosen editor as the default for this file type. For diffs this updates the // specialized `workbench.diffEditorAssociations` setting instead of the general one. The - // currently configured default (if any) is checked. Setting a default also reopens the active + // effective default is checked. Setting a default also reopens the active // editor with that type so the change takes effect immediately. - const configuredDefault = editorResolverService.getConfiguredDefaultEditor(available.resource, available.isDiffEditor); const setDefaultActions: IAction[] = available.editors.map(editor => toAction({ id: `setDefault.${editor.id}`, label: labelWithSource(editor), - checked: editor.id === configuredDefault, + checked: editor.id === defaultRule.editor.id, run: () => { - editorResolverService.updateUserAssociations(glob, editor.id, available.isDiffEditor); + editorResolverService.setDefaultEditor(available.resource, editor.id, available.isDiffEditor); return commandService.executeCommand(REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, editor.id); } })); diff --git a/src/vs/workbench/common/chatPullRequest.ts b/src/vs/workbench/common/chatPullRequest.ts new file mode 100644 index 00000000000000..c2ca02cad41cbb --- /dev/null +++ b/src/vs/workbench/common/chatPullRequest.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../base/common/codicons.js'; +import { themeColorFromId, ThemeIcon } from '../../base/common/themables.js'; + +export type ChatPullRequestState = 'open' | 'closed' | 'merged' | 'draft'; + +/** + * Additional live status used to refine the icon of an open pull request. + */ +export interface IPullRequestIconStatus { + /** Whether the pull request has merge conflicts. */ + readonly hasMergeConflicts?: boolean; + /** Whether the pull request has at least one failing CI check. */ + readonly hasFailingChecks?: boolean; + /** Whether the pull request has at least one unresolved review comment thread. */ + readonly hasUnresolvedComments?: boolean; +} + +/** + * Computes the shared pull request glyph and state color used by chat/session pills. + */ +export function computePullRequestIcon(state: ChatPullRequestState, status?: IPullRequestIconStatus): ThemeIcon { + switch (state) { + case 'merged': + return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; + case 'closed': + return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; + case 'draft': + return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; + case 'open': + if (status?.hasMergeConflicts || status?.hasFailingChecks) { + return { ...Codicon.gitPullRequestError, color: themeColorFromId('charts.orange') }; + } + if (status?.hasUnresolvedComments) { + return { ...Codicon.gitPullRequestComment, color: themeColorFromId('charts.green') }; + } + return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; + } +} + +const pullRequestIconPriority = new Map([ + [Codicon.gitPullRequestError.id, 6], + [Codicon.gitPullRequestComment.id, 5], + [Codicon.gitPullRequest.id, 4], + [Codicon.gitPullRequestDraft.id, 3], + [Codicon.gitPullRequestDone.id, 2], + [Codicon.gitPullRequestClosed.id, 1], +]); + +/** Returns the most important status icon across a set of pull requests. */ +export function getHighestPriorityPullRequestIcon(icons: readonly (ThemeIcon | undefined)[]): ThemeIcon | undefined { + let result: ThemeIcon | undefined; + let resultPriority = -1; + for (const icon of icons) { + if (!icon) { + continue; + } + const priority = pullRequestIconPriority.get(icon.id) ?? 0; + if (priority > resultPriority) { + result = icon; + resultPriority = priority; + } + } + return result; +} 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..ef36e2f7a8f12e --- /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 = $('h2.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..5339c5ab52e017 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/browser/media/browserWelcome.css @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * 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: 0; + 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/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index c8ebc636de1e9e..b75785b204bdd0 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -176,11 +176,8 @@ export async function playwrightInvoke( /** * Past-tense label for a browser tool call that failed. * - * These tools declare only an `invocationMessage`, so on completion the - * present-tense label is reused verbatim and a failed call reads as a - * successful one ("Capturing browser screenshot"). Naming the failure keeps - * the completed state honest, as the agent host already does for client tool - * calls and the codex mapper does for its own results. + * Without one, a completed call keeps whatever label the tool prepared, so a + * failure reads as a success. */ const failedMessage = localize('browser.actionFailed', "Browser action failed"); @@ -217,9 +214,10 @@ export function invokeFunctionResultToToolResult(result: IInvokeFunctionResult, } export function errorResult(message: string): IToolResult { + const error = message || failedMessage; return { - content: [{ kind: 'text', value: message }], - toolResultError: message, + content: [{ kind: 'text', value: error }], + toolResultError: error, toolResultMessage: failedMessage, }; } diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts index 82473356997522..eaf6a2b83bdb4d 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/tools/browserToolHelpers.test.ts @@ -44,6 +44,14 @@ suite('browserToolHelpers', () => { assert.ok(result.toolResultMessage); }); + test('errorResult with an empty message still reports a failure', () => { + // `throw ''` and `new Error()` both reach here as an empty string. + const result = errorResult(''); + + assert.ok(result.toolResultError, 'an empty error message is still a failure'); + assert.ok(result.content.some(part => part.kind === 'text' && part.value), 'the model needs a non-empty reason'); + }); + test('browser context explains active network filtering', () => { const editorService = upcastPartial({ activeEditor: undefined, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 59e4716c4eba93..a86a086ef99461 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -21,7 +21,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { isStickyPromptHeaderShown } from '../promptTimeline/promptTimelineWidgetContrib.js'; import { FocusAgentSessionsAction } from '../agentSessions/agentSessionsActions.js'; import { AGENT_SESSION_RENAME_ACTION_ID } from '../agentSessions/agentSessions.js'; -import { IChatWidgetService } from '../chat.js'; +import { IChatWidgetService, isIChatResourceViewContext } from '../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../chatEditing/chatEditingActions.js'; export class PanelChatAccessibilityHelp implements IAccessibleViewImplementation { @@ -64,7 +64,7 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false, sessionStatusPillsSupported: boolean = type === 'panelChat' || type === 'agentView'): string { const content = []; if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files. Focus an additions and deletions label and press Enter or Space to open the changes in a diff editor.')); @@ -82,6 +82,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.openAgentHostFolderPicker', 'When starting an agent session in a multi-root workspace, you can choose which root folder it runs in by invoking the Folder command{0}, then selecting a folder from the list.', '')); content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); } + if (sessionStatusPillsSupported) { + content.push(localize('chat.sessionStatusPills', 'When session status pills appear above the input, use Tab to focus the toolbar, then use the left and right arrow keys to move between pills. Press Enter or Space to activate a pill. Open the context menu{0} to choose which optional pills are visible.', '')); + } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. One pet appears in whichever editor or Agents window is active. Drag it around the chat with the mouse and release it to drop it, or flick it in any direction to throw it along the gesture before gravity pulls it down. Pointer collisions are ignored for half a second after a drag release. After that, while the pet is falling, move the pointer into it to bounce it upward; pointer movement and where it catches the pet affect the bounce. Sideways and upward travel do not start the bounce counter. A counter beside the pet tracks consecutive bounces and remains for up to five seconds after landing, or until the pet next reacts or interacts. Landing with at least twenty bounces triggers confetti unless reduced motion is enabled. If it falls past the input, a despawn effect appears at the bottom and a respawn effect appears at the top before it automatically returns to the input. Moving the pointer rapidly between the pet\u2019s left and right sides makes it dizzy. With the keyboard, use Tab to focus the pet, then the left and right arrows to make it hop along the input until it reaches an edge. Hold Shift with the left or right arrow to throw it toward a wall; while it is airborne, press Enter or Space to bounce it upward. Rapidly alternate the unmodified arrows to make it dizzy. Press Enter or Space while it is resting to interact with it. When an achievement unlocks, the pet shows a gold star for ten seconds; activate the pet during that time to open Achievements. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Achievements, Go on the Run, Come Back, Grow, Shrink, Reset Size, Stable Colors, or Insiders Colors, and press Enter to activate the choice. Grow and Shrink change its size in twenty-percent steps, while Reset Size restores its default size. The pet position and selected size are shared across chats and windows and remembered after you restart.', '')); if (supportsFileReferences) { @@ -206,7 +209,8 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi const cachedPosition = inputEditor.getPosition(); inputEditor.getSupportedActions(); - const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService)); + const isInlineChat = isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isInlineChat; + const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService), !widget.rendersInputOnTop && !isInlineChat); return new AccessibleContentProvider( type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, 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 ecf4417386a857..b69a3520c8d980 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -12,6 +12,7 @@ import { autorun, type IObservable } from '../../../../../../base/common/observa 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'; @@ -319,7 +320,9 @@ 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, workingDirectories)) { items.set(child.itemKey ?? child.uri.toString(), { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index bf4571c1ec3ad0..c5fce8067b5bf0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -11,7 +11,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { AgentHostMcpServers, AgentHostMcpServersConfigKey } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -569,7 +569,11 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus const provisionalSession = this._provisionalSessionService.get(sessionResource); if (provisionalSession) { // Provisional (untitled) sessions are always backed by the ambient host. - return { connection: this._connectionsService.ambientConnection, backendSession: provisionalSession }; + return { + connection: this._connectionsService.ambientConnection, + connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, + backendSession: provisionalSession, + }; } if (isUntitledChatSession(sessionResource)) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts index 45f723b5be3fd1..732b3311a1b87b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -32,7 +32,7 @@ import { } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; -import { IChatResponseFileChangesProvider, IChatResponseFileEdit } from '../../chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesProvider, IChatResponseFileEdit } from '../../chatResponseFileChangesService.js'; const SUBSCRIPTION_OWNER = 'AgentHostResponseFileChangesProvider'; const REQUEST_CACHE_CAPACITY = 1000; @@ -43,6 +43,13 @@ const REQUEST_CACHE_CAPACITY = 1000; */ type TurnDiffSource = 'unsupported' | 'changeset' | 'authoritativeEmpty' | 'response' | 'branchFallback' | 'retained'; +interface IResponseFileEdits { + readonly diffs: readonly IChatResponseFileEdit[]; + readonly hasValidEdits: boolean; +} + +const EMPTY_RESPONSE_FILE_EDITS: IResponseFileEdits = { diffs: [], hasValidEdits: false }; + function uriArrayEquals(a: readonly URI[], b: readonly URI[]): boolean { return a.length === b.length && a.every((uri, index) => isEqual(uri, b[index])); } @@ -63,6 +70,36 @@ function getToolCallFileEdits(toolCall: ToolCallState): ISessionFileDiff[] { return edits; } +/** Maps one Agent Host changeset file into the diff shape used by chat editors. */ +export function agentHostChangesetFileToEntryDiff(file: ChangesetFile, connectionAuthority: string): IEditSessionEntryDiff | undefined { + const normalized = normalizeFileEdit(file.edit); + if (!normalized) { + return undefined; + } + + const modifiedURI = toAgentHostUri(normalized.resource, connectionAuthority); + const originalURI = normalized.beforeContentUri + ? toAgentHostContentUri(normalized.beforeContentUri, connectionAuthority) + : modifiedURI; + const modifiedSnapshotURI = normalized.afterContentUri + ? toAgentHostContentUri(normalized.afterContentUri, connectionAuthority) + : undefined; + + return { + originalURI, + modifiedURI, + modifiedSnapshotURI, + isCreated: normalized.kind === FileEditKind.Create, + isDeleted: normalized.kind === FileEditKind.Delete, + added: file.edit.diff?.added ?? 0, + removed: file.edit.diff?.removed ?? 0, + quitEarly: false, + identical: false, + isFinal: true, + isBusy: false, + }; +} + /** * Supplies the chat "Changed N files" summary for agent host responses from the * authoritative per-turn changeset the host computes server-side (the same @@ -122,7 +159,8 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const key = `${backendSession.toString()}\0${backendChat?.toString() ?? ''}\0${requestId}`; let obs = this._perRequestFileEdits.get(key); if (!obs) { - obs = this._createFileEditDiffsObservable(backendSession, backendChat, requestId); + const fileEdits = this._createFileEditDiffsObservable(backendSession, backendChat, requestId); + obs = derived(reader => fileEdits.read(reader).diffs); this._perRequestFileEdits.set(key, obs); } return obs; @@ -176,7 +214,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const changesetState = turnUri ? changesetStateObs.read(reader).read(reader) : undefined; const changeset = changesetState instanceof Error ? undefined : changesetState; const changesetDiffs = changeset?.files - .map(file => this._changesetFileToEntryDiff(file)) + .map(file => agentHostChangesetFileToEntryDiff(file, this._connectionAuthority)) .filter(isDefined); // A non-empty per-turn changeset is always authoritative (e.g. a turn // added after migration, which does have checkpoints), so it takes @@ -199,12 +237,12 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements return select('unsupported', retained); } if (changeset?.status === ChangesetStatus.Ready && retained.length === 0) { - return select('authoritativeEmpty', [], changeset.status); + return select('authoritativeEmpty', AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, changeset.status); } - const responseDiffs = responseFileEditsObs.read(reader); - return responseDiffs.length - ? select('response', responseDiffs, changeset?.status) + const responseFileEdits = responseFileEditsObs.read(reader); + return responseFileEdits.hasValidEdits + ? select('response', responseFileEdits.diffs.length > 0 ? responseFileEdits.diffs : AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, changeset?.status) : select('retained', retained, changeset?.status); }); } @@ -244,12 +282,12 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const state = branchChangesetStateObs.read(reader).read(reader); const changeset = state instanceof Error ? undefined : state; return changeset?.files - .map(file => this._changesetFileToEntryDiff(file)) + .map(file => agentHostChangesetFileToEntryDiff(file, this._connectionAuthority)) .filter(isDefined) ?? []; }); } - private _createFileEditDiffsObservable(backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { + private _createFileEditDiffsObservable(backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { const sessionStateObs = this._subscribe(StateComponents.Session, constObservable(backendSession)); const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); @@ -301,7 +339,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements return this._responsePartsToEntryDiffs(turn.responseParts, workspaceRoots); } } - return []; + return EMPTY_RESPONSE_FILE_EDITS; }); } @@ -323,8 +361,9 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements }); } - private _responsePartsToEntryDiffs(responseParts: readonly ResponsePart[], workspaceRoots: readonly URI[]): IChatResponseFileEdit[] { + private _responsePartsToEntryDiffs(responseParts: readonly ResponsePart[], workspaceRoots: readonly URI[]): IResponseFileEdits { const byUri = new Map(); + let hasValidEdits = false; for (const responsePart of responseParts) { if (responsePart.kind !== ResponsePartKind.ToolCall) { continue; @@ -334,27 +373,35 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements if (!diff) { continue; } + hasValidEdits = true; const key = getComparisonKey(diff.modifiedURI); const existing = byUri.get(key); if (existing) { existing.added += diff.added; existing.removed += diff.removed; + existing.modifiedURI = diff.modifiedURI; + existing.modifiedSnapshotURI = diff.modifiedSnapshotURI; + existing.isDeleted = diff.isDeleted; + // A file created and then deleted within the turn has no net diff to present. + if (existing.isCreated && existing.isDeleted) { + byUri.delete(key); + } } else { byUri.set(key, diff); } } } - return [...byUri.values()]; + return { diffs: [...byUri.values()], hasValidEdits }; } private _fileEditToEntryDiff(fileEdit: ISessionFileDiff, workspaceRoots: readonly URI[]): IChatResponseFileEdit | undefined { const normalized = normalizeFileEdit(fileEdit); - if (!normalized || !normalized.afterUri) { + if (!normalized) { return undefined; } - const afterUri = normalized.afterUri; + const resource = normalized.resource; - const modifiedURI = toAgentHostUri(afterUri, this._connectionAuthority); + const modifiedURI = toAgentHostUri(resource, this._connectionAuthority); const originalURI = normalized.kind === FileEditKind.Create || !normalized.beforeContentUri ? modifiedURI : toAgentHostContentUri(normalized.beforeContentUri, this._connectionAuthority); @@ -366,52 +413,16 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements originalURI, modifiedURI, modifiedSnapshotURI, + isCreated: normalized.kind === FileEditKind.Create, + isDeleted: normalized.kind === FileEditKind.Delete, added: fileEdit.diff?.added ?? 0, removed: fileEdit.diff?.removed ?? 0, quitEarly: false, identical: false, isFinal: true, isBusy: false, - isOutsideWorkspace: !workspaceRoots.some(root => isEqualOrParent(afterUri, root)), + isOutsideWorkspace: !workspaceRoots.some(root => isEqualOrParent(resource, root)), }; } - private _changesetFileToEntryDiff(file: ChangesetFile): IEditSessionEntryDiff | undefined { - const normalized = normalizeFileEdit(file.edit); - if (!normalized) { - return undefined; - } - - const modifiedURI = toAgentHostUri(normalized.resource, this._connectionAuthority); - // For creates there is no before-content; fall back to the modified URI - // so the entry still resolves. The collapsed summary uses the - // server-provided counts below, so its +/- numbers stay correct - // regardless; only an explicitly-opened diff of a created file shows no - // delta. - const originalURI = normalized.beforeContentUri - ? toAgentHostContentUri(normalized.beforeContentUri, this._connectionAuthority) - : modifiedURI; - - // The frozen after-turn snapshot, when the changeset provides one. Lets - // consumers show this turn's diff (before-snapshot -> after-snapshot) - // rather than before-snapshot -> live file (which includes later turns). - // Distinct from the checkpoint-ref readability fix (#323932): that made - // these blobs readable; this line decides *which* snapshot to diff against. - const modifiedSnapshotURI = normalized.afterContentUri - ? toAgentHostContentUri(normalized.afterContentUri, this._connectionAuthority) - : undefined; - - return { - originalURI, - modifiedURI, - modifiedSnapshotURI, - isDeleted: normalized.kind === FileEditKind.Delete, - added: file.edit.diff?.added ?? 0, - removed: file.edit.diff?.removed ?? 0, - quitEarly: false, - identical: false, - isFinal: true, - isBusy: false, - }; - } } 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/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts new file mode 100644 index 00000000000000..ae8797faaea5a7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -0,0 +1,551 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../../../base/browser/dom.js'; +import { toAction } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, derived, derivedObservableWithCache, derivedOpts, observableFromEvent, observableSignal, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { isDefined } from '../../../../../../base/common/types.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { resolveChangesetUriTemplate, selectDefaultChangeset, type DefaultChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { ISessionArtifact, isGitHubArtifactLink, readSessionArtifacts, SessionArtifactType } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillEntries, getChatPillResourceLocation, IChatPillEntry, IChatPillSection, type ChatPillsCompactMode } from '../../../../../browser/chatPills.js'; +import { chatChangesStatsEqual, EMPTY_CHAT_CHANGES_STATS, IChatChangesStats } from '../../../../../browser/chatChangesPill.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { computePullRequestIcon, getHighestPriorityPullRequestIcon } from '../../../../../common/chatPullRequest.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { chatPersistentContentVisibleClass, type ChatWidget } from '../../widget/chatWidget.js'; +import { observeTurnStatusPillsEnabled, openChatTurnFile, previewKind } from '../../widget/chatTurnPills.js'; +import { openChatFileChanges } from '../../editorChatResponseFileChangesService.js'; +import { ChatInputPills, StandardChatInputPillSources } from '../../chatInputPills.js'; +import { agentHostChangesetFileToEntryDiff } from './agentHostResponseFileChanges.js'; + +const offeredPillKinds: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Artifacts, + SessionChatPillKind.References, + SessionChatPillKind.Browsers, +]; + +const artifactIcons: ReadonlyMap = new Map([ + [SessionArtifactType.PullRequest, Codicon.gitPullRequest], + [SessionArtifactType.Issue, Codicon.issues], + [SessionArtifactType.Commit, Codicon.gitCommit], + [SessionArtifactType.Website, Codicon.globe], + [SessionArtifactType.Resource, Codicon.link], +]); + +const artifactSectionOrder: readonly { readonly type: SessionArtifactType; readonly title: string }[] = [ + { type: SessionArtifactType.PullRequest, title: localize('agentHostSessionPills.artifacts.pullRequests', "Pull Requests") }, + { type: SessionArtifactType.Issue, title: localize('agentHostSessionPills.artifacts.issues', "Issues") }, + { type: SessionArtifactType.Commit, title: localize('agentHostSessionPills.artifacts.commits', "Commits") }, + { type: SessionArtifactType.Website, title: localize('agentHostSessionPills.artifacts.websites', "Websites") }, + { type: SessionArtifactType.File, title: localize('agentHostSessionPills.artifacts.files', "Files") }, + { type: SessionArtifactType.Resource, title: localize('agentHostSessionPills.artifacts.resources', "Resources") }, +]; + +export interface IAgentHostSessionPillMetadata { + readonly pullRequestUrls: readonly string[]; + readonly issueUrls: readonly string[]; + readonly artifacts: readonly ISessionArtifact[]; + readonly references: readonly ISessionArtifact[]; +} + +function linkKey(link: string): string { + return link.replace(/\/+$/, '').toLowerCase(); +} + +function dedupeLinks(...groups: readonly (readonly string[] | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const group of groups) { + for (const link of group ?? []) { + const key = linkKey(link); + if (!seen.has(key)) { + seen.add(key); + result.push(link); + } + } + } + return result; +} + +function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { + return first === second || (first.size === second.size && [...first].every(value => second.has(value))); +} + +function isPromotedArtifact(artifact: ISessionArtifact, type: SessionArtifactType): artifact is ISessionArtifact & { readonly link: string } { + return artifact.isArtifact + && artifact.type === type + && artifact.isGitHub === true + && typeof artifact.link === 'string' + && isGitHubArtifactLink(artifact.link); +} + +/** Partitions Agent Host metadata into dedicated GitHub, artifact, and reference pills. */ +export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undefined): IAgentHostSessionPillMetadata { + const entries = readSessionArtifacts(meta); + const github = readSessionGitHubState(meta); + const artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)).map(entry => entry.link); + const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)).map(entry => entry.link); + const pullRequestUrls = dedupeLinks(getSessionRelatedPullRequestUrls(github), artifactPullRequests); + const issueUrls = dedupeLinks(artifactIssues); + const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey)); + const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link))); + return { + pullRequestUrls, + issueUrls, + artifacts: remaining.filter(entry => entry.isArtifact), + references: remaining.filter(entry => !entry.isArtifact), + }; +} + +/** Resolves the session-wide changeset represented by the workbench Changes pill. */ +export function resolveAgentHostSessionChangeset( + backendSession: URI, + changesets: readonly Changeset[] | undefined, + defaultKind?: DefaultChangesetKind, +): { readonly changeset: Changeset; readonly resource: URI } | undefined { + const staticChangesets = changesets?.filter(changeset => !changeset.uriTemplate.includes('{')) ?? []; + const changeset = selectDefaultChangeset(staticChangesets, defaultKind); + const resource = changeset ? parseUri(resolveChangesetUriTemplate(backendSession.toString(), changeset.uriTemplate)) : undefined; + return changeset && resource ? { changeset, resource } : undefined; +} + +/** Returns the workbench chat resources whose browsers belong in the current chat's pill. */ +export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: Pick | undefined): ReadonlySet { + const ownerIds = new Set([sessionResource.toString()]); + if (!state) { + return ownerIds; + } + + const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + const currentChatResource = parseUri(explicitChatResource ?? getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()); + if (!currentChatResource) { + return ownerIds; + } + + for (const chat of state.chats) { + const parentChatResource = chat.origin?.kind === ChatOriginKind.Tool ? parseUri(chat.origin.chat) : undefined; + const parsedChat = parseChatUri(chat.resource); + if (!parentChatResource || !isEqual(parentChatResource, currentChatResource) || !parsedChat) { + continue; + } + + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: null }).toString()); + const query = new URLSearchParams(sessionResource.query); + query.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, chat.resource); + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: query.toString() }).toString()); + } + return ownerIds; +} + +function resolutionEquals(first: IAgentHostSessionResolution | undefined, second: IAgentHostSessionResolution | undefined): boolean { + return first === second || (!!first && !!second + && first.connection === second.connection + && first.connectionAuthority === second.connectionAuthority + && first.defaultChangesetKind === second.defaultChangesetKind + && isEqual(first.backendSession, second.backendSession)); +} + +function changesetTargetEquals( + first: { readonly changeset: Changeset; readonly resource: URI } | undefined, + second: { readonly changeset: Changeset; readonly resource: URI } | undefined, +): boolean { + return first === second || (!!first && !!second + && first.changeset.changeKind === second.changeset.changeKind + && first.changeset.label === second.changeset.label + && first.changeset.uriTemplate === second.changeset.uriTemplate + && isEqual(first.resource, second.resource)); +} + +function parseUri(value: string | undefined): URI | undefined { + if (!value) { + return undefined; + } + try { + return URI.parse(value, true); + } catch { + return undefined; + } +} + +function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string { + const resource = parseUri(link); + const number = resource ? githubReferenceNumber(resource, kind) : undefined; + if (kind === 'pullRequest') { + return number + ? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number) + : localize('agentHostSessionPills.pullRequest', "Pull Request"); + } + return number + ? localize('agentHostSessionPills.issue.number', "Issue #{0}", number) + : localize('agentHostSessionPills.issue', "Issue"); +} + +function githubReferenceNumber(resource: URI, kind: 'pullRequest' | 'issue'): string | undefined { + const segment = kind === 'pullRequest' ? 'pull' : 'issues'; + return new RegExp(`/${segment}/(?\\d+)(?:/|$)`).exec(resource.path)?.groups?.number; +} + +function websiteKey(url: string): string | undefined { + const parsed = URL.parse(url); + if (!parsed) { + return undefined; + } + const path = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${path}${parsed.search}${parsed.hash}`; +} + +/** Adds Agent Host session metadata pills to a workbench chat input. */ +export class AgentHostSessionInputPills extends Disposable { + + private readonly _browserChanged = observableSignal(this); + private readonly _browserListeners = this._register(new MutableDisposable()); + + constructor( + private readonly _widget: ChatWidget, + compact: ChatPillsCompactMode, + @IAgentHostConnectionsService connectionsService: IAgentHostConnectionsService, + @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, + @IClipboardService private readonly _clipboardService: IClipboardService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService private readonly _openerService: IOpenerService, + @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, + ) { + super(); + + const pillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); + const sessionResource = observableFromEvent(this, this._widget.onDidChangeViewModel, () => this._widget.viewModel?.sessionResource); + const sessionResolutionChanged = observableSignalFromEvent(this, connectionsService.onDidChangeSessionResolution); + const resolution = derivedOpts({ owner: this, equalsFn: resolutionEquals }, reader => { + sessionResolutionChanged.read(reader); + const resource = sessionResource.read(reader); + return resource ? connectionsService.resolveSessionResource(resource) : undefined; + }); + const sessionStateSource = derived(this, reader => { + const current = resolution.read(reader); + if (!current || !pillsEnabled.read(reader)) { + return constObservable(undefined); + } + const subscription = reader.store.add(current.connection.getSubscription(StateComponents.Session, current.backendSession, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const sessionState = derived(this, reader => sessionStateSource.read(reader).read(reader)); + const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? resolveAgentHostSessionChangeset(currentResolution.backendSession, sessionState.read(reader)?.changesets, currentResolution.defaultChangesetKind) + : undefined; + }); + const changesetStateSource = derived(this, reader => { + const currentResolution = resolution.read(reader); + const resource = changesetTarget.read(reader)?.resource; + if (!currentResolution || !resource) { + return constObservable(undefined); + } + const subscription = reader.store.add(currentResolution.connection.getSubscription(StateComponents.Changeset, resource, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const changesetFiles = derivedObservableWithCache<{ readonly connectionAuthority: string; readonly resource: URI; readonly files: ChangesetState['files'] } | undefined>(this, (reader, lastValue) => { + const currentResolution = resolution.read(reader); + const target = changesetTarget.read(reader); + if (!currentResolution || !target) { + return undefined; + } + const state = changesetStateSource.read(reader).read(reader); + if (!state) { + return lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource) ? lastValue : undefined; + } + if (state.status !== ChangesetStatus.Ready && lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource)) { + return lastValue; + } + return { connectionAuthority: currentResolution.connectionAuthority, resource: target.resource, files: state.files }; + }); + const changes = derived(this, reader => { + const currentResolution = resolution.read(reader); + const files = changesetFiles.read(reader)?.files; + if (!currentResolution || !files) { + return []; + } + return files + .map(file => agentHostChangesetFileToEntryDiff(file, currentResolution.connectionAuthority)) + .filter(isDefined); + }); + const changeStats = derivedOpts({ owner: this, equalsFn: chatChangesStatsEqual }, reader => { + const diffs = changes.read(reader); + return diffs.length === 0 + ? EMPTY_CHAT_CHANGES_STATS + : { + files: diffs.length, + insertions: diffs.reduce((total, diff) => total + diff.added, 0), + deletions: diffs.reduce((total, diff) => total + diff.removed, 0), + }; + }); + const metadata = derived(this, reader => getAgentHostSessionPillMetadata(sessionState.read(reader)?._meta)); + const gitHubState = derived(this, reader => readSessionGitHubState(sessionState.read(reader)?._meta)); + + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); + this._refreshBrowserListeners(); + const browserInputs = derived(this, reader => { + this._browserChanged.read(reader); + const resource = sessionResource.read(reader); + if (!resource || !resolution.read(reader) || !pillsEnabled.read(reader)) { + return []; + } + const ownerIds = getAgentHostSessionBrowserOwnerIds(resource, sessionState.read(reader)); + return [...this._browserViewService.getKnownBrowserViews().values()] + .filter(input => input.model?.owner.type === 'agent' && ownerIds.has(input.model.owner.sessionId)); + }); + const browserUrls = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { + return visibility.isVisible(SessionChatPillKind.Browsers, reader) + ? new Set(browserInputs.read(reader).map(input => input.url).filter(isDefined)) + : new Set(); + }); + + const pullRequestSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).pullRequestUrls, 'pullRequest', gitHubState.read(reader))); + const pullRequestIcon = derived(this, reader => { + const icons = getChatPillEntries(pullRequestSections.read(reader)).map(entry => entry.icon); + return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon('open'); + }); + const issueSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).issueUrls, 'issue')); + const artifactSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).artifacts, browserUrls.read(reader), currentResolution) + : []; + }); + const referenceSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).references, browserUrls.read(reader), currentResolution) + : []; + }); + const browserSections = derived(this, reader => { + const entries = browserInputs.read(reader).map(input => this._browserEntry(input, sessionResource.read(reader))); + return entries.length > 0 ? [{ title: localize('agentHostSessionPills.browsers.section', "Browsers"), entries }] : []; + }); + + const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { + changes: { + stats: changeStats, + label: derived(this, reader => changesetTarget.read(reader)?.changeset.label ?? localize('agentHostSessionPills.changes', "Changes")), + open: () => this._openChanges(changesetTarget.get()?.changeset.label ?? localize('agentHostSessionPills.changesEditor', "Session Changes"), changes.get()), + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestIcon }, + issues: { sections: issueSections }, + artifacts: { sections: artifactSections }, + references: { sections: referenceSections }, + browsers: { sections: browserSections }, + }, offeredPillKinds)); + const inputPills = this._register(instantiationService.createInstance(ChatInputPills, this._widget.inputPart.persistentContentContainerElement, { + debugName: 'AgentHostSessionInputPills.content', + compact, + targetWindow: getWindow(this._widget.inputPart.persistentContentContainerElement), + enabled: pillsEnabled, + sources: constObservable(sources.sources), + offeredKinds: offeredPillKinds, + ariaLabel: localize('agentHostSessionPills.ariaLabel', "Session status"), + focusFallback: () => this._widget.focusInput(), + })); + inputPills.element.classList.add('agent-host-session-input-pills'); + + this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ + onDidChange: inputPills.onDidChange, + getElements: () => inputPills.getPillElements(), + })); + const updateVisibility = (visible: boolean) => { + this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); + this._widget.setPersistentContentHeight(visible ? CHAT_INPUT_PILLS_ROW_HEIGHT : undefined); + }; + this._register(inputPills.onDidChangeVisibility(updateVisibility)); + updateVisibility(inputPills.visible); + } + + private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType): readonly IChatPillSection[] { + const entries = links.map(link => { + const resource = parseUri(link); + if (!resource) { + return undefined; + } + const number = githubReferenceNumber(resource, kind); + const label = referenceLabel(link, kind); + const pullRequestState = kind === 'pullRequest' + && gitHubState?.pullRequestState + && gitHubState.pullRequestStateUrl + && linkKey(gitHubState.pullRequestStateUrl) === linkKey(link) + ? gitHubState.pullRequestState + : 'open'; + return { + id: linkKey(link), + label, + ...(kind === 'pullRequest' && number ? { pillLabel: `#${number}` } : {}), + icon: kind === 'pullRequest' ? computePullRequestIcon(pullRequestState) : Codicon.issues, + toolbarActions: [toAction({ + id: `chatInputPills.copy.${kind}.${linkKey(link)}`, + label: kind === 'pullRequest' + ? localize('agentHostSessionPills.copyPullRequest', "Copy Pull Request URL") + : localize('agentHostSessionPills.copyIssue', "Copy Issue URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(resource.toString(true)), + })], + ...getChatPillResourceLocation(resource, label), + open: () => this._openExternal(resource), + } satisfies IChatPillEntry; + }).filter(isDefined); + const title = kind === 'pullRequest' + ? localize('agentHostSessionPills.pullRequests.section', "Pull Requests") + : localize('agentHostSessionPills.issues.section', "Issues"); + return entries.length > 0 ? [{ title, entries }] : []; + } + + private _buildArtifactSections(entries: readonly ISessionArtifact[], browserUrls: ReadonlySet, resolution: IAgentHostSessionResolution): readonly IChatPillSection[] { + const browserKeys = new Set([...browserUrls].map(websiteKey).filter(isDefined)); + const entriesByType = new Map(); + for (const artifact of entries) { + if (artifact.type === SessionArtifactType.Website && artifact.link) { + const key = websiteKey(artifact.link); + if (key && browserKeys.has(key)) { + continue; + } + } + const entry = this._artifactEntry(artifact, resolution); + if (entry) { + const typeEntries = entriesByType.get(artifact.type) ?? []; + typeEntries.push(entry); + entriesByType.set(artifact.type, typeEntries); + } + } + return artifactSectionOrder.flatMap(({ type, title }) => { + const sectionEntries = entriesByType.get(type); + return sectionEntries?.length ? [{ title, entries: sectionEntries }] : []; + }); + } + + private _artifactEntry(artifact: ISessionArtifact, resolution: IAgentHostSessionResolution): IChatPillEntry | undefined { + if (artifact.type === SessionArtifactType.File || artifact.type === SessionArtifactType.Resource) { + const artifactResource = parseUri(artifact.uri); + if (!artifactResource) { + return undefined; + } + const resource = artifact.type === SessionArtifactType.File + ? toAgentHostUri(artifactResource, resolution.connectionAuthority) + : artifactResource; + const label = artifact.type === SessionArtifactType.File ? basename(resource) : artifact.label; + return { + id: artifact.id, + label, + ...(artifact.type === SessionArtifactType.File ? { resource } : { icon: Codicon.link }), + ...getChatPillResourceLocation(resource, label), + open: () => this._openResource(resource), + }; + } + + const link = parseUri(artifact.link); + const icon = artifactIcons.get(artifact.type) ?? Codicon.archive; + if (link) { + const copyAction = artifact.type === SessionArtifactType.Commit && artifact.commitHash + ? [toAction({ + id: 'chat.agentHost.sessionPills.copyCommitHash', + label: localize('agentHostSessionPills.copyCommitHash', "Copy Commit Hash"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(artifact.commitHash!), + })] + : undefined; + return { + id: artifact.id, + label: artifact.label, + icon, + ...(copyAction ? { toolbarActions: copyAction } : {}), + ...getChatPillResourceLocation(link, artifact.label), + open: () => this._openExternal(link), + }; + } + if (artifact.type === SessionArtifactType.Commit && artifact.commitHash) { + return { + id: artifact.id, + label: artifact.label, + icon, + ariaLabel: localize('agentHostSessionPills.copyCommit', "Copy commit hash for {0}", artifact.label), + tooltip: artifact.commitHash, + open: () => { void this._clipboardService.writeText(artifact.commitHash!); }, + }; + } + return undefined; + } + + private _browserEntry(input: BrowserEditorInput, sessionResource: URI | undefined): IChatPillEntry { + const label = input.title?.trim() || localize('agentHostSessionPills.browser', "Browser"); + return { + id: input.id, + label, + icon: Codicon.globe, + open: () => { void this._openBrowser(input, sessionResource); }, + }; + } + + private async _openBrowser(input: BrowserEditorInput, sessionResource: URI | undefined): Promise { + const url = input.url; + const shared = url + ? [...this._browserViewService.getContextualBrowserViews({ activeSessionId: sessionResource?.toString() }).values()] + .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)) + : []; + const target = input.model?.sharingState === BrowserViewSharingState.Shared || !url + ? input + : shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; + const existing = this._editorService.findEditors(target.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); + const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); + await this._editorService.openEditor(target, undefined, targetGroup); + } + + private _openChanges(label: string, diffs: readonly IEditSessionEntryDiff[]): void { + if (diffs.length > 0) { + openChatFileChanges(this._editorService, label, diffs); + } + } + + private _openExternal(resource: URI): void { + void this._openerService.open(resource, { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }); + } + + private _openResource(resource: URI): void { + const kind = previewKind(resource); + if (kind) { + void openChatTurnFile({ uri: resource, kind, created: false }, this._openerService, this._configurationService); + return; + } + void this._openerService.open(resource, { fromUserGesture: true }); + } + + private _refreshBrowserListeners(): void { + const store = new DisposableStore(); + this._browserListeners.value = store; + for (const input of this._browserViewService.getKnownBrowserViews().values()) { + store.add(input.onDidChangeLabel(() => this._browserChanged.trigger(undefined))); + } + this._browserChanged.trigger(undefined); + } +} 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/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 1ede7615d60582..233c5c84fa2875 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -181,7 +181,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionTarget) case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Open a new Codex session using the Codex extension from OpenAI. Codex sessions can be managed from the chat sessions view."); case AgentSessionProviders.AgentHostCodex: - return localize('chat.session.providerDescription.agentHostCodex', "Delegate tasks to the Codex App Server using the Codex models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); + return localize('chat.session.providerDescription.agentHostCodex', "Delegate tasks to the Codex agent using models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); case AgentSessionProviders.Growth: return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); case AgentSessionProviders.AgentHostCopilot: 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/attachments/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts index 09e6202c2d1499..270daa6ce33c50 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; @@ -21,10 +22,12 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/i import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IChatRequestVariableEntry, isImageVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestVariableValue, IDynamicVariable, toAttachedContextDynamicVariable } from '../../common/attachments/chatVariables.js'; +import { chatSlashCommandForeground } from '../../common/widget/chatColors.js'; import { IChatWidget } from '../chat.js'; import { IChatWidgetContrib } from '../widget/chatWidget.js'; export const dynamicVariableDecorationType = 'chat-dynamic-variable'; +export const dynamicVariableIconDecorationType = 'chat-dynamic-variable-icon'; const issueIconCharacter = '\ueb0c'; const pullRequestIconCharacter = '\uea64'; @@ -292,6 +295,9 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC const decorationIds = this.host.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, validVariables.map((r): IDecorationOptions => ({ range: r.range, hoverMessage: this.getHoverForReference(r), + }))); + this.host.inputEditor.setDecorationsByType('chat', dynamicVariableIconDecorationType, validVariables.map((r): IDecorationOptions => ({ + range: Range.fromPositions(Range.getStartPosition(r.range)), renderOptions: getReferenceIconRenderOptions(r), }))); @@ -360,6 +366,7 @@ function getReferenceIconRenderOptions(reference: IDynamicVariable): IDecoration : undefined; return contentText ? { before: { + color: themeColorFromId(chatSlashCommandForeground), contentText, fontFamily: 'codicon', margin: '0 2px 0 0', 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..03d38ce9451e57 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -71,6 +71,7 @@ import { ILanguageModelStatsService, LanguageModelStatsService } from '../common import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { ISessionChatPillVisibilityService, SessionChatPillVisibility } from '../common/sessionChatPills.js'; import { AgentPluginDiscoveryPriority, IAgentPluginService, agentPluginDiscoveryRegistry } from '../common/plugins/agentPluginService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig, isTildePath } from '../common/promptSyntax/config/config.js'; @@ -132,6 +133,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'; @@ -1072,7 +1074,7 @@ configurationRegistry.registerConfiguration({ deprecationMessage: nls.localize('chat.turnStatusPills.objectDeprecated', "The per-pill object form is deprecated. Use a boolean value instead."), }, ], - markdownDescription: nls.localize('chat.turnStatusPills', "Controls whether agent status pills are shown above the chat input while a turn is in progress and inside the completed response. Only applies to agent sessions."), + markdownDescription: nls.localize('chat.turnStatusPills', "Controls whether agent status pills are shown above the chat input and inside completed responses. Only applies to agent sessions."), default: true, }, [mcpAccessConfig]: { @@ -3277,9 +3279,11 @@ 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); +registerSingleton(ISessionChatPillVisibilityService, SessionChatPillVisibility, InstantiationType.Delayed); registerSingleton(IChatPasteTargetService, ChatPasteTargetService, InstantiationType.Delayed); registerSingleton(IChatSideChatService, ChatSideChatService, InstantiationType.Delayed); registerSingleton(IChatRequestOriginService, ChatRequestOriginService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 594743c9fc7d22..b1dbbc19a7a1a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -450,6 +450,8 @@ export interface IChatWidget { setInput(query?: string): void; getInput(): string; refreshParsedInput(): void; + /** Floats persistent input content and reserves matching space below the transcript. */ + setPersistentContentHeight(height: number | undefined): void; logInputHistory(): void; acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise; getSelectedModelRequestOptions(): Pick; diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPills.ts b/src/vs/workbench/contrib/chat/browser/chatInputPills.ts new file mode 100644 index 00000000000000..ca5cd44c7c3a64 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputPills.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import type { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Action, Separator, toAction, type IAction, type IActionRunner } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import type { ThemeIcon } from '../../../../base/common/themables.js'; +import type { CodeWindow } from '../../../../base/browser/window.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../browser/labels.js'; +import { ChatChangesPillActionViewItem, type IChatChangesStats } from '../../../browser/chatChangesPill.js'; +import { ChatPillsRow, ChatPillsWidget, getChatPillEntries, type ChatPillsCompactMode, type IChatPill, type IChatPillSection } from '../../../browser/chatPills.js'; +import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../browser/chatDropdownPill.js'; +import { getSessionChatPillLabel, getSessionChatPillMenu, ISessionChatPillVisibilityService, type ISessionChatPillMenuEntry, SessionChatPillKind } from '../common/sessionChatPills.js'; +import { chatArtifactPillOptions } from './widget/chatTurnPills.js'; +import { sessionBrowsersPillOptions, sessionCustomizationsPillOptions, sessionIssuesPillOptions, sessionPullRequestsPillOptions, sessionReferencesPillOptions, sessionSubagentsPillOptions } from './sessionChatPillOptions.js'; + +export interface IChatInputPillSource { + readonly kind?: SessionChatPillKind; + readonly hasData: IObservable; + readonly pill: IObservable; +} + +export interface IChatInputPillsOptions { + readonly debugName: string; + readonly compact: ChatPillsCompactMode; + readonly targetWindow?: CodeWindow; + readonly enabled: IObservable; + readonly sources: IObservable; + readonly offeredKinds: readonly SessionChatPillKind[]; + readonly ariaLabel?: string; + readonly context?: IObservable; + readonly actionRunner?: IActionRunner; + readonly focusFallback?: () => void; +} + +export interface IStandardChatInputPillSections { + readonly sections: IObservable; + readonly icon?: ThemeIcon | IObservable; +} + +export interface IStandardChatInputPillsData { + readonly changes?: { + readonly stats: IObservable; + readonly label: IObservable; + open(): void; + }; + readonly pullRequests?: IStandardChatInputPillSections; + readonly issues?: IStandardChatInputPillSections; + readonly artifacts?: IStandardChatInputPillSections; + readonly references?: IStandardChatInputPillSections; + readonly customizations?: IStandardChatInputPillSections; + readonly browsers?: IStandardChatInputPillSections; + readonly subagents?: IStandardChatInputPillSections; +} + +function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { + return first === second || (first.size === second.size && [...first].every(value => second.has(value))); +} + +/** Creates a source backed by one section/dropdown pill. */ +export function createChatSectionPillSource( + kind: SessionChatPillKind, + action: IChatPill['action'], + sections: IObservable, + options: IChatDropdownPillOptions, + resourceLabels: ResourceLabels, + instantiationService: IInstantiationService, +): IChatInputPillSource { + return { + kind, + hasData: derived(reader => getChatPillEntries(sections.read(reader)).length > 0), + pill: createChatSectionPill(action, sections, options, resourceLabels, instantiationService), + }; +} + +/** Builds the canonical pill components and ordering from surface-specific data adapters. */ +export class StandardChatInputPillSources extends Disposable { + readonly sources: readonly IChatInputPillSource[]; + + constructor( + data: IStandardChatInputPillsData, + offeredKinds: readonly SessionChatPillKind[], + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const offered = new Set(offeredKinds); + const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const sources: IChatInputPillSource[] = []; + if (data.changes && offered.has(SessionChatPillKind.Changes)) { + const changes = data.changes; + const action = this._register(new Action('chatInputPills.changes', changes.label.get(), undefined, true, () => changes.open())); + this._register(autorun(reader => { + const label = changes.label.read(reader); + action.label = label; + action.tooltip = localize('chatInputPills.viewChanges', "View {0}", label); + })); + const pill: IChatPill = { + action, + createActionViewItem: (options: IActionViewItemOptions) => new ChatChangesPillActionViewItem(action, options, changes.stats, instantiationService), + }; + sources.push({ + kind: SessionChatPillKind.Changes, + hasData: derived(reader => changes.stats.read(reader).files > 0), + pill: constObservable(pill), + }); + } + + const addSections = (kind: SessionChatPillKind, source: IStandardChatInputPillSections | undefined, options: IChatDropdownPillOptions) => { + if (!source || !offered.has(kind)) { + return; + } + const action = this._register(new Action(`chatInputPills.${kind}`, getSessionChatPillLabel(kind))); + sources.push(createChatSectionPillSource(kind, action, source.sections, source.icon ? { ...options, icon: source.icon } : options, resourceLabels, instantiationService)); + }; + addSections(SessionChatPillKind.PullRequests, data.pullRequests, sessionPullRequestsPillOptions); + addSections(SessionChatPillKind.Issues, data.issues, sessionIssuesPillOptions); + addSections(SessionChatPillKind.Artifacts, data.artifacts, chatArtifactPillOptions); + addSections(SessionChatPillKind.References, data.references, sessionReferencesPillOptions); + addSections(SessionChatPillKind.Customizations, data.customizations, sessionCustomizationsPillOptions); + addSections(SessionChatPillKind.Browsers, data.browsers, sessionBrowsersPillOptions); + addSections(SessionChatPillKind.Subagents, data.subagents, sessionSubagentsPillOptions); + this.sources = sources; + } +} + +/** Shared renderer/controller for status pills above a chat input. */ +export class ChatInputPills extends Disposable { + readonly element: HTMLElement; + readonly onDidChange: Event; + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility = this._onDidChangeVisibility.event; + + private readonly _row: ChatPillsRow; + private readonly _pills: ChatPillsWidget; + private _visible = false; + + constructor( + container: HTMLElement | undefined, + private readonly _options: IChatInputPillsOptions, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @ISessionChatPillVisibilityService private readonly _visibility: ISessionChatPillVisibilityService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._row = this._register(new ChatPillsRow(_options.debugName, { + compact: _options.compact, + targetWindow: _options.targetWindow, + })); + this.element = this._row.element; + if (container) { + container.appendChild(this.element); + this._register(toDisposable(() => this.element.remove())); + } + + const visibleSources = derived(this, reader => { + if (!_options.enabled.read(reader)) { + return []; + } + return _options.sources.read(reader).filter(source => + source.hasData.read(reader) && (!source.kind || this._visibility.isVisible(source.kind, reader))); + }); + const model = { + pills: derived(this, reader => visibleSources.read(reader).map(source => source.pill.read(reader))), + context: _options.context, + }; + this._pills = this._register(instantiationService.createInstance(ChatPillsWidget, model, { + ariaLabel: _options.ariaLabel, + actionRunner: _options.actionRunner, + allowContextMenu: true, + })); + this._pills.element.classList.add('show-file-icons'); + this._row.content.appendChild(this._pills.element); + this._row.observe(this._pills.element); + this._register(this._pills.onDidRemoveFocusedPill(() => this._row.restoreFocus(() => this._pills.getPillElements(), _options.focusFallback))); + this.onDidChange = Event.any(this._row.onDidChangeLayout, this._pills.onDidChangePills); + + const kindsWithData = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { + const kinds = new Set(); + if (!_options.enabled.read(reader)) { + return kinds; + } + for (const source of _options.sources.read(reader)) { + if (source.kind && source.hasData.read(reader)) { + kinds.add(source.kind); + } + } + return kinds; + }); + const showContextMenu = (anchor: HTMLElement | StandardMouseEvent, targetKind?: SessionChatPillKind) => { + const kinds = kindsWithData.get(); + if (kinds.size === 0) { + return; + } + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this._getVisibilityActions(kinds, targetKind), + }); + }; + this._register(addDisposableListener(this._row.content, EventType.CONTEXT_MENU, event => { + event.preventDefault(); + event.stopPropagation(); + const anchor = new StandardMouseEvent(getWindow(this._row.content), event); + showContextMenu(anchor, this._getTargetKind(event.target as HTMLElement | null)); + })); + this._register(this._row.onDidRequestContextMenu(anchor => showContextMenu(anchor, this._getTargetKind(anchor)))); + + derived(this, reader => { + const anyVisible = this._pills.isVisible.read(reader); + const anyHidden = kindsWithData.read(reader).size > 0; + return anyVisible ? 'visible' : anyHidden ? 'empty' : 'hidden'; + }).recomputeInitiallyAndOnChange(this._store, state => { + const activeElement = getWindow(this._row.content).document.activeElement; + const restoreInputFocus = state === 'hidden' && !!activeElement && this._row.content.contains(activeElement); + const visible = state !== 'hidden'; + this.element.classList.toggle('hidden', !visible); + this._row.setEmpty(state === 'empty', localize('chatInputPills.configure', "Configure Session Status Pills")); + if (this._visible !== visible) { + this._visible = visible; + this._onDidChangeVisibility.fire(visible); + } + this._row.scanDomNode(); + if (restoreInputFocus) { + this._row.restoreFocus(() => this._pills.getPillElements(), _options.focusFallback); + } + }); + } + + get visible(): boolean { + return this._visible; + } + + getPillElements(): readonly HTMLElement[] { + return this._pills.getPillElements(); + } + + private _getTargetKind(target: HTMLElement | null): SessionChatPillKind | undefined { + const targetPill = this._pills.getPill(target); + if (!targetPill) { + return undefined; + } + for (const source of this._options.sources.get()) { + if (source.kind && source.pill.get() === targetPill) { + return source.kind; + } + } + return undefined; + } + + private _getVisibilityActions(kindsWithData: ReadonlySet, targetKind?: SessionChatPillKind) { + const menu = getSessionChatPillMenu(kindsWithData, this._visibility.readHiddenKinds(undefined), targetKind, this._options.offeredKinds); + const restoreFocus = () => this._row.restoreFocus(() => this._pills.getPillElements()); + const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ + id: `chatInputPills.toggle.${entry.kind}`, + label: entry.label, + checked: entry.checked, + run: () => { + this._visibility.toggle(entry.kind); + restoreFocus(); + }, + }); + const groups: IAction[][] = []; + if (menu.hide) { + const hide = menu.hide; + groups.push([toAction({ + id: `chatInputPills.hide.${hide.kind}`, + label: hide.label, + run: () => { + this._visibility.hide(hide.kind); + restoreFocus(); + }, + })]); + } + groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); + return Separator.join(...groups); + } +} + +/** Creates a source for a pill whose data presence is represented by its membership. */ +export function createChatInputPillSource(pill: IChatPill, kind?: SessionChatPillKind): IChatInputPillSource { + return { kind, hasData: constObservable(true), pill: constObservable(pill) }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts index 4cf217020a7183..0df12106cc3ed3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatResponseFileChangesService.ts @@ -16,6 +16,9 @@ export interface IChatResponseFileEdit extends IEditSessionEntryDiff { readonly isOutsideWorkspace: boolean; } +/** An authoritative result saying valid edits produced no net file changes. */ +export const AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES: readonly IEditSessionEntryDiff[] = []; + /** * Supplies the per-response (per-request) file-change diffs rendered by the * "Changed N files" summary under a completed chat response. @@ -31,7 +34,9 @@ export interface IChatResponseFileChangesProvider { * Returns an observable of the file-change diffs produced by `requestId` * within `sessionResource`, or `undefined` when this provider cannot * supply changes for that request (in which case the caller falls back to - * the chat editing session). + * the chat editing session). Return + * {@link AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES} when valid edits + * cancel to no net change and consumers must clear previously cached diffs. */ getChangesForRequest(sessionResource: URI, requestId: string): IObservable | undefined; 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/editorChatResponseFileChangesService.ts b/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts index 46de3de6f4f5d7..1e887b3c72fed0 100644 --- a/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts +++ b/src/vs/workbench/contrib/chat/browser/editorChatResponseFileChangesService.ts @@ -6,8 +6,28 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditSessionEntryDiff } from '../common/editing/chatEditingService.js'; import { AbstractChatResponseFileChangesService, IChatResponseFileChangesOpenContext } from './chatResponseFileChangesService.js'; +/** Maps a chat-produced file change to the editor resources that represent its actual before/after states. */ +export function toChatFileChangeEditorResource(diff: IEditSessionEntryDiff) { + return { + original: { resource: diff.isCreated ? undefined : diff.originalURI }, + modified: { resource: diff.isDeleted ? undefined : diff.modifiedSnapshotURI ?? diff.modifiedURI }, + goToFileResource: diff.modifiedURI, + }; +} + +/** Opens chat-produced file changes in the standard multi-diff editor. */ +export function openChatFileChanges(editorService: IEditorService, label: string, diffs: readonly IEditSessionEntryDiff[]): void { + const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); + void editorService.openEditor({ + multiDiffSource: source, + label, + resources: diffs.map(toChatFileChangeEditorResource), + }); +} + export class EditorChatResponseFileChangesService extends AbstractChatResponseFileChangesService { constructor( @IEditorService private readonly editorService: IEditorService, @@ -23,14 +43,6 @@ export class EditorChatResponseFileChangesService extends AbstractChatResponseFi if (!diffs?.length) { return; } - const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); - this.editorService.openEditor({ - multiDiffSource: source, - label: localize('chatTurnPills.changes.title', "Turn File Changes"), - resources: diffs.map(diff => ({ - original: { resource: diff.originalURI }, - modified: { resource: diff.isDeleted ? undefined : diff.modifiedURI }, - })), - }); + openChatFileChanges(this.editorService, localize('chatTurnPills.changes.title', "Turn File Changes"), diffs); } } diff --git a/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts b/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts new file mode 100644 index 00000000000000..f3a4108350c057 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionChatPillOptions.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { ChatPillSingleEntry, type IChatDropdownPillOptions } from '../../../browser/chatDropdownPill.js'; +import { computePullRequestIcon } from '../../../common/chatPullRequest.js'; + +/** Shared presentation of the pull requests pill. */ +export const sessionPullRequestsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionPullRequests', + icon: computePullRequestIcon('open'), + title: localize('sessionPullRequests.title', "Pull Requests"), + summaryLabel: count => count === 1 + ? localize('sessionPullRequests.countSingle', "1 Pull Request") + : localize('sessionPullRequests.count', "{0} Pull Requests", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionPullRequests.showSingle', "Show 1 pull request") + : localize('sessionPullRequests.show', "Show {0} pull requests", count), +}; + +/** Shared presentation of the issues pill. */ +export const sessionIssuesPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionIssues', + icon: Codicon.issues, + title: localize('sessionIssues.title', "Issues"), + summaryLabel: count => count === 1 + ? localize('sessionIssues.countSingle', "1 Issue") + : localize('sessionIssues.count', "{0} Issues", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionIssues.showSingle', "Show 1 issue") + : localize('sessionIssues.show', "Show {0} issues", count), +}; + +/** Shared presentation of the references pill. */ +export const sessionReferencesPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionReferences', + icon: Codicon.bookmark, + title: localize('sessionReferences.title', "References"), + summaryLabel: count => count === 1 + ? localize('sessionReferences.countSingle', "1 Reference") + : localize('sessionReferences.count', "{0} References", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionReferences.showSingle', "Show 1 reference") + : localize('sessionReferences.show', "Show {0} references", count), + singleEntry: ChatPillSingleEntry.Summary, +}; + +/** Shared presentation of the active browsers pill. */ +export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBrowsers', + icon: Codicon.globe, + title: localize('sessionBrowsers.title', "Browsers"), + summaryLabel: count => count === 1 + ? localize('sessionBrowsers.countSingle', "1 Active Browser") + : localize('sessionBrowsers.count', "{0} Active Browsers", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionBrowsers.showSingle', "Show 1 browser") + : localize('sessionBrowsers.show', "Show {0} browsers", count), +}; + +/** Shared presentation of the customizations pill. */ +export const sessionCustomizationsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionCustomizations', + icon: Codicon.bookmark, + title: localize('sessionCustomizations.title', "Customizations"), + summaryLabel: count => count === 1 + ? localize('sessionCustomizations.countSingle', "1 Customization") + : localize('sessionCustomizations.count', "{0} Customizations", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionCustomizations.showSingle', "Show 1 customization") + : localize('sessionCustomizations.show', "Show {0} customizations", count), + singleEntry: ChatPillSingleEntry.Summary, +}; + +/** Shared presentation of the subagents pill. */ +export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionSubagents', + icon: Codicon.agent, + title: localize('sessionSubagents.title', "Background Activities"), + summaryLabel: count => count === 1 + ? localize('sessionSubagents.countSingle', "1 Subagent") + : localize('sessionSubagents.count', "{0} Subagents", count), + summaryAriaLabel: count => count === 1 + ? localize('sessionSubagents.showSingle', "Show 1 subagent") + : localize('sessionSubagents.show', "Show {0} subagents", count), +}; 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/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 2719423a5a0ae7..3bfedfb39c91e6 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -320,6 +320,19 @@ export interface IVoiceSessionController { export const IVoiceSessionController = createDecorator('voiceSessionController'); +export type VoiceNewSessionPreparationResult = 'prepared' | 'sent' | 'failed'; + +/** Whether a chat input owns the current Voice Mode session. */ +export function isVoiceSessionActiveForInput(inputFocused: boolean, targetSession: URI | undefined, hasDraftTarget: boolean, sessionResource: URI | undefined): boolean { + if (hasDraftTarget) { + return false; + } + if (targetSession) { + return !!sessionResource && isEqual(targetSession, sessionResource); + } + return inputFocused; +} + export class VoiceSessionController extends Disposable implements IVoiceSessionController { declare readonly _serviceBrand: undefined; @@ -2019,15 +2032,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (text !== rawText && e.args) { e.args['text'] = text; } - if (e.args?.['new_session'] === true) { - // Pin this submission to the new target so it outranks any - // stale focus-change pin. - this._setPinnedSubmitSession(undefined); - this.newSessionAsTarget(); - if (text.trim()) { - this._setPinnedSubmitSession(this._targetSession.get()); - } - } + const createNewSession = e.args?.['new_session'] === true; this._statusText.set(VoiceToolDispatchService.getActionLabel(e.name), undefined); this._persistEntry('agent_tool_call', this._renderToolCallSummary(e.name, e.args), { toolName: e.name, @@ -2043,9 +2048,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._sendContext(); this.voiceClientService.sendToolResult(e.callId, result); }; - const sendPromise = shouldSend - ? this._sendTranscriptionToChat(text) - : Promise.resolve(false); + const sendPromise = this._prepareNewSessionTarget(createNewSession, text).then(result => { + if (result === 'failed') { + return false; + } + if (result === 'sent' || !shouldSend) { + return true; + } + return this._sendTranscriptionToChat(text); + }); sendPromise.then(sent => { if (!sent) { this._clearAwaitingReply(); @@ -3688,6 +3699,28 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + private async _prepareNewSessionTarget(createNewSession: boolean, text: string): Promise { + if (!createNewSession) { + return 'prepared'; + } + + this._setPinnedSubmitSession(undefined); + if (CommandsRegistry.getCommand('_chat.voice.prepareNewSession')) { + try { + return await this.commandService.executeCommand('_chat.voice.prepareNewSession', text) ?? 'failed'; + } catch (error) { + this.logService.error('Failed to prepare a host session for Voice Mode:', error); + return 'failed'; + } + } + + this.newSessionAsTarget(); + if (text.trim()) { + this._setPinnedSubmitSession(this._targetSession.get()); + } + return 'prepared'; + } + /** * Watch a session's latest response and surface it in the floating window * transcript. Called when voice sends to a non-visible session so the user diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index 5568750a6c283c..d0f7993a56e7df 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -33,6 +33,7 @@ import { ChatConfiguration } from '../../../common/constants.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatChangesSummaryPart as IChatFileChangesSummaryPart, IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { openChatFileChanges, toChatFileChangeEditorResource } from '../../editorChatResponseFileChangesService.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatTreeItem } from '../../chat.js'; import { ResourcePool } from './chatCollections.js'; @@ -80,7 +81,7 @@ export function renderChangesSummaryFileList( const altKey = (dom.isMouseEvent(item.browserEvent) || dom.isKeyboardEvent(item.browserEvent)) && item.browserEvent.altKey; const openInDiffEditorByDefault = configurationService.getValue(ChatConfiguration.OpenChangedFileInDiffEditor); - const openInDiffEditor = altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault; + const openInDiffEditor = diff.isDeleted || (altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault); if (!openInDiffEditor) { const fileURI = ChatEditingSnapshotTextModelContentProvider.getOriginalFileURI(diff.modifiedURI); @@ -92,9 +93,14 @@ export function renderChangesSummaryFileList( // fall back to the diff editor. } + const editorResource = toChatFileChangeEditorResource(diff); + if (!editorResource.original.resource || !editorResource.modified.resource) { + openChatFileChanges(editorService, localize('chat.fileChanges', "File Changes"), [diff]); + return; + } editorService.openEditor({ - original: { resource: diff.originalURI }, - modified: { resource: diff.modifiedURI }, + original: editorResource.original, + modified: editorResource.modified, options: { preserveFocus: true } }); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index bd96da33f51615..3298219d724121 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -9,6 +9,7 @@ import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -25,6 +26,7 @@ import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameS import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../common/widget/chatColors.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, chatSubcommandLeader, IParsedChatRequest, IParsedChatRequestPart } from '../../../common/requestParser/chatParserTypes.js'; import { IChatMarkdownContent, IChatService } from '../../../common/chatService/chatService.js'; +import { chatPasteLinkMetadataKey } from '../../../common/attachments/chatVariables.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../chat.js'; @@ -81,6 +83,16 @@ export interface IDecorationWidgetArgs { title?: string; } +export function getPastedChatReferenceMarkdown(part: IParsedChatRequestPart, uri: URI | undefined): string | undefined { + if (!(part instanceof ChatRequestDynamicVariablePart) + || part._meta?.[chatPasteLinkMetadataKey] !== true + || (uri?.scheme !== Schemas.http && uri?.scheme !== Schemas.https)) { + return undefined; + } + + return `[${part.text}](${uri.toString(true)})`; +} + export class ChatMarkdownDecorationsRenderer extends Disposable { private readonly richLinkDecorator: Lazy; @@ -123,6 +135,10 @@ export class ChatMarkdownDecorationsRenderer extends Disposable { const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ? part.data : undefined; + const pastedLink = getPastedChatReferenceMarkdown(part, uri); + if (pastedLink) { + return pastedLink; + } const title = uri ? this.labelService.getUriLabel(uri, { relative: true }) : part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : part instanceof ChatRequestAgentSubcommandPart ? part.command.description : diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts index 22e8af135ffdd2..7becd03a395d2f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts @@ -18,7 +18,7 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatRendererContent, IChatTurnPillsPart } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; -import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; import { EMPTY_DIFF_STATS, IDiffStats, observeTurnStatusPillsEnabled } from '../chatTurnPills.js'; import { renderChangesSummaryFileList } from './chatChangesSummaryPart.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; @@ -53,7 +53,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent // keep the last non-empty result rather than dropping a rendered summary. this._diffs = derivedObservableWithCache(this, (reader, lastValue) => { const diffs = providedDiffs.read(reader); - return diffs.length > 0 ? diffs : (lastValue ?? diffs); + return diffs.length > 0 || diffs === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES ? diffs : (lastValue ?? diffs); }); const providedStats = this._chatResponseFileChangesService.getChangeStatsForRequest?.( @@ -67,7 +67,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent } const diffs = this._diffs.read(reader); if (diffs.length === 0) { - return lastValue ?? EMPTY_DIFF_STATS; + return diffs === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES ? EMPTY_DIFF_STATS : (lastValue ?? EMPTY_DIFF_STATS); } let insertions = 0, deletions = 0; for (const diff of diffs) { 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..61aab7e3bc9e68 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 @@ -108,7 +108,7 @@ display: flex; } -.chat-agent-merge .chat-agent-merge-message-toggle.codicon { +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-message-toggle.codicon { font-size: var(--vscode-codiconFontSize-compact); } @@ -126,31 +126,21 @@ background-color: var(--vscode-toolbar-activeBackground); } -/* `.codicon` joins each compound so these sizes outrank the `font` shorthand on -the codicon base rule, which would otherwise force every glyph to 16px. */ -.chat-agent-merge .chat-agent-merge-twistie.codicon { +/* The card scope keeps compact sizing ahead of the workbench codicon rule. */ +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-twistie.codicon { position: relative; z-index: 1; flex: 0 0 auto; 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, @@ -231,7 +221,7 @@ which sits either on an ancestor or on the workbench element itself. */ } /* Shared leading icon: a fixed width keeps comment bodies and check names on one indent. */ -.chat-agent-merge .chat-agent-merge-row-icon.codicon { +.chat-agent-merge > .chat-agent-merge-card .chat-agent-merge-row-icon.codicon { flex: 0 0 auto; width: var(--vscode-codiconFontSize-compact); font-size: var(--vscode-codiconFontSize-compact); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 6f05c72cc6a06d..9ee9bb29cd4c6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -663,6 +663,10 @@ function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation } } +export function isAnchorTarget(target: EventTarget | null): boolean { + return dom.isHTMLElement(target) && !!target.closest('a'); +} + export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -2137,7 +2141,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') !== 'none' && this.rendererOptions.editable) { templateData.elementDisposables.add(dom.addDisposableListener(templateData.rowContainer, dom.EventType.KEY_DOWN, e => { const ev = new StandardKeyboardEvent(e); - if (ev.equals(KeyCode.Space) || ev.equals(KeyCode.Enter)) { + if ((ev.equals(KeyCode.Space) || ev.equals(KeyCode.Enter)) && !isAnchorTarget(e.target)) { if (this.viewModel?.editing?.id !== element.id) { ev.preventDefault(); ev.stopPropagation(); @@ -4564,11 +4568,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer IChatRequestModeInfo | undefined) | undefined; private readonly _useTreeHierarchy: boolean; /** Scrollable space kept below the last item, see {@link IChatListWidgetOptions.paddingBottom}. */ - private readonly _paddingBottom: number; + private _paddingBottom: number; //#endregion @@ -1242,6 +1242,19 @@ export class ChatListWidget extends Disposable { this._renderer.updateOptions(options); } + setPaddingBottom(paddingBottom: number): void { + const value = Math.max(0, paddingBottom); + if (value === this._paddingBottom) { + return; + } + const wasScrolledToBottom = this.isScrolledToBottom; + this._paddingBottom = value; + this._tree.updateOptions({ paddingBottom: value }); + if (wasScrolledToBottom) { + this.scrollToEnd(); + } + } + /** * Update the list/tree color overrides, including the sticky-scroll surface. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 10014ff6858afb..ade34c6c292cce 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -377,6 +377,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private listContainer!: HTMLElement; private container!: HTMLElement; + private _persistentContentHeight: number; private transcriptProgress: { readonly container: HTMLElement; readonly content: HTMLElement } | undefined; private readonly transcriptProgressPart = this._register(new MutableDisposable()); private transcriptProgressActive = false; @@ -608,6 +609,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); + this._persistentContentHeight = viewOptions.persistentContentHeight ?? 0; this.readOnlyBanner = viewOptions.isSessionsWindow ? undefined @@ -1086,12 +1088,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } })); this.updateSessionStateIndicator(); - if (this.viewOptions.persistentContentHeight) { - // The class floats the persistent content; the variable tells the - // surfaces the list now extends behind how far to keep clear. - this.container.classList.add(chatFloatingPersistentContentClass); - this.container.style.setProperty(chatPersistentContentHeightVariable, `${this.viewOptions.persistentContentHeight}px`); - } + this._applyPersistentContentHeight(); this.editorOverflowWidgetsDomNode = this.viewOptions.editorOverflowWidgetsDomNode; if (!this.editorOverflowWidgetsDomNode) { const editorOverflowWidgetsDomNode = this.layoutService.getContainer(dom.getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); @@ -2120,7 +2117,7 @@ export class ChatWidget extends Disposable implements IChatWidget { getSelectedModelRequestOptions: () => this.getSelectedModelRequestOptions(), getCurrentModeInfo: () => this.input.currentModeInfo, getEditingValue: () => this.input.inputEditor.getValue(), - paddingBottom: this.viewOptions.persistentContentHeight, + paddingBottom: this._persistentContentHeight, } )); @@ -2698,6 +2695,28 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + setPersistentContentHeight(height: number | undefined): void { + const persistentContentHeight = Math.max(0, height ?? 0); + if (persistentContentHeight === this._persistentContentHeight) { + return; + } + this._persistentContentHeight = persistentContentHeight; + this._applyPersistentContentHeight(); + } + + private _applyPersistentContentHeight(): void { + if (!this.container) { + return; + } + const floatsPersistentContent = this._persistentContentHeight > 0; + this.container.classList.toggle(chatFloatingPersistentContentClass, floatsPersistentContent); + if (floatsPersistentContent) { + this.container.style.setProperty(chatPersistentContentHeightVariable, `${this._persistentContentHeight}px`); + } else { + this.container.style.removeProperty(chatPersistentContentHeightVariable); + } + this.listWidget?.setPaddingBottom(this._persistentContentHeight); + } setModel(model: IChatModel | undefined): void { if (!this.container || !this.inputPart) { @@ -3143,11 +3162,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 +3174,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..471aebdc9428a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -121,7 +121,7 @@ import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../speechToT import { IDictationOnboardingService } from '../../speechToText/dictationOnboarding.js'; import { isDictationActiveForEditor, notifyDictationSubmitted, onDidChangeDictationEditor } from '../../speechToText/dictationSession.js'; import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; -import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; +import { isVoiceSessionActiveForInput, IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { getAgentSessionPullRequestContextValue } from '../../agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; @@ -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, @@ -3444,15 +3445,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const { location } = this.getWidgetLocationInfo(widget); const focusedWidget = observableFromEvent(this, this.chatWidgetService.onDidChangeFocusedSession, () => this.chatWidgetService.lastFocusedWidget); + const voiceSessionResource = observableFromEvent(this, widget.onDidChangeViewModel, () => widget.viewModel?.sessionResource); const isVoiceInputActive = derived(this, reader => focusedWidget.read(reader) === widget); const isVoiceSessionActive = derived(this, reader => { - if (!isVoiceInputActive.read(reader)) { - return false; - } const target = this.voiceSessionController.targetSession.read(reader); const hasDraftTarget = this.voiceSessionController.hasDraftTarget.read(reader); - const resource = widget.viewModel?.sessionResource; - return !hasDraftTarget && (!target || (!!resource && isEqual(target, resource))); + const resource = voiceSessionResource.read(reader); + return isVoiceSessionActiveForInput(isVoiceInputActive.read(reader), target, hasDraftTarget, resource); }); const inputPickerCompactStates = new Map>(); @@ -3539,9 +3538,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 +3746,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/editor/chatInputReferenceDecorations.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts index 0956f3fd9dc723..2b38675bde3853 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputReferenceDecorations.ts @@ -3,18 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { combinedDisposable, IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { themeColorFromId } from '../../../../../../../base/common/themables.js'; import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; -import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; +import { dynamicVariableDecorationType, dynamicVariableIconDecorationType } from '../../../attachments/chatDynamicVariables.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; export function registerChatInputReferenceDecorationType(codeEditorService: ICodeEditorService, decorationType = dynamicVariableDecorationType): IDisposable { - return codeEditorService.registerDecorationType('chat', decorationType, { - color: themeColorFromId(chatSlashCommandForeground), - backgroundColor: themeColorFromId(chatSlashCommandBackground), - borderRadius: '3px', - rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges - }); + return combinedDisposable( + codeEditorService.registerDecorationType('chat', decorationType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }), + codeEditorService.registerDecorationType('chat', dynamicVariableIconDecorationType, { + color: themeColorFromId(chatSlashCommandForeground), + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }) + ); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts index 932abd97bcb4df..8cc3c9f2552cc6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts @@ -31,7 +31,7 @@ import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IExtensionService, isProposedApiEnabled } from '../../../../../../services/extensions/common/extensions.js'; import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, isImageVariableEntry, toPasteVariableEntry, ChatPasteAttachmentMetadata } from '../../../../common/attachments/chatVariableEntries.js'; import { chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js'; -import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; +import { chatPasteLinkMetadataKey, IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { IChatPasteTarget, IChatPasteTargetService } from '../../../chat.js'; import { chatInputSchemes, isChatInputModel, ChatConfiguration } from '../../../../common/constants.js'; import { cleanupOldImages, createFileForMedia, resizeImage } from '../../../chatImageUtils.js'; @@ -45,8 +45,6 @@ export const pastedTextArtifactDefaultMinLength = 10000; */ const pastedTextArtifactMinLines = 10; export const CHAT_ATTACHMENT_MIME_TYPE = 'application/vnd.chat.attachment+json'; -const chatPasteLinkMetadataKey = 'chatPasteLink'; - interface SerializedCopyData { readonly uri: UriComponents; readonly range: IRange; 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/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index d83dfdee8b4411..96bfa813316dcf 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -33,6 +33,7 @@ import { IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind, IResolvedNewChatSessionType, SessionTypeSelectionReason } from '../../../common/constants.js'; import { clearChatEditor } from '../../actions/chatClear.js'; +import { AgentHostSessionInputPills } from '../../agentSessions/agentHost/agentHostSessionInputPills.js'; import { ChatEditorInput } from './chatEditorInput.js'; import { ChatWidget } from '../../widget/chatWidget.js'; import { IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; @@ -151,6 +152,7 @@ export class ChatEditor extends AbstractEditorWithViewState this._widget.setVisible(this.isBodyVisible() && !this.welcomeController?.isShowingWelcome.read(reader)); this._register(this.onDidChangeBodyVisibility(() => updateWidgetVisibility())); diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts index c9663e08f67096..6124123af8e380 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariables.ts @@ -50,6 +50,8 @@ export interface IChatVariablesService { getSelectedToolAndToolSets(sessionResource: URI): ToolAndToolSetEnablementMap; } +export const chatPasteLinkMetadataKey = 'chatPasteLink'; + export interface IDynamicVariable { range: IRange; id: string; 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/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 92e773e0d65050..f857ed46437ff1 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -320,6 +320,9 @@ export interface IEditSessionEntryDiff extends IEditSessionDiffStats { */ modifiedSnapshotURI?: URI; + /** Whether the modified resource was created by this edit. */ + isCreated?: boolean; + /** Whether the modified resource was deleted by this edit. */ isDeleted?: boolean; 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/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/workbench/contrib/chat/common/sessionChatPills.ts similarity index 81% rename from src/vs/sessions/contrib/chat/common/sessionChatPills.ts rename to src/vs/workbench/contrib/chat/common/sessionChatPills.ts index bc5c0c46e7fda4..ee1b22d775dc89 100644 --- a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts +++ b/src/vs/workbench/contrib/chat/common/sessionChatPills.ts @@ -6,10 +6,11 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IReader } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { observableMemento, ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -/** The kinds of pill shown above the chat input, each independently hideable. */ +/** The kinds of pill shown above an agent chat input, each independently hideable. */ export const enum SessionChatPillKind { Changes = 'changes', Artifacts = 'artifacts', @@ -24,11 +25,11 @@ export const enum SessionChatPillKind { /** All pill kinds, in the order they are offered in the visibility menu. */ export const SESSION_CHAT_PILL_KINDS: readonly SessionChatPillKind[] = [ SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, SessionChatPillKind.Artifacts, SessionChatPillKind.References, SessionChatPillKind.Customizations, - SessionChatPillKind.PullRequests, - SessionChatPillKind.Issues, SessionChatPillKind.Browsers, SessionChatPillKind.Subagents, ]; @@ -64,7 +65,7 @@ export interface ISessionChatPillMenuEntry { /** * The pill visibility context menu: an optional "Hide X" for the pill that was - * right-clicked, then the kinds the session has data for, then the rest. The + * right-clicked, then the kinds the surface has data for, then the rest. The * caller renders a separator between the groups it shows. */ export interface ISessionChatPillMenu { @@ -74,21 +75,21 @@ export interface ISessionChatPillMenu { } /** - * Builds the visibility menu. Every hideable kind is listed and toggleable, - * checked while it is not hidden, grouped by whether the session has data for it. - * - * @param targetKind The pill that was right-clicked, which gains a "Hide X" - * entry. Omitted when the click did not land on a pill. + * Builds the visibility menu. Every offered, hideable kind is listed and + * toggleable, checked while it is not hidden, and grouped by whether the + * current session has data for it. */ export function getSessionChatPillMenu( kindsWithData: ReadonlySet, hiddenKinds: ReadonlySet, targetKind?: SessionChatPillKind, + offeredKinds: readonly SessionChatPillKind[] = SESSION_CHAT_PILL_KINDS, ): ISessionChatPillMenu { + const offered = new Set(offeredKinds); const withData: ISessionChatPillMenuEntry[] = []; const withoutData: ISessionChatPillMenuEntry[] = []; for (const kind of SESSION_CHAT_PILL_KINDS) { - if (!isSessionChatPillHideable(kind)) { + if (!offered.has(kind) || !isSessionChatPillHideable(kind)) { continue; } (kindsWithData.has(kind) ? withData : withoutData).push({ @@ -98,7 +99,7 @@ export function getSessionChatPillMenu( }); } - const hide = targetKind !== undefined && isSessionChatPillHideable(targetKind) + const hide = targetKind !== undefined && offeredKinds.includes(targetKind) && isSessionChatPillHideable(targetKind) ? { kind: targetKind, label: localize('sessionChatPills.hide', "Hide {0}", getSessionChatPillLabel(targetKind)) } : undefined; @@ -124,8 +125,20 @@ const hiddenSessionChatPills = observableMemento({ }, }); +export const ISessionChatPillVisibilityService = createDecorator('sessionChatPillVisibilityService'); + +export interface ISessionChatPillVisibilityService { + readonly _serviceBrand: undefined; + readHiddenKinds(reader: IReader | undefined): ReadonlySet; + isVisible(kind: SessionChatPillKind, reader: IReader | undefined): boolean; + hide(kind: SessionChatPillKind): void; + toggle(kind: SessionChatPillKind): void; +} + /** The user's per-kind pill visibility choices, persisted across windows. */ -export class SessionChatPillVisibility extends Disposable { +export class SessionChatPillVisibility extends Disposable implements ISessionChatPillVisibilityService { + + declare readonly _serviceBrand: undefined; private readonly _hiddenKinds: ObservableMemento; diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index f168203a642ce3..9fff3da414c380 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -117,6 +117,26 @@ suite('Chat Accessibility Help', () => { }); }); + test('documents session status pill keyboard interaction', () => { + const keybindingService = { + lookupKeybindings: () => [], + } as unknown as IKeybindingService; + + assert.deepStrictEqual({ + panelChat: getAccessibilityHelpText('panelChat', keybindingService, true).includes('left and right arrow keys to move between pills'), + agentView: getAccessibilityHelpText('agentView', keybindingService, true).includes(''), + agentQuickChat: getAccessibilityHelpText('agentView', keybindingService, true, false, false, false).includes('session status pills'), + quickChat: getAccessibilityHelpText('quickChat', keybindingService, true).includes('session status pills'), + inlineChat: getAccessibilityHelpText('inlineChat', keybindingService, true).includes('session status pills'), + }, { + panelChat: true, + agentView: true, + agentQuickChat: false, + quickChat: false, + inlineChat: false, + }); + }); + test('documents transcript Find everywhere it is enabled, but not in quick chat', () => { const keybindingService = { lookupKeybindings: () => [], diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts index f02a26a8a7a14c..f92139f5fccf72 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -31,7 +31,7 @@ import { } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { AgentHostResponseFileChangesProvider } from '../../../browser/agentSessions/agentHost/agentHostResponseFileChanges.js'; -import { IChatResponseFileEdit } from '../../../browser/chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileEdit } from '../../../browser/chatResponseFileChangesService.js'; class FakeAgentConnection extends mock() { override readonly clientId = 'test-client'; @@ -148,6 +148,39 @@ suite('AgentHostResponseFileChangesProvider', () => { ]); }); + test('wraps local non-file snapshots through the Agent Host file system', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, 'local', () => backendSession, undefined, new NullLogService())); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { + status: ChangesetStatus.Ready, + files: [{ + id: '1', + edit: { + before: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-before' } }, + after: { uri: URI.file('/repo/a.ts').toString(), content: { uri: 'git-blob://a-after' } }, + diff: { added: 1, removed: 1 }, + }, + }], + } satisfies ChangesetState); + + const diff = observe(provider, ds).latest()[0]; + + assert.deepStrictEqual({ + originalScheme: diff.originalURI.scheme, + originalAuthority: diff.originalURI.authority, + originalSource: fromAgentHostUri(diff.originalURI).toString(), + modifiedSource: diff.modifiedSnapshotURI && fromAgentHostUri(diff.modifiedSnapshotURI).toString(), + }, { + originalScheme: 'vscode-agent-host', + originalAuthority: 'local', + originalSource: 'git-blob://a-before/', + modifiedSource: 'git-blob://a-after/', + }); + }); + test('keeps the changeset subscription when session state updates', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); @@ -218,6 +251,146 @@ suite('AgentHostResponseFileChangesProvider', () => { ); }); + test('includes deleted response edits when a turn checkpoint is unavailable', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); + const provider = ds.add(createProvider(conn, () => backendSession, () => defaultChatUri)); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Computing, files: [] } satisfies ChangesetState); + conn.setState(defaultChatUri.toString(), { + turns: [{ + id: 't1', + responseParts: [{ + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: URI.file('/repo/deleted.ts').toString(), content: { uri: 'git-blob://deleted-before' } }, + diff: { added: 0, removed: 6 }, + }], + }, + }], + }], + } as unknown as ChatState); + + const { latest } = observe(provider, ds); + assert.deepStrictEqual(latest().map(diff => ({ + file: fromAgentHostUri(diff.modifiedURI).path, + before: fromAgentHostUri(diff.originalURI).authority, + after: diff.modifiedSnapshotURI, + isCreated: diff.isCreated, + isDeleted: diff.isDeleted, + })), [{ + file: '/repo/deleted.ts', + before: 'deleted-before', + after: undefined, + isCreated: false, + isDeleted: true, + }]); + }); + + test('aggregates response edits by first and final file state', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const defaultChatUri = URI.parse(buildDefaultChatUri(backendSession.toString())); + const provider = ds.add(createProvider(conn, () => backendSession, () => defaultChatUri)); + const replaceResource = URI.file('/repo/replaced.ts').toString(); + const transientResource = URI.file('/repo/transient.ts').toString(); + const responseParts = [ + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: replaceResource, content: { uri: 'git-blob://replace-before' } }, + diff: { added: 0, removed: 4 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + after: { uri: replaceResource, content: { uri: 'git-blob://replace-after' } }, + diff: { added: 5, removed: 0 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + after: { uri: transientResource, content: { uri: 'git-blob://transient-after' } }, + diff: { added: 3, removed: 0 }, + }], + }, + }, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + content: [{ + type: ToolResultContentType.FileEdit, + before: { uri: transientResource, content: { uri: 'git-blob://transient-before-delete' } }, + diff: { added: 0, removed: 3 }, + }], + }, + }, + ]; + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Computing, files: [] } satisfies ChangesetState); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts: responseParts.slice(0, 3) }], + } as unknown as ChatState); + + const { latest } = observe(provider, ds); + const beforeCancellation = latest().map(diff => fromAgentHostUri(diff.modifiedURI).path); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts }], + } as unknown as ChatState); + const afterCancellation = latest().map(diff => ({ + file: fromAgentHostUri(diff.modifiedURI).path, + before: fromAgentHostUri(diff.originalURI).authority, + after: diff.modifiedSnapshotURI && fromAgentHostUri(diff.modifiedSnapshotURI).authority, + added: diff.added, + removed: diff.removed, + isCreated: diff.isCreated, + isDeleted: diff.isDeleted, + })); + conn.setState(defaultChatUri.toString(), { + turns: [{ id: 't1', responseParts: responseParts.slice(2) }], + } as unknown as ChatState); + + assert.deepStrictEqual({ + beforeCancellation, + afterCancellation, + isAuthoritativeEmpty: latest() === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, + afterAllCancellation: latest(), + }, { + beforeCancellation: ['/repo/replaced.ts', '/repo/transient.ts'], + afterCancellation: [{ + file: '/repo/replaced.ts', + before: 'replace-before', + after: 'replace-after', + added: 5, + removed: 4, + isCreated: false, + isDeleted: false, + }], + isAuthoritativeEmpty: true, + afterAllCancellation: [], + }); + }); + test('preserves an authoritative empty turn changeset', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); @@ -244,7 +417,13 @@ suite('AgentHostResponseFileChangesProvider', () => { } as unknown as ChatState); const { latest } = observe(provider, ds); - assert.deepStrictEqual(latest(), []); + assert.deepStrictEqual({ + diffs: latest(), + isAuthoritativeEmpty: latest() === AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, + }, { + diffs: [], + isAuthoritativeEmpty: true, + }); }); test('the recorded migrated turn falls back to the branch changeset when its turn changeset is empty', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts new file mode 100644 index 00000000000000..38846533a203fc --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.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 { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ISessionArtifact, SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { buildDefaultChatUri, buildSubagentChatUri, Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, ComponentToState, SessionState, StateComponents, withSessionGitHubState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { AgentHostSessionInputPills, getAgentHostSessionBrowserOwnerIds, getAgentHostSessionPillMetadata, resolveAgentHostSessionChangeset } from '../../../browser/agentSessions/agentHost/agentHostSessionInputPills.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { chatPersistentContentVisibleClass, ChatWidget } from '../../../browser/widget/chatWidget.js'; +import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; +import { ChatViewModel } from '../../../common/model/chatViewModel.js'; + +class StaticAgentConnection extends mock() { + readonly requested: Array<{ kind: StateComponents; resource: URI }> = []; + private readonly emitters = new Map>(); + + constructor(private readonly values: ReadonlyMap) { + super(); + } + + override getSubscription(kind: T, resource: URI): IReference> { + this.requested.push({ kind, resource }); + let emitter = this.emitters.get(kind); + if (!emitter) { + emitter = new Emitter(); + this.emitters.set(kind, emitter); + } + const values = this.values; + return { + object: { + get value() { return values.get(kind) as ComponentToState[T]; }, + get verifiedValue() { return values.get(kind) as ComponentToState[T]; }, + onDidChange: emitter.event as Event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + } + + setState(kind: StateComponents, value: SessionState | ChangesetState): void { + (this.values as Map).set(kind, value); + this.emitters.get(kind)?.fire(value); + } +} + +suite('AgentHostSessionInputPills', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('partitions GitHub links, artifacts, and references without duplication', () => { + const entries: readonly ISessionArtifact[] = [ + { id: 'created-pr', type: SessionArtifactType.PullRequest, label: 'Created PR', link: 'https://github.com/microsoft/vscode/pull/2', isGitHub: true, isArtifact: true }, + { id: 'duplicate-pr', type: SessionArtifactType.PullRequest, label: 'Existing PR', link: 'https://github.com/microsoft/vscode/pull/1/', isGitHub: true, isArtifact: false }, + { id: 'created-issue', type: SessionArtifactType.Issue, label: 'Created Issue', link: 'https://github.com/microsoft/vscode/issues/3', isGitHub: true, isArtifact: true }, + { id: 'issue-reference', type: SessionArtifactType.Issue, label: 'Related Issue', link: 'https://github.com/microsoft/vscode/issues/4', isGitHub: true, isArtifact: false }, + { id: 'website', type: SessionArtifactType.Website, label: 'Preview', link: 'https://example.com', isArtifact: true }, + { id: 'resource', type: SessionArtifactType.Resource, label: 'Docs', uri: 'https://example.com/docs', isArtifact: false }, + ]; + const meta = withSessionGitHubState( + withSessionArtifacts(undefined, entries), + { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + }, + ); + + const metadata = getAgentHostSessionPillMetadata(meta); + + assert.deepStrictEqual({ + pullRequestUrls: metadata.pullRequestUrls, + issueUrls: metadata.issueUrls, + artifactIds: metadata.artifacts.map(artifact => artifact.id), + referenceIds: metadata.references.map(reference => reference.id), + }, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + ], + issueUrls: ['https://github.com/microsoft/vscode/issues/3'], + artifactIds: ['website'], + referenceIds: ['issue-reference', 'resource'], + }); + }); + + test('resolves the configured session changeset and ignores templated entries', () => { + const backendSession = URI.parse('ahp-session:/session'); + const changesets: readonly Changeset[] = [ + { label: 'Last Turn', uriTemplate: 'changeset/turn/{turnId}', changeKind: ChangesetKind.Turn }, + { label: 'Session Changes', uriTemplate: 'changeset/session', changeKind: ChangesetKind.Session }, + { label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }, + ]; + + assert.deepStrictEqual({ + preferred: resolveAgentHostSessionChangeset(backendSession, changesets, ChangesetKind.Session), + fallback: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 2), ChangesetKind.Branch), + turnOnly: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 1), ChangesetKind.Session), + }, { + preferred: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + fallback: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + turnOnly: undefined, + }); + }); + + test('includes browsers owned by direct tool-origin child chats', () => { + const sessionResource = URI.parse('vscode-chat-session://agent-host/session'); + const backendSession = URI.parse('ahp-session://host/session'); + const parentChat = buildDefaultChatUri(backendSession); + const childChat = buildSubagentChatUri(backendSession, 'tool-1'); + const unrelatedChildChat = buildSubagentChatUri(backendSession, 'tool-2'); + const childChatId = 'subagent/tool-1'; + const stateWithoutChild = { + defaultChat: parentChat, + chats: [], + } as unknown as SessionState; + const stateWithChild = { + defaultChat: parentChat, + chats: [{ + resource: childChat, + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tool-1' }, + }, { + resource: unrelatedChildChat, + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(URI.parse('ahp-session://host/other')), toolCallId: 'tool-2' }, + }], + } as unknown as SessionState; + const explicitQuery = new URLSearchParams(); + explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, childChat); + const canonicalChildResource = sessionResource.with({ fragment: childChatId, query: null }); + const explicitChildResource = sessionResource.with({ fragment: childChatId, query: explicitQuery.toString() }); + + const before = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithoutChild); + const after = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithChild); + + assert.deepStrictEqual({ + before: [...before], + after: [...after], + hasCanonicalChild: after.has(canonicalChildResource.toString()), + hasExplicitChild: after.has(explicitChildResource.toString()), + hasUnrelatedChild: after.has(sessionResource.with({ fragment: 'subagent/tool-2', query: null }).toString()), + }, { + before: [sessionResource.toString()], + after: [ + sessionResource.toString(), + canonicalChildResource.toString(), + explicitChildResource.toString(), + ], + hasCanonicalChild: true, + hasExplicitChild: true, + hasUnrelatedChild: false, + }); + }); + + test('does not render pills for a Local chat input', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('vscode-chat-session://local/session'); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => undefined, + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + + assert.deepStrictEqual({ + hidden: row?.classList.contains('hidden'), + pillCount: row?.querySelectorAll('.chat-pill-item').length, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, { + hidden: true, + pillCount: 0, + persistentContentVisible: false, + persistentContentHeight: undefined, + }); + }); + + test('marks floating persistent content visible when Agent Host pills have data', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as unknown as ChangesetState], + ])); + const otherConnection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as unknown as ChangesetState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const resolutionChanged = new Emitter(); + let currentConnection = connection; + let connectionAuthority = 'local'; + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: resolutionChanged.event, + connections: [], + resolveSessionResource: () => ({ + connection: currentConnection, + connectionAuthority, + backendSession, + defaultChangesetKind: ChangesetKind.Branch, + }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + const button = row?.querySelector('.chat-pill-button'); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + const recomputing = { + hidden: row?.classList.contains('hidden'), + buttonPreserved: row?.querySelector('.chat-pill-button') === button, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [], + } as ChangesetState); + const readyEmpty = { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as ChangesetState); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + currentConnection = otherConnection; + connectionAuthority = 'remote'; + resolutionChanged.fire(); + + assert.deepStrictEqual({ + recomputing, + readyEmpty, + otherConnection: { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, + subscriptions: [...new Map(connection.requested.map(request => { + const value = { kind: request.kind, resource: request.resource.toString() }; + return [`${value.kind}:${value.resource}`, value]; + })).values()], + }, { + recomputing: { + hidden: false, + buttonPreserved: true, + persistentContentVisible: true, + persistentContentHeight: 28, + }, + readyEmpty: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + otherConnection: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + subscriptions: [{ + kind: StateComponents.Session, + resource: 'copilot:/session', + }, { + kind: StateComponents.Changeset, + resource: 'copilot:/session/changeset/branch', + }], + }); + }); + + test('matches the Agents Window pull request summary presentation', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + 'https://github.com/microsoft/vscode/pull/3', + ], + // Only pull request #1 is merged; the other entries must retain their open state. + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [{ authority: 'local', address: undefined, name: 'Local', isAmbient: true, connection }], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const button = persistentContent.querySelector('.chat-dropdown-pill-button'); + const icon = button?.querySelector('.chat-pill-icon'); + const multiple = { + button, + label: button?.querySelector('.chat-pill-label')?.textContent, + iconClass: icon?.classList.contains('codicon-git-pull-request'), + iconColor: icon?.style.color, + hasChevron: button?.querySelector('.chat-pill-chevron') !== null, + }; + connection.setState(StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState); + const singleButton = persistentContent.querySelector('.chat-dropdown-pill-button'); + const singleIcon = singleButton?.querySelector('.chat-pill-icon'); + + assert.deepStrictEqual({ + multiple, + single: { + buttonPreserved: singleButton === multiple.button, + label: singleButton?.querySelector('.chat-pill-label')?.textContent, + iconClass: singleIcon?.classList.contains('codicon-git-pull-request-done'), + iconColor: singleIcon?.style.color, + hasChevron: singleButton?.querySelector('.chat-pill-chevron') !== null, + }, + }, { + multiple: { + button, + label: '3 Pull Requests', + iconClass: true, + iconColor: 'var(--vscode-charts-green)', + hasChevron: true, + }, + single: { + buttonPreserved: true, + label: '#1', + iconClass: true, + iconColor: 'var(--vscode-charts-purple)', + hasChevron: false, + }, + }); + }); + + test('keeps a matching website artifact visible while Browsers is hidden', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const website = URI.parse('https://example.com/preview'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionArtifacts(undefined, [{ + id: 'preview', + type: SessionArtifactType.Website, + label: 'Preview', + link: website.toString(), + isArtifact: true, + }]), + } as unknown as SessionState], + ])); + const browserModel = upcastPartial({ + owner: { type: 'agent', sessionId: sessionResource.toString() }, + }); + const browser = new class extends mock() { + override get id(): string { return 'preview-browser'; } + override get model(): IBrowserViewModel { return browserModel; } + override get url(): string { return website.toString(); } + override get title(): string { return 'Preview'; } + override readonly onDidChangeLabel = Event.None; + }(); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map([[browser.id, browser]]), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set([SessionChatPillKind.Browsers]), + isVisible: kind => kind !== SessionChatPillKind.Browsers, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + + assert.deepStrictEqual({ + pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + empty: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('empty'), + }, { + pills: ['1 Artifact'], + empty: false, + }); + }); +}); 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/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts index b53a48bb840d52..cc600858970906 100644 --- a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../../editor/common/core/range.js'; +import { IDecorationOptions } from '../../../../../../editor/common/editorCommon.js'; import { TrackedRangeStickiness } from '../../../../../../editor/common/model.js'; import { TestCodeEditorService } from '../../../../../../editor/test/browser/editorTestServices.js'; import { createTestCodeEditor } from '../../../../../../editor/test/browser/testCodeEditor.js'; @@ -19,7 +21,7 @@ import { TestThemeService } from '../../../../../../platform/theme/test/common/t import { IDynamicVariable, toAttachedContextDynamicVariable } from '../../../common/attachments/chatVariables.js'; import { IChatWidget } from '../../../browser/chat.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../browser/attachments/chatVariables.js'; -import { ChatDynamicVariableModel, dynamicVariableDecorationType } from '../../../browser/attachments/chatDynamicVariables.js'; +import { ChatDynamicVariableModel, dynamicVariableDecorationType, dynamicVariableIconDecorationType } from '../../../browser/attachments/chatDynamicVariables.js'; import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IToolData, ToolDataSource, ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -237,6 +239,10 @@ suite('ChatDynamicVariableModel', () => { store.add(codeEditorService.registerDecorationType('test', dynamicVariableDecorationType, { rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, })); + store.add(codeEditorService.registerDecorationType('test', dynamicVariableIconDecorationType, { + color: { id: 'chat.slashCommandForeground' }, + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + })); const editor = store.add(createTestCodeEditor(textModel, { serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), })); @@ -279,6 +285,53 @@ suite('ChatDynamicVariableModel', () => { }); }); + test('renders GitHub reference icons with the reference foreground color', () => { + const issueText = 'microsoft/vscode#334284'; + const pullRequestText = 'microsoft/vscode#333953'; + const { editor, model } = createDynamicVariableModel(`${issueText} ${pullRequestText}`); + let iconDecorations: readonly IDecorationOptions[] = []; + const setDecorationsByType = editor.setDecorationsByType.bind(editor); + editor.setDecorationsByType = ((description: string, key: string, options: IDecorationOptions[]) => { + if (key === dynamicVariableIconDecorationType) { + iconDecorations = options; + } + return setDecorationsByType(description, key, options); + }) as typeof editor.setDecorationsByType; + + model.addReference(createMockVariable({ + range: new Range(1, 1, 1, issueText.length + 1), + icon: Codicon.issues, + })); + model.addReference(createMockVariable({ + id: 'var-2', + range: new Range(1, issueText.length + 2, 1, issueText.length + pullRequestText.length + 2), + icon: Codicon.gitPullRequest, + })); + + assert.deepStrictEqual(iconDecorations.map(decoration => ({ + range: decoration.range, + before: decoration.renderOptions?.before, + })), [{ + range: new Range(1, 1, 1, 1), + before: { + color: { id: 'chat.slashCommandForeground' }, + contentText: '\ueb0c', + fontFamily: 'codicon', + margin: '0 2px 0 0', + verticalAlign: 'middle', + }, + }, { + range: new Range(1, issueText.length + 2, 1, issueText.length + 2), + before: { + color: { id: 'chat.slashCommandForeground' }, + contentText: '\uea64', + fontFamily: 'codicon', + margin: '0 2px 0 0', + verticalAlign: 'middle', + }, + }]); + }); + test('removes a reference without deleting replacement text', () => { const { editor, model } = createDynamicVariableModel('explain #sym:example '); model.addReference(createMockVariable({ diff --git a/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts new file mode 100644 index 00000000000000..67f332c77e138a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatInputPills.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatInputPills, createChatInputPillSource, StandardChatInputPillSources, type IStandardChatInputPillsData } from '../../browser/chatInputPills.js'; +import { ISessionChatPillVisibilityService, SESSION_CHAT_PILL_KINDS, SessionChatPillKind } from '../../common/sessionChatPills.js'; + +suite('StandardChatInputPillSources', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses one canonical composition for different offered kind sets', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sections = constObservable([]); + const data: IStandardChatInputPillsData = { + changes: { + stats: constObservable({ files: 1, insertions: 2, deletions: 1 }), + label: constObservable('Changes'), + open: () => { }, + }, + pullRequests: { sections }, + issues: { sections }, + artifacts: { sections }, + references: { sections }, + customizations: { sections }, + browsers: { sections }, + subagents: { sections }, + }; + const full = store.add(instantiationService.createInstance(StandardChatInputPillSources, data, SESSION_CHAT_PILL_KINDS)); + const editorKinds = [ + SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Artifacts, + SessionChatPillKind.References, + SessionChatPillKind.Browsers, + ]; + const editor = store.add(instantiationService.createInstance(StandardChatInputPillSources, data, editorKinds)); + + assert.deepStrictEqual({ + full: full.sources.map(source => source.kind), + editor: editor.sources.map(source => source.kind), + }, { + full: SESSION_CHAT_PILL_KINDS, + editor: editorKinds, + }); + }); + + test('keeps the row available for restoring a hidden pill', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const hidden = observableValue('hiddenPills', false); + instantiationService.stub(ISessionChatPillVisibilityService, { + _serviceBrand: undefined, + readHiddenKinds: reader => hidden.read(reader) ? new Set([SessionChatPillKind.Browsers]) : new Set(), + isVisible: (kind, reader) => kind !== SessionChatPillKind.Browsers || !hidden.read(reader), + hide: () => hidden.set(true, undefined), + toggle: () => hidden.set(!hidden.get(), undefined), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + const hasData = observableValue('browserPill.hasData', true); + const source = { + ...createChatInputPillSource({ action: store.add(new Action('browser', 'Browser')) }, SessionChatPillKind.Browsers), + hasData, + }; + const overlayFocus = document.createElement('button'); + container.appendChild(overlayFocus); + let focusFallbackCount = 0; + const inputPills = store.add(instantiationService.createInstance(ChatInputPills, container, { + debugName: 'ChatInputPills.test', + compact: false, + enabled: constObservable(true), + sources: constObservable([source]), + offeredKinds: [SessionChatPillKind.Browsers], + focusFallback: () => { + focusFallbackCount++; + overlayFocus.focus(); + }, + })); + const before = { + hidden: inputPills.element.classList.contains('hidden'), + empty: inputPills.element.classList.contains('empty'), + pillCount: inputPills.getPillElements().length, + }; + + overlayFocus.focus(); + inputPills.getPillElements()[0].setAttribute('aria-expanded', 'true'); + hidden.set(true, undefined); + await timeout(0); + const afterHidden = { + hidden: inputPills.element.classList.contains('hidden'), + empty: inputPills.element.classList.contains('empty'), + pillCount: inputPills.getPillElements().length, + emptyRowFocused: document.activeElement === inputPills.element.querySelector('.chat-pills-row-content'), + }; + hasData.set(false, undefined); + await timeout(0); + + assert.deepStrictEqual({ + before, + after: afterHidden, + afterDataRemoved: { + hidden: inputPills.element.classList.contains('hidden'), + focusFallbackCount, + fallbackFocused: document.activeElement === overlayFocus, + }, + }, { + before: { + hidden: false, + empty: false, + pillCount: 1, + }, + after: { + hidden: false, + empty: true, + pillCount: 0, + emptyRowFocused: true, + }, + afterDataRemoved: { + hidden: true, + focusFallbackCount: 1, + fallbackFocused: true, + }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts index b2b79ec99caec8..4f3323e94e012c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatResponseFileChangesService.test.ts @@ -29,6 +29,7 @@ suite('EditorChatResponseFileChangesService', () => { ? constObservable([{ originalURI: URI.file('/before.ts'), modifiedURI: URI.file('/after.ts'), + modifiedSnapshotURI: URI.file('/after-snapshot.ts'), added: 2, removed: 1, quitEarly: false, @@ -45,6 +46,17 @@ suite('EditorChatResponseFileChangesService', () => { identical: false, isFinal: true, isBusy: false, + }, { + originalURI: URI.file('/created.ts'), + modifiedURI: URI.file('/created.ts'), + modifiedSnapshotURI: URI.file('/created-snapshot.ts'), + isCreated: true, + added: 4, + removed: 0, + quitEarly: false, + identical: false, + isFinal: true, + isBusy: false, }]) : undefined, })); @@ -58,15 +70,22 @@ suite('EditorChatResponseFileChangesService', () => { resources: opened.resources?.map(resource => ({ original: resource.original.resource?.toString(), modified: resource.modified.resource?.toString(), + goToFile: resource.goToFileResource?.toString(), })), }, { label: 'Turn File Changes', resources: [{ original: 'file:///before.ts', - modified: 'file:///after.ts', + modified: 'file:///after-snapshot.ts', + goToFile: 'file:///after.ts', }, { original: 'file:///deleted-before.ts', modified: undefined, + goToFile: 'file:///deleted.ts', + }, { + original: undefined, + modified: 'file:///created-snapshot.ts', + goToFile: 'file:///created.ts', }], }); }); 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/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index db1664ba3e2dbe..21feb2795966c7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -9,14 +9,14 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, derived, ISettableObservable, observableFromEvent, 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'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService as BaseTestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -34,7 +34,7 @@ import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessi import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; -import { VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; +import { isVoiceSessionActiveForInput, VoiceNewSessionPreparationResult, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatModelReference, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; @@ -53,6 +53,45 @@ class TestConfigurationService extends BaseTestConfigurationService { } } +suite('Voice Mode input ownership', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const target = URI.parse('agent-host-copilot:/session-1'); + const other = URI.parse('agent-host-copilot:/session-2'); + + test('follows a pinned session target independently of input focus', () => { + assert.deepStrictEqual({ + matchingUnfocused: isVoiceSessionActiveForInput(false, target, false, target), + mismatchedFocused: isVoiceSessionActiveForInput(true, target, false, other), + targetlessFocused: isVoiceSessionActiveForInput(true, undefined, false, target), + targetlessUnfocused: isVoiceSessionActiveForInput(false, undefined, false, target), + draft: isVoiceSessionActiveForInput(true, undefined, true, target), + }, { + matchingUnfocused: true, + mismatchedFocused: false, + targetlessFocused: true, + targetlessUnfocused: false, + draft: false, + }); + }); + + test('updates when a chat input materializes its pinned session resource', () => { + const viewModelChange = store.add(new Emitter()); + const viewModel: { resource: URI | undefined } = { resource: undefined }; + const sessionResource = observableFromEvent(store, viewModelChange.event, () => viewModel.resource); + const active = derived(store, reader => isVoiceSessionActiveForInput(false, target, false, sessionResource.read(reader))); + let current = false; + store.add(autorun(reader => { + current = active.read(reader); + })); + + viewModel.resource = target; + viewModelChange.fire(); + + assert.strictEqual(current, true); + }); +}); + class TestVoiceClientService extends mock() { private narrationCounter = 0; readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string; checkpoint?: IVoiceCheckpointNarrationMetadata; confirmationType?: VoiceConfirmationType }[] = []; @@ -663,6 +702,31 @@ class AdoptingCommandService extends TestCommandService { } } +class PreparingNewSessionCommandService extends TestCommandService { + constructor(private readonly result: VoiceNewSessionPreparationResult = 'sent') { + super(); + } + + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + if (commandId === '_chat.voice.prepareNewSession') { + return this.result as T; + } + if (commandId === '_chat.voice.getCurrentSession') { + return 'sessions-voice://new-chat/composer' as T; + } + return super.executeCommand(commandId, ...args); + } +} + +class RejectingNewSessionCommandService extends TestCommandService { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + if (commandId === '_chat.voice.prepareNewSession') { + throw new Error('preparation failed'); + } + return super.executeCommand(commandId, ...args); + } +} + class RejectingAcceptCommandService extends TestCommandService { override async executeCommand(commandId: string, ...args: unknown[]): Promise { if (commandId === '_chat.voice.acceptInput') { @@ -5381,6 +5445,93 @@ suite('VoiceSessionController', () => { }); }); + test('send_to_chat with new_session lets the host prepare its own session', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new PreparingNewSessionCommandService(); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-send', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-send', result: 'ok' }], + }); + }); + + test('send_to_chat with new_session does not fall back when host preparation fails', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new PreparingNewSessionCommandService('failed'); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-failed', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-failed', result: 'error' }], + }); + }); + + test('send_to_chat with new_session does not fall back when the host command rejects', async () => { + store.add(CommandsRegistry.registerCommand('_chat.voice.prepareNewSession', () => undefined)); + const voiceClientService = new TestVoiceClientService(); + const commandService = new RejectingNewSessionCommandService(); + const chatService = new NewSessionChatService(); + const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'host-new-session-rejected', + name: 'send_to_chat', + args: { text: 'refactor the upload service', new_session: true }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + created: chatService.created.length, + sent: chatService.sent, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + }, { + created: 0, + sent: [], + acceptedInputs: [], + toolResults: [{ callId: 'host-new-session-rejected', result: 'error' }], + }); + }); + test('send_to_chat with new_session and no text creates and targets a session without sending', async () => { const voiceClientService = new TestVoiceClientService(); const commandService = new TestCommandService(); @@ -5411,7 +5562,7 @@ suite('VoiceSessionController', () => { sent: [], acceptedInputs: [], target: 'chat-session://new/1', - toolResults: [{ callId: 'new-session-empty', result: 'error' }], + toolResults: [{ callId: 'new-session-empty', result: 'ok' }], awaitingReply: false, voiceState: 'idle', status: 'Hold to speak...', diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts index 709659b26ddc0e..62d1fa971c9e56 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatChangesSummaryPart.test.ts @@ -8,8 +8,10 @@ import { toAction } from '../../../../../../../base/common/actions.js'; import { Disposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { 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'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { isResourceDiffEditorInput, isResourceMultiDiffEditorInput } from '../../../../../../common/editor.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; import { IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; @@ -154,4 +156,74 @@ suite('ChatCheckpointFileChangesSummaryContentPart', () => { ], }); }); + + test('opens row diffs using snapshots and missing create or delete sides', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const opened: unknown[] = []; + const editorService = new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + opened.push(args[0]); + return undefined; + } + }(); + const configurationService = new class extends mock() { + override getValue(): T { + return true as T; + } + }(); + const container = document.createElement('div'); + const diffs = observableValue('testFileChanges', [{ + ...emptySessionEntryDiff(URI.file('/edited-before.ts'), URI.file('/edited.ts')), + modifiedSnapshotURI: URI.file('/edited-snapshot.ts'), + }, { + ...emptySessionEntryDiff(URI.file('/created.ts'), URI.file('/created.ts')), + modifiedSnapshotURI: URI.file('/created-snapshot.ts'), + isCreated: true, + }, { + ...emptySessionEntryDiff(URI.file('/deleted-before.ts'), URI.file('/deleted.ts')), + isDeleted: true, + }]); + store.add(renderChangesSummaryFileList(container, diffs, instantiationService, editorService, configurationService)); + + for (const row of container.querySelectorAll('.monaco-list-row')) { + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } + + assert.deepStrictEqual(opened.map(input => { + if (isResourceDiffEditorInput(input)) { + return { + kind: 'diff', + original: input.original.resource?.toString(), + modified: input.modified.resource?.toString(), + }; + } + assert.ok(isResourceMultiDiffEditorInput(input)); + return { + kind: 'multiDiff', + resources: input.resources?.map(resource => ({ + original: resource.original.resource?.toString(), + modified: resource.modified.resource?.toString(), + goToFile: resource.goToFileResource?.toString(), + })), + }; + }), [{ + kind: 'diff', + original: 'file:///edited-before.ts', + modified: 'file:///edited-snapshot.ts', + }, { + kind: 'multiDiff', + resources: [{ + original: undefined, + modified: 'file:///created-snapshot.ts', + goToFile: 'file:///created.ts', + }], + }, { + kind: 'multiDiff', + resources: [{ + original: 'file:///deleted-before.ts', + modified: undefined, + goToFile: 'file:///deleted.ts', + }], + }]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts index f29b54cafc6967..18ce6caf868a31 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTurnPillsPart.test.ts @@ -9,7 +9,7 @@ import { observableValue } from '../../../../../../../base/common/observable.js' import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; -import { IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; +import { AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, IChatResponseFileChangesService } from '../../../../browser/chatResponseFileChangesService.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatTurnPillsContentPart } from '../../../../browser/widget/chatContentParts/chatTurnPillsPart.js'; @@ -19,7 +19,7 @@ import { IChatTurnPillsPart } from '../../../../common/model/chatViewModel.js'; suite('ChatTurnPillsContentPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('keeps the turn changes summary once it has been shown', () => { + test('keeps transient empty changes and clears authoritative empty changes', () => { const instantiationService = workbenchInstantiationService(undefined, store); const diffs = observableValue('turnChanges', []); instantiationService.stub(IChatResponseFileChangesService, { @@ -60,11 +60,14 @@ suite('ChatTurnPillsContentPart', () => { // The changeset recompute at turn end briefly reports no files. diffs.set([], undefined); states.push(readState()); + diffs.set(AUTHORITATIVE_EMPTY_CHAT_RESPONSE_FILE_CHANGES, undefined); + states.push(readState()); assert.deepStrictEqual(states, [ { display: 'none', files: '0 files changed', additions: '+0', deletions: '-0' }, { display: '', files: '2 files changed', additions: '+8', deletions: '-3' }, { display: '', files: '2 files changed', additions: '+8', deletions: '-3' }, + { display: 'none', files: '0 files changed', additions: '+0', deletions: '-0' }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 975acd3b071a77..57cf009b563ed3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -23,11 +23,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IViewDescriptorService } from '../../../../../common/views.js'; import { IChatOutputRendererService } from '../../../browser/chatOutputItemRenderer.js'; -import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithActiveSubagentContent, endsWithCompletedQuestionInteraction, formatCompletedResponseDisclosureLabel, formatResponseTokenStats, getCompletedResponseCollapseEndIndex, getFinalResponseStartIndex, getFinalResponseStartIndexAfterMovingResponseOutcomeTools, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isFinalResponseRendered, isWaitingForMcpServers, moveResponseOutcomeToolsAfterFinalResponse, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCollapseCompletedResponsePart, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithActiveSubagentContent, endsWithCompletedQuestionInteraction, formatCompletedResponseDisclosureLabel, formatResponseTokenStats, getCompletedResponseCollapseEndIndex, getFinalResponseStartIndex, getFinalResponseStartIndexAfterMovingResponseOutcomeTools, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isAnchorTarget, isFinalResponseRendered, isWaitingForMcpServers, moveResponseOutcomeToolsAfterFinalResponse, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCollapseCompletedResponsePart, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { ChatWidget } from '../../../browser/widget/chatWidget.js'; import { isChatTurnStatusPillsEnabled } from '../../../browser/widget/chatTurnPills.js'; import { ChatSubagentContentPart } from '../../../browser/widget/chatContentParts/chatSubagentContentPart.js'; import { ChatCollapsibleContentPart } from '../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; +import { getPastedChatReferenceMarkdown } from '../../../browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { ChatRequestQueueKind, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; @@ -35,7 +36,7 @@ import { ChatModel } from '../../../common/model/chatModel.js'; import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; +import { ChatRequestDynamicVariablePart, ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { ChatEditorOptions } from '../../../browser/widget/chatOptions.js'; import { shouldRenderGeneratedImageResult, shouldRenderSessionCreatedResult } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; @@ -47,6 +48,61 @@ import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatMo suite('ChatListRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('recognizes nested compact reference content as a link target', () => { + const anchor = mainWindow.document.createElement('a'); + const icon = mainWindow.document.createElement('span'); + const label = mainWindow.document.createElement('span'); + anchor.append(icon, label); + + assert.deepStrictEqual({ + anchor: isAnchorTarget(anchor), + icon: isAnchorTarget(icon), + label: isAnchorTarget(label), + plainText: isAnchorTarget(mainWindow.document.createElement('span')), + textNode: isAnchorTarget(mainWindow.document.createTextNode('text')), + }, { + anchor: true, + icon: true, + label: true, + plainText: false, + textNode: false, + }); + }); + + test('renders pasted GitHub references as navigable links', () => { + const uri = URI.parse('https://github.com/microsoft/vscode/pull/334310'); + const part = new ChatRequestDynamicVariablePart( + new OffsetRange(0, 24), + new Range(1, 1, 1, 25), + 'microsoft/vscode#334310', + uri.toString(), + undefined, + uri, + undefined, + undefined, + undefined, + undefined, + { chatPasteLink: true }, + true, + uri.toString(), + ); + + assert.deepStrictEqual({ + pastedGitHubLink: getPastedChatReferenceMarkdown(part, uri), + ordinaryReference: getPastedChatReferenceMarkdown(new ChatRequestDynamicVariablePart( + part.range, + part.editorRange, + part.text, + part.id, + part.modelDescription, + part.data, + ), uri), + }, { + pastedGitHubLink: '[microsoft/vscode#334310](https://github.com/microsoft/vscode/pull/334310)', + ordinaryReference: undefined, + }); + }); + suite('shouldScheduleInitialHeightChange', () => { test('only schedules first measurement updates when needed to avoid clipping', () => { assert.deepStrictEqual([ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 9dfe421336f7dd..e0810c36d33ed0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -291,8 +291,8 @@ suite('ChatListWidget', () => { // The bottom padding counts towards the scroll height, so `scrollToEnd` has to // scroll through it or the list never reports being at the bottom - which both // streaming auto-scroll and the scroll-down button depend on. - test('scrolls through the bottom padding to reach the end', async () => { - const { disposables, model, widget } = createWidget({ paddingBottom: 30 }); + test('updates bottom padding while keeping the list at the end', async () => { + const { disposables, model, widget } = createWidget(); for (let i = 0; i < 10; i++) { const text = `question ${i}`; const request = model.addRequest({ @@ -307,13 +307,19 @@ suite('ChatListWidget', () => { await waitForStableLayout(widget); widget.scrollToEnd(); await waitForStableLayout(widget); + const scrollHeightWithoutPadding = widget.scrollHeight; + + widget.setPaddingBottom(30); + await waitForStableLayout(widget); assert.deepStrictEqual({ // Guards the test from passing vacuously on a list that cannot scroll. overflows: widget.scrollHeight > widget.renderHeight, + paddingAdded: widget.scrollHeight - scrollHeightWithoutPadding, atBottom: widget.isScrolledToBottom, }, { overflows: true, + paddingAdded: 30, atBottom: true, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts index f878922c52ee83..95598831ee848c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -107,6 +107,7 @@ suite('ChatTurnPills', () => { label: 'plan.md', resource: URI.file('/repo/plan.md'), ariaLabel: 'Open plan.md', + ariaDescription: 'file:///repo/plan.md', tooltip: 'file:///repo/plan.md', open: () => { }, }; @@ -115,15 +116,25 @@ suite('ChatTurnPills', () => { disposables.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, action, {}, constObservable([{ title: 'Files', entries: [entry] }]), chatArtifactPillOptions)), ]; - const ariaLabels = items.map(item => { + const accessibility = items.map(item => { const container = document.createElement('div'); mainWindow.document.body.appendChild(container); disposables.add(toDisposable(() => container.remove())); item.render(container); - return container.querySelector('.monaco-button')?.getAttribute('aria-label'); + const button = container.querySelector('.monaco-button'); + return { + label: button?.getAttribute('aria-label'), + description: button?.getAttribute('aria-description'), + }; }); - assert.deepStrictEqual(ariaLabels, ['Open plan.md', 'Open plan.md']); + assert.deepStrictEqual(accessibility, [{ + label: 'Open plan.md', + description: 'file:///repo/plan.md', + }, { + label: 'Open plan.md', + description: 'file:///repo/plan.md', + }]); }); test('focusing a pill restores its tab stop, so the row stays reachable by Tab', () => { @@ -214,6 +225,7 @@ suite('ChatTurnPills', () => { override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[]): void { shownItems = items.map(item => ({ kind: item.kind, label: item.label, ariaDescription: item.ariaDescription, hover: typeof item.hover?.content === 'string' ? item.hover.content : undefined })); } + override hide(): void { } }); const opened: string[] = []; const widget = disposables.add(instantiationService.createInstance(ChatTurnPillsWidget, { 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/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts similarity index 87% rename from src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts rename to src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts index 573248c7c5e84b..215f96d83722ff 100644 --- a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/sessionChatPills.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility } from '../../common/sessionChatPills.js'; suite('SessionChatPills', () => { @@ -23,10 +23,10 @@ suite('SessionChatPills', () => { { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true }, ], withoutData: [ + { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, { kind: SessionChatPillKind.References, label: 'References', checked: true }, { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true }, - { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true }, { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true }, ], }); @@ -46,6 +46,25 @@ suite('SessionChatPills', () => { }); }); + test('limits the menu to the pill kinds offered by the surface', () => { + assert.deepStrictEqual( + getSessionChatPillMenu( + new Set([SessionChatPillKind.PullRequests, SessionChatPillKind.Browsers]), + new Set(), + SessionChatPillKind.Browsers, + [SessionChatPillKind.Changes, SessionChatPillKind.Artifacts, SessionChatPillKind.PullRequests], + ), + { + withData: [ + { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: true }, + ], + withoutData: [ + { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true }, + ], + }, + ); + }); + test('hides customizations and subagents by default, and always shows changes', () => { const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); @@ -89,7 +108,6 @@ suite('SessionChatPills', () => { issues: visibility.isVisible(SessionChatPillKind.Issues, undefined), restored: disposables.add(new SessionChatPillVisibility(storageService)).isVisible(SessionChatPillKind.PullRequests, undefined), }; - // Hiding an already-hidden pill is a no-op, so one toggle brings it back. visibility.hide(SessionChatPillKind.PullRequests); visibility.toggle(SessionChatPillKind.PullRequests); 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/activityBar.css b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css index 7d4b82f6149112..9b9f5846634a78 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css @@ -55,6 +55,38 @@ height: calc(var(--activity-bar-action-height, 28px) - 4px); } +/* Keep the compact application menu on the same control tier and icon ramp as adjacent activity targets. */ +.modern-ui.monaco-workbench .activitybar .menubar.compact { + height: var(--activity-bar-action-height, 36px); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button { + justify-content: center; +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:is(:focus, .open) { + background-color: transparent; +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact .toolbar-toggle-more { + box-sizing: border-box; + width: calc(var(--activity-bar-action-height, 36px) - var(--vscode-spacing-size40)); + height: calc(var(--activity-bar-action-height, 36px) - var(--vscode-spacing-size40)); + padding: 0; + border-radius: var(--vscode-cornerRadius-small); + font-size: var(--vscode-codiconFontSize); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact:not(:focus-within) > .menubar-menu-button:hover .toolbar-toggle-more { + color: var(--vscode-modernActivityBarItem-hoverForeground); + background-color: var(--vscode-modernActivityBarItem-hoverBackground); +} + +.modern-ui.monaco-workbench .activitybar .menubar.compact > .menubar-menu-button:is(:focus, .open) .toolbar-toggle-more { + color: var(--vscode-modernActivityBarItem-activeForeground); + background-color: var(--vscode-modernActivityBarItem-activeBackground); +} + :is(.hc-black, .hc-light).modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { border-radius: var(--vscode-cornerRadius-small); background-color: transparent; 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/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 8d9b72ea4a03ea..887fb07cd8430a 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -32,8 +32,10 @@ import { WorkbenchState } from '../../../../../platform/workspace/common/workspa import { ColorThemeData } from '../../../../services/themes/common/colorThemeData.js'; import { generateColorThemeCSS } from '../../../../services/themes/browser/colorThemeCss.js'; import '../../../../browser/media/floatingPanels.css'; +import '../../../../../base/browser/ui/menu/menubar.css'; import '../../../../browser/parts/activitybar/media/activityaction.css'; import '../../../../browser/parts/media/paneCompositePart.css'; +import '../../../../browser/parts/titlebar/media/menubarControl.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; import '../../../../browser/parts/notifications/media/notificationsCenter.css'; import '../../../../browser/parts/notifications/media/notificationsToasts.css'; @@ -965,6 +967,126 @@ suite('ModernUIContribution', () => { }); }); + test('keeps floating rail overlays anchored to the viewport', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui floating-panels'; + root.style.height = '200px'; + root.style.display = 'inline-flex'; + root.style.setProperty('--activity-bar-action-height', '36px'); + root.style.setProperty('--activity-bar-width', '36px'); + root.style.setProperty('--vscode-spacing-sizeNone', '0px'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size60', '6px'); + root.style.setProperty('--vscode-spacing-size80', '8px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const activityBar = appendElement(root, 'part activitybar left'); + const content = appendElement(activityBar, 'content'); + const menubar = appendElement(content, 'menubar compact'); + const menuButton = appendElement(menubar, 'menubar-menu-button open'); + const menu = appendElement(menuButton, 'menubar-menu-items-holder monaco-menu-container'); + menu.style.top = '120px'; + menu.style.left = '240px'; + menu.style.width = '200px'; + menu.style.height = '160px'; + + const activityBarBounds = activityBar.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + const menuBarStyle = getWindow(menubar).getComputedStyle(menubar); + + assert.deepStrictEqual({ + position: getWindow(menu).getComputedStyle(menu).position, + menuBarHeight: menubar.clientHeight, + menuBarComputedHeight: menuBarStyle.height, + menuBarDisplay: menuBarStyle.display, + menuBarPaddingTop: menuBarStyle.paddingTop, + top: menuBounds.top, + left: menuBounds.left, + width: menuBounds.width, + leavesRail: menuBounds.left > activityBarBounds.right, + }, { + position: 'fixed', + menuBarHeight: 39, + menuBarComputedHeight: '39px', + menuBarDisplay: 'flex', + menuBarPaddingTop: '3px', + top: 120, + left: 240, + width: 200, + leavesRail: true, + }); + }); + + test('styles focused and open compact application menu states', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui floating-panels'; + root.style.setProperty('--activity-bar-action-height', '36px'); + root.style.setProperty('--vscode-codiconFontSize', '16px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-menubar-selectionBorder', '#123456'); + root.style.setProperty('--vscode-modernActivityBarItem-hoverBackground', '#234567'); + root.style.setProperty('--vscode-modernActivityBarItem-hoverForeground', '#345678'); + root.style.setProperty('--vscode-modernActivityBarItem-activeBackground', '#456789'); + root.style.setProperty('--vscode-modernActivityBarItem-activeForeground', '#56789a'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size80', '8px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const activityBar = appendElement(root, 'part activitybar left'); + const content = appendElement(activityBar, 'content'); + const menubar = appendElement(content, 'menubar compact'); + const menuButton = appendElement(menubar, 'menubar-menu-button'); + menuButton.tabIndex = 0; + const target = appendElement(menuButton, 'menubar-menu-title toolbar-toggle-more'); + const targetWindow = getWindow(target); + const targetStyles = () => { + const style = targetWindow.getComputedStyle(target); + return { + buttonBackgroundColor: targetWindow.getComputedStyle(menuButton).backgroundColor, + backgroundColor: style.backgroundColor, + color: style.color, + outlineColor: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }; + + // The headless runner cannot activate :focus; .open exercises the shared focus/open selectors. + menuButton.classList.add('open'); + const focusAndOpen = targetStyles(); + root.classList.add('hc-black'); + root.style.setProperty('--vscode-menubar-selectionBorder', '#abcdef'); + const highContrastFocusAndOpen = targetStyles(); + + assert.deepStrictEqual({ + fontSize: targetWindow.getComputedStyle(target).fontSize, + focusAndOpen, + highContrastFocusAndOpen, + }, { + fontSize: '16px', + focusAndOpen: { + buttonBackgroundColor: 'rgba(0, 0, 0, 0)', + backgroundColor: 'rgb(69, 103, 137)', + color: 'rgb(86, 120, 154)', + outlineColor: 'rgb(18, 52, 86)', + outlineStyle: 'solid', + outlineWidth: '1px', + }, + highContrastFocusAndOpen: { + buttonBackgroundColor: 'rgba(0, 0, 0, 0)', + backgroundColor: 'rgb(69, 103, 137)', + color: 'rgb(86, 120, 154)', + outlineColor: 'rgb(171, 205, 239)', + outlineStyle: 'solid', + outlineWidth: '1px', + }, + }); + }); + test('uses the editor surface border color', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui floating-panels'; 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/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index 6a6d6ff6ac84fc..9177442c7fe17f 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -26,7 +26,7 @@ import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorIn import { IExtensionService } from '../../extensions/common/extensions.js'; import { findGroup } from '../common/editorGroupFinder.js'; import { IEditorGroup, IEditorGroupsService } from '../common/editorGroupsService.js'; -import { diffEditorsAssociationsSettingId, EditorAssociation, EditorAssociations, EditorInputFactoryObject, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorsOptions, priorityToRank, RegisteredEditorInfo, RegisteredEditorOptions, RegisteredEditorPriority, RegisteredEditorRegistrationInfo, ResolvedEditor, ResolvedStatus, toRegisteredEditorPriorityInfo } from '../common/editorResolverService.js'; +import { diffEditorsAssociationsSettingId, EditorAssociation, EditorAssociations, EditorInputFactoryObject, EditorMatchRule, EditorMatchRuleSource, editorsAssociationsSettingId, globMatchesResource, EditorMatches, IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorMatchesOptions, IEditorResolverServiceGetEditorsOptions, isUnconfiguredUniversalOptionalEditorMatch, priorityToRank, RegisteredEditorInfo, RegisteredEditorOptions, RegisteredEditorPriority, RegisteredEditorRegistrationInfo, ResolvedEditor, ResolvedStatus, toRegisteredEditorPriorityInfo } from '../common/editorResolverService.js'; import { PreferredGroup } from '../common/editorService.js'; interface RegisteredEditor { @@ -38,6 +38,21 @@ interface RegisteredEditor { type RegisteredEditors = Array; +const enum EditorSelectionSource { + Requested, + UserAssociation, + EditorPriority, + None +} + +type EditorSelection = + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.Requested; readonly conflictingDefault: false } + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.UserAssociation; readonly association: EditorAssociation; readonly conflictingDefault: false } + | { readonly editor: RegisteredEditor; readonly source: EditorSelectionSource.EditorPriority; readonly conflictingDefault: boolean } + | { readonly editor: undefined; readonly source: EditorSelectionSource.None; readonly conflictingDefault: false }; + +type DefaultEditorSelection = Exclude; + function normalizeRegisteredEditorInfo(editorInfo: RegisteredEditorRegistrationInfo): RegisteredEditorInfo { return { id: editorInfo.id, @@ -285,9 +300,106 @@ export class EditorResolverService extends Disposable implements IEditorResolver return this.getAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); } - getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined { - const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; - return this.getAssociationsForResourceFromSetting(resource, settingId)[0]?.viewType; + getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches { + this._flattenedEditors = this._flattenEditorsMap(); + + const associationType = options?.isDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const associations = this.getAssociationsForResourceByType(resource, associationType); + const editors = this.findMatchingEditors(resource, associationType, associations); + const selection = this.getDefaultEditorSelection(resource, associationType, false, editors, associations); + const naturalEditors = this.findMatchingEditors(resource, associationType, []); + const naturalSelection = this.getDefaultEditorSelection(resource, associationType, false, naturalEditors, []); + + const uniqueEditors = distinct(editors, editor => editor.editorInfo.id); + const defaultEditorId = selection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + const naturalDefaultEditorId = naturalSelection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + const matches = uniqueEditors.map(editor => this.createEditorMatchRule(resource, associationType, editor, associations, selection)); + if (!matches.some(match => match.editor.id === DEFAULT_EDITOR_ASSOCIATION.id) + && (defaultEditorId === DEFAULT_EDITOR_ASSOCIATION.id || naturalDefaultEditorId === DEFAULT_EDITOR_ASSOCIATION.id)) { + const defaultEditor = this._registeredEditors.find(editor => editor.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id); + const editorInfo = defaultEditor?.editorInfo ?? { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + detail: DEFAULT_EDITOR_ASSOCIATION.providerDisplayName, + priority: toRegisteredEditorPriorityInfo(RegisteredEditorPriority.builtin) + }; + matches.unshift(Object.freeze({ + editor: editorInfo, + priority: RegisteredEditorPriority.builtin, + source: EditorMatchRuleSource.Fallback, + associationPattern: this.getDefaultAssociationPattern(resource) + })); + } + + const defaultRuleIndex = matches.findIndex(match => match.editor.id === defaultEditorId); + const naturalDefaultRuleIndex = matches.findIndex(match => match.editor.id === naturalDefaultEditorId); + if (defaultRuleIndex === -1 || naturalDefaultRuleIndex === -1) { + throw new Error('The effective and natural default editors must be matching editors.'); + } + + return new EditorMatches(matches, defaultRuleIndex, naturalDefaultRuleIndex, selection.conflictingDefault); + } + + private createEditorMatchRule(resource: URI, associationType: EditorAssociationType, editor: RegisteredEditor, associations: EditorAssociations, selection: DefaultEditorSelection): EditorMatchRule { + const priority = this.getEffectivePriority(editor.editorInfo, associationType); + if (!selection.editor && editor.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.Fallback, + associationPattern: this.getDefaultAssociationPattern(resource) + }; + } + + if (selection.editor?.editorInfo.id === editor.editorInfo.id) { + if (selection.source === EditorSelectionSource.UserAssociation) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.UserAssociation, + association: selection.association, + associationPattern: selection.association.filenamePattern ?? this.getDefaultAssociationPattern(resource, selection.editor) + }; + } + if (selection.source === EditorSelectionSource.EditorPriority) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: selection.editor.globPattern, + associationPattern: this.getDefaultAssociationPattern(resource, selection.editor) + }; + } + } + + const association = associations.find(association => association.viewType === editor.editorInfo.id); + if (association && priority !== RegisteredEditorPriority.exclusive) { + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.UserAssociation, + association, + associationPattern: association.filenamePattern ?? this.getDefaultAssociationPattern(resource, editor) + }; + } + + return { + editor: editor.editorInfo, + priority, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: editor.globPattern, + associationPattern: this.getDefaultAssociationPattern(resource, editor) + }; + } + + private getDefaultAssociationPattern(resource: URI, selectedEditor?: RegisteredEditor): string { + if (selectedEditor) { + return typeof selectedEditor.globPattern === 'string' && globMatchesResource(selectedEditor.globPattern, resource) + ? selectedEditor.globPattern + : `*${extname(resource)}`; + } + + return `*${extname(resource)}`; } private getAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { @@ -314,10 +426,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver } private getAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { - const matchingAssociations = this.getRawAssociationsForResourceFromSetting(resource, settingId); - const allEditors: RegisteredEditors = this._registeredEditors; - // Ensure that the settings are valid editors - return matchingAssociations.filter(association => allEditors.find(c => c.editorInfo.id === association.viewType)); + return this.getMatchingAssociationsForResource(resource, this.getAllUserAssociationsForSetting(settingId)); } private getRawAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { @@ -330,7 +439,15 @@ export class EditorResolverService extends Disposable implements IEditorResolver } private getRawAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { - const associations = this.getAllUserAssociationsForSetting(settingId); + return this.getMatchingRawAssociationsForResource(resource, this.getAllUserAssociationsForSetting(settingId)); + } + + private getMatchingAssociationsForResource(resource: URI, associations: EditorAssociations): EditorAssociations { + const matchingAssociations = this.getMatchingRawAssociationsForResource(resource, associations); + return matchingAssociations.filter(association => this._registeredEditors.some(editor => editor.editorInfo.id === association.viewType)); + } + + private getMatchingRawAssociationsForResource(resource: URI, associations: EditorAssociations): EditorAssociations { const matchingAssociations = associations.filter(association => association.filenamePattern && globMatchesResource(association.filenamePattern, resource)); // Sort matching associations based on glob length as a longer glob will be more specific return matchingAssociations.sort((a, b) => (b.filenamePattern?.length ?? 0) - (a.filenamePattern?.length ?? 0)); @@ -342,9 +459,18 @@ export class EditorResolverService extends Disposable implements IEditorResolver private getAllUserAssociationsForSetting(settingId: string): EditorAssociations { const inspectedEditorAssociations = this.configurationService.inspect<{ [fileNamePattern: string]: string }>(settingId) || {}; - const defaultAssociations = inspectedEditorAssociations.defaultValue ?? {}; - const workspaceAssociations = inspectedEditorAssociations.workspaceValue ?? {}; - const userAssociations = inspectedEditorAssociations.userValue ?? {}; + return this.mergeEditorAssociationSettings( + inspectedEditorAssociations.defaultValue ?? {}, + inspectedEditorAssociations.workspaceValue ?? {}, + inspectedEditorAssociations.userValue ?? {} + ); + } + + private mergeEditorAssociationSettings( + defaultAssociations: Readonly>, + workspaceAssociations: Readonly>, + userAssociations: Readonly> + ): EditorAssociations { const rawAssociations: { [fileNamePattern: string]: string } = { ...workspaceAssociations }; // We want to apply the default associations and user associations on top of the workspace associations but ignore duplicate keys. for (const [key, value] of Object.entries({ ...defaultAssociations, ...userAssociations })) { @@ -408,44 +534,103 @@ export class EditorResolverService extends Disposable implements IEditorResolver return Array.from(this._flattenedEditors.values()).flat(); } - updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void { - this.updateUserAssociationsForSetting(forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); - } + setDefaultEditor(resource: URI, editorID: string, forDiffEditor?: boolean): void { + const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; + const associationType = forDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const matches = this.getEditorMatches(resource, { isDiffEditor: forDiffEditor }); + const currentAssociation = matches.defaultRule.source === EditorMatchRuleSource.UserAssociation ? matches.defaultRule.association : undefined; + const associationPattern = matches.defaultRule.associationPattern; + if (editorID === matches.naturalDefaultRule.editor.id) { + if (currentAssociation && this.getDefaultEditorIdAfterRemovingAssociation(resource, associationType, settingId, associationPattern) === editorID) { + this.removeUserAssociationForSetting(settingId, associationPattern); + return; + } + if (!currentAssociation && matches.defaultRule.editor.id === editorID && !matches.conflictingDefault) { + return; + } + } - private updateUserAssociationsForType(associationType: EditorAssociationType, globPattern: string, editorID: string): void { - this.updateUserAssociationsForSetting(associationType === EditorAssociationType.DiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); + this.updateUserAssociationsForSetting(settingId, associationPattern, editorID); } private updateUserAssociationsForSetting(settingId: string, globPattern: string, editorID: string): void { - const newAssociation: EditorAssociation = { viewType: editorID, filenamePattern: globPattern }; - const currentAssociations = this.getAllUserAssociationsForSetting(settingId); - const newSettingObject = Object.create(null); - // Form the new setting object including the newest associations - for (const association of [...currentAssociations, newAssociation]) { - if (association.filenamePattern) { - newSettingObject[association.filenamePattern] = association.viewType; - } - } + const newSettingObject = this.toEditorAssociationSetting(this.getAllUserAssociationsForSetting(settingId)); + newSettingObject[globPattern] = editorID; this.configurationService.updateValue(settingId, newSettingObject); } + private getDefaultEditorIdAfterRemovingAssociation(resource: URI, associationType: EditorAssociationType, settingId: string, globPattern: string): string | undefined { + const remainingSettingAssociations = this.getAssociationsAfterRemovingAssociation(settingId, globPattern); + if (!remainingSettingAssociations) { + return undefined; + } + + let associations = this.getMatchingAssociationsForResource(resource, remainingSettingAssociations); + if (associationType === EditorAssociationType.DiffEditor && associations.length === 0) { + associations = this.getAssociationsForResource(resource) + .filter(association => !this.isExplicitForAssociationType(association.viewType, associationType)); + } + + const editors = this.findMatchingEditors(resource, associationType, associations); + const selection = this.getDefaultEditorSelection(resource, associationType, false, editors, associations); + return selection.conflictingDefault ? undefined : selection.editor?.editorInfo.id ?? DEFAULT_EDITOR_ASSOCIATION.id; + } + + private getAssociationsAfterRemovingAssociation(settingId: string, globPattern: string): EditorAssociations | undefined { + const inspectedAssociations = this.configurationService.inspect>(settingId); + if (!inspectedAssociations) { + return undefined; + } + + const explicitUserTargetCount = Number(inspectedAssociations.userRemoteValue !== undefined) + + Number(inspectedAssociations.userLocalValue !== undefined); + const userTargetCount = explicitUserTargetCount || Number(inspectedAssociations.userValue !== undefined); + const configuredTargetCount = Number(inspectedAssociations.workspaceFolderValue !== undefined) + + Number(inspectedAssociations.workspaceValue !== undefined) + + userTargetCount + + Number(inspectedAssociations.applicationValue !== undefined); + if (configuredTargetCount !== 1) { + return undefined; + } + + const updatedSetting = this.toEditorAssociationSetting(this.getAllUserAssociationsForSetting(settingId), globPattern); + if (inspectedAssociations.workspaceValue !== undefined) { + return this.mergeEditorAssociationSettings( + inspectedAssociations.defaultValue ?? {}, + updatedSetting, + inspectedAssociations.userValue ?? {} + ); + } + if (userTargetCount === 1) { + return this.mergeEditorAssociationSettings( + inspectedAssociations.defaultValue ?? {}, + inspectedAssociations.workspaceValue ?? {}, + updatedSetting + ); + } + return undefined; + } + private removeUserAssociationForSetting(settingId: string, globPattern: string): void { const currentAssociations = this.getAllUserAssociationsForSetting(settingId); if (!currentAssociations.some(association => association.filenamePattern === globPattern)) { return; } - const newSettingObject = Object.create(null); - for (const association of currentAssociations) { - if (association.filenamePattern && association.filenamePattern !== globPattern) { - newSettingObject[association.filenamePattern] = association.viewType; + this.configurationService.updateValue(settingId, this.toEditorAssociationSetting(currentAssociations, globPattern)); + } + + private toEditorAssociationSetting(associations: EditorAssociations, excludedPattern?: string): Record { + const settingObject: Record = Object.create(null); + for (const association of associations) { + if (association.filenamePattern && association.filenamePattern !== excludedPattern) { + settingObject[association.filenamePattern] = association.viewType; } } - this.configurationService.updateValue(settingId, newSettingObject); + return settingObject; } - private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor): RegisteredEditor[] { + private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor, userSettings = this.getAssociationsForResourceByType(resource, associationType)): RegisteredEditor[] { // The user setting should be respected even if the editor doesn't specify that resource in package.json - const userSettings = this.getAssociationsForResourceByType(resource, associationType); const matchingEditors: RegisteredEditor[] = []; // Then all glob patterns for (const [key, editors] of this._flattenedEditors) { @@ -483,24 +668,14 @@ export class EditorResolverService extends Disposable implements IEditorResolver // By resource if (URI.isUri(resourceOrOptions)) { - const resource = resourceOrOptions; - const associationType = options?.isDiffEditor ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; - let editors = this.findMatchingEditors(resource, associationType); - if (editors.find(editor => this.getEffectivePriority(editor.editorInfo, associationType) === RegisteredEditorPriority.exclusive)) { + const editorMatches = this.getEditorMatches(resourceOrOptions, options); + if (editorMatches.hasExclusiveMatch) { return []; } - if (options?.excludeUnconfiguredUniversalOptionalEditors) { - const configuredEditorIds = new Set(this.getAssociationsForResourceByType(resource, associationType).map(association => association.viewType)); - editors = editors.filter(editor => { - const priority = this.getEffectivePriority(editor.editorInfo, associationType); - return editor.globPattern !== '*' - || priority !== RegisteredEditorPriority.option - || editor.editorInfo.id === options.currentEditorId - || configuredEditorIds.has(editor.editorInfo.id); - }); - return distinct(editors.map(editor => editor.editorInfo), editor => editor.id); - } - return editors.map(editor => editor.editorInfo); + const matches = options?.excludeUnconfiguredUniversalOptionalEditors + ? editorMatches.matches.filter(match => !isUnconfiguredUniversalOptionalEditorMatch(match) || match.editor.id === options.currentEditorId) + : editorMatches.matches; + return matches.map(match => match.editor); } // All @@ -528,52 +703,44 @@ export class EditorResolverService extends Disposable implements IEditorResolver * Given a resource and an editorId selects the best possible editor * @returns The editor and whether there was another default which conflicted with it */ - private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined, associationType: EditorAssociationType): { editor: RegisteredEditor | undefined; conflictingDefault: boolean } { - - const findMatchingEditor = (editors: RegisteredEditors, viewType: string) => { - return editors.find((editor) => { - if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { - return false; - } - if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { - return false; - } - - if (editor.options?.canSupportResource !== undefined) { - return editor.editorInfo.id === viewType && editor.options.canSupportResource(resource); - } - return editor.editorInfo.id === viewType; - }); - }; - + private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined, associationType: EditorAssociationType): EditorSelection { if (editorId && editorId !== EditorResolution.EXCLUSIVE_ONLY) { // Specific id passed in doesn't have to match the resource, it can be anything - const registeredEditors = this._registeredEditors; - return { - editor: findMatchingEditor(registeredEditors, editorId), - conflictingDefault: false - }; + const editor = this.findEditor(resource, associationType, this._registeredEditors, editorId); + return editor + ? { editor, source: EditorSelectionSource.Requested, conflictingDefault: false } + : { editor: undefined, source: EditorSelectionSource.None, conflictingDefault: false }; } - const editors = this.findMatchingEditors(resource, associationType); + return this.getDefaultEditorSelection(resource, associationType, editorId === EditorResolution.EXCLUSIVE_ONLY); + } - const associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType); + private getDefaultEditorSelection(resource: URI, associationType: EditorAssociationType, exclusiveOnly = false, editors = this.findMatchingEditors(resource, associationType), associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType)): DefaultEditorSelection { // We only want minPriority+ if no user defined setting is found, else we won't resolve an editor - const minPriority = editorId === EditorResolution.EXCLUSIVE_ONLY ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; + const minPriority = exclusiveOnly ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; let possibleEditors = editors.filter(editor => priorityToRank(this.getEffectivePriority(editor.editorInfo, associationType)) >= priorityToRank(minPriority) && editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); if (possibleEditors.length === 0) { + const association = !exclusiveOnly ? associationsFromSetting[0] : undefined; + const editor = association ? this.findEditor(resource, associationType, editors, association.viewType) : undefined; + return editor && association + ? { editor, source: EditorSelectionSource.UserAssociation, association, conflictingDefault: false } + : { editor: undefined, source: EditorSelectionSource.None, conflictingDefault: false }; + } + // If the editor is exclusive we use that, else use the user setting, else we check canSupportResource, else take the viewtype of first possible editor + const configuredEditor = associationsFromSetting[0] ? this.findEditor(resource, associationType, editors, associationsFromSetting[0].viewType) : undefined; + const exclusiveEditor = this.getEffectivePriority(possibleEditors[0].editorInfo, associationType) === RegisteredEditorPriority.exclusive ? possibleEditors[0] : undefined; + if (configuredEditor && !exclusiveEditor) { return { - editor: associationsFromSetting[0] && minPriority !== RegisteredEditorPriority.exclusive ? findMatchingEditor(editors, associationsFromSetting[0].viewType) : undefined, + editor: configuredEditor, + source: EditorSelectionSource.UserAssociation, + association: associationsFromSetting[0], conflictingDefault: false }; } - // If the editor is exclusive we use that, else use the user setting, else we check canSupportResource, else take the viewtype of first possible editor - const configuredEditor = associationsFromSetting[0] ? findMatchingEditor(editors, associationsFromSetting[0].viewType) : undefined; - const selectedViewType = this.getEffectivePriority(possibleEditors[0].editorInfo, associationType) === RegisteredEditorPriority.exclusive ? - possibleEditors[0].editorInfo.id : - configuredEditor?.editorInfo.id || - (possibleEditors.find(editor => (!editor.options?.canSupportResource || editor.options.canSupportResource(resource)))?.editorInfo.id) || - possibleEditors[0].editorInfo.id; + + const selectedEditor = exclusiveEditor + ?? possibleEditors.find(editor => !editor.options?.canSupportResource || editor.options.canSupportResource(resource)) + ?? possibleEditors[0]; let conflictingDefault = false; @@ -587,11 +754,24 @@ export class EditorResolverService extends Disposable implements IEditorResolver } return { - editor: findMatchingEditor(editors, selectedViewType), + editor: selectedEditor, + source: EditorSelectionSource.EditorPriority, conflictingDefault }; } + private findEditor(resource: URI, associationType: EditorAssociationType, editors: RegisteredEditors, editorId: string): RegisteredEditor | undefined { + return editors.find(editor => { + if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { + return false; + } + if (associationType === EditorAssociationType.MergeEditor && !editor.editorFactoryObject.createMergeEditorInput) { + return false; + } + return editor.editorInfo.id === editorId && (!editor.options?.canSupportResource || editor.options.canSupportResource(resource)); + }); + } + private getEffectivePriority(editorInfo: RegisteredEditorInfo, associationType: EditorAssociationType): RegisteredEditorPriority { switch (associationType) { case EditorAssociationType.DiffEditor: @@ -799,65 +979,61 @@ export class EditorResolverService extends Disposable implements IEditorResolver }); } - private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker: boolean | undefined, associationType: EditorAssociationType) { + private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker: boolean | undefined, associationType: EditorAssociationType, defaultAssociationType = associationType) { const currentEditor = this.editorGroupService.activeGroup.findEditors(resource).at(0); // If untitled, we want all registered editors - let registeredEditors = resource.scheme === Schemas.untitled ? this._registeredEditors.filter(e => e.editorInfo.priority.editor !== RegisteredEditorPriority.exclusive) : this.findMatchingEditors(resource, associationType); - if (associationType === EditorAssociationType.DiffEditor) { - registeredEditors = registeredEditors.filter(editor => !!editor.editorFactoryObject.createDiffEditorInput); - } + let registeredEditors = resource.scheme === Schemas.untitled + ? this._registeredEditors + .filter(editor => editor.editorInfo.priority.editor !== RegisteredEditorPriority.exclusive) + .filter(editor => associationType !== EditorAssociationType.DiffEditor || !!editor.editorFactoryObject.createDiffEditorInput) + .map(editor => editor.editorInfo) + : this.getEditorMatches(resource, { isDiffEditor: associationType === EditorAssociationType.DiffEditor }).matches.map(match => match.editor); // We don't want duplicate Id entries - registeredEditors = distinct(registeredEditors, c => c.editorInfo.id); - const defaultSetting = this.getAssociationsForResourceByType(resource, associationType)[0]?.viewType; + registeredEditors = distinct(registeredEditors, editor => editor.id); + const defaultRule = this.getEditorMatches(resource, { isDiffEditor: defaultAssociationType === EditorAssociationType.DiffEditor }).defaultRule; // Not the most efficient way to do this, but we want to ensure the text editor is at the top of the quickpick registeredEditors = registeredEditors.sort((a, b) => { - if (a.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + if (a.id === DEFAULT_EDITOR_ASSOCIATION.id) { return -1; - } else if (b.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { + } else if (b.id === DEFAULT_EDITOR_ASSOCIATION.id) { return 1; } else { - return priorityToRank(this.getEffectivePriority(b.editorInfo, associationType)) - priorityToRank(this.getEffectivePriority(a.editorInfo, associationType)); + return priorityToRank(this.getEffectivePriority(b, associationType)) - priorityToRank(this.getEffectivePriority(a, associationType)); } }); const quickPickEntries: Array = []; const currentlyActiveLabel = localize('promptOpenWith.currentlyActive', "Active"); const currentDefaultLabel = localize('promptOpenWith.currentDefault', "Default"); const currentDefaultAndActiveLabel = localize('promptOpenWith.currentDefaultAndActive', "Active and Default"); - // Default order = setting -> highest priority -> text - let defaultViewType = defaultSetting; - if (!defaultViewType && registeredEditors.length > 2 && this.getEffectivePriority(registeredEditors[1].editorInfo, associationType) !== RegisteredEditorPriority.option) { - defaultViewType = registeredEditors[1]?.editorInfo.id; - } - if (!defaultViewType) { - defaultViewType = DEFAULT_EDITOR_ASSOCIATION.id; - } // Map the editors to quickpick entries registeredEditors.forEach(editor => { const currentViewType = currentEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id; - const isActive = currentEditor ? editor.editorInfo.id === currentViewType : false; - const isDefault = editor.editorInfo.id === defaultViewType; + const isActive = currentEditor ? editor.id === currentViewType : false; + const isDefault = editor.id === defaultRule.editor.id; const quickPickEntry: IQuickPickItem = { - id: editor.editorInfo.id, - label: editor.editorInfo.label, + id: editor.id, + label: editor.label, description: isActive && isDefault ? currentDefaultAndActiveLabel : isActive ? currentlyActiveLabel : isDefault ? currentDefaultLabel : undefined, - detail: editor.editorInfo.detail ?? editor.editorInfo.priority.editor, + detail: editor.detail ?? editor.priority.editor, }; quickPickEntries.push(quickPickEntry); }); if (!showDefaultPicker && extname(resource) !== '') { const separator: IQuickPickSeparator = { type: 'separator' }; quickPickEntries.push(separator); + const editorDefault = this.getEditorMatches(resource).defaultRule; const configureDefaultEntry = { id: EditorResolverService.configureDefaultID, - label: localize('promptOpenWith.configureDefault', "Configure default editor for '{0}'...", `*${extname(resource)}`), + label: localize('promptOpenWith.configureDefault', "Configure default editor for '{0}'...", editorDefault.associationPattern), }; quickPickEntries.push(configureDefaultEntry); // For diffs, additionally offer to configure a diff-only default so the choice does not // affect how the resource opens as a normal editor (writes to `diffEditorAssociations`). if (associationType === EditorAssociationType.DiffEditor) { + const diffEditorDefault = this.getEditorMatches(resource, { isDiffEditor: true }).defaultRule; const configureDefaultDiffEntry = { id: EditorResolverService.configureDefaultDiffID, - label: localize('promptOpenWith.configureDefaultDiff', "Configure default editor (diff only) for '{0}'...", `*${extname(resource)}`), + label: localize('promptOpenWith.configureDefaultDiff', "Configure default editor (diff only) for '{0}'...", diffEditorDefault.associationPattern), }; quickPickEntries.push(configureDefaultDiffEntry); } @@ -883,28 +1059,33 @@ export class EditorResolverService extends Disposable implements IEditorResolver // so that the per-item gear button keeps writing to the matching setting, but the "Configure // default editor" entries can target a specific setting (general vs. diff-only). const updateSettingType = updateAssociationType ?? associationType; + const defaultRule = this.getEditorMatches(resource, { isDiffEditor: updateSettingType === EditorAssociationType.DiffEditor }).defaultRule; // Persists the picked editor as the default for this resource's glob. When the user configures // the general default from a diff context, any diff-only override for the same glob is cleared // so that the general default also takes effect for diffs. const persistDefaultAssociation = (editorID: string) => { - const globPattern = `*${extname(resource)}`; - this.updateUserAssociationsForType(updateSettingType, globPattern, editorID); + const associationPattern = defaultRule.associationPattern; + this.setDefaultEditor(resource, editorID, updateSettingType === EditorAssociationType.DiffEditor); if (updateSettingType === EditorAssociationType.Editor && associationType === EditorAssociationType.DiffEditor) { - this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, globPattern); + const matchingDiffAssociation = this.getRawAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId) + .find(association => association.filenamePattern === associationPattern); + if (matchingDiffAssociation?.filenamePattern) { + this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, matchingDiffAssociation.filenamePattern); + } } }; // Get all the editors for the resource as quickpick entries - const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType); + const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType, updateSettingType); // Create the editor picker const disposables = new DisposableStore(); const editorPicker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const placeHolderMessage = showDefaultPicker ? (updateSettingType === EditorAssociationType.DiffEditor ? - localize('promptOpenWith.updateDefaultDiffPlaceHolder', "Select new default editor (diff only) for '{0}'", `*${extname(resource)}`) : - localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", `*${extname(resource)}`)) : + localize('promptOpenWith.updateDefaultDiffPlaceHolder', "Select new default editor (diff only) for '{0}'", defaultRule.associationPattern) : + localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", defaultRule.associationPattern)) : localize('promptOpenWith.placeHolder', "Select editor for '{0}'", basename(resource)); editorPicker.placeholder = placeHolderMessage; editorPicker.canAcceptInBackground = true; diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index 73467c02ce19e0..de13324904d112 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -166,6 +166,10 @@ export interface IEditorResolverServiceGetEditorsOptions { readonly isDiffEditor?: boolean; } +export interface IEditorResolverServiceGetEditorMatchesOptions { + readonly isDiffEditor?: boolean; +} + export interface IEditorResolverServiceGetAllEditorsOptions { /** * Excludes registrations whose editor priority is exclusive. @@ -173,6 +177,90 @@ export interface IEditorResolverServiceGetAllEditorsOptions { readonly excludeExclusiveEditors?: boolean; } +export const enum EditorMatchRuleSource { + UserAssociation, + EditorRegistration, + Fallback +} + +/** + * The effective rule that makes one editor choice available for a resource. + */ +export type EditorMatchRule = { + readonly editor: RegisteredEditorInfo; + readonly priority: RegisteredEditorPriority; + readonly associationPattern: string; +} & ( + | { readonly source: EditorMatchRuleSource.UserAssociation; readonly association: EditorAssociation } + | { readonly source: EditorMatchRuleSource.EditorRegistration; readonly globPattern: string | glob.IRelativePattern } + | { readonly source: EditorMatchRuleSource.Fallback } + ); + +export function isUnconfiguredUniversalOptionalEditorMatch(rule: EditorMatchRule): boolean { + return rule.source === EditorMatchRuleSource.EditorRegistration + && rule.globPattern === '*' + && rule.priority === RegisteredEditorPriority.option; +} + +function freezeEditorMatchRule(rule: EditorMatchRule): EditorMatchRule { + const editor = Object.freeze({ + ...rule.editor, + priority: Object.freeze({ ...rule.editor.priority }) + }); + switch (rule.source) { + case EditorMatchRuleSource.UserAssociation: + return Object.freeze({ ...rule, editor, association: Object.freeze({ ...rule.association }) }); + case EditorMatchRuleSource.EditorRegistration: + return Object.freeze({ + ...rule, + editor, + globPattern: typeof rule.globPattern === 'string' ? rule.globPattern : Object.freeze({ ...rule.globPattern }) + }); + case EditorMatchRuleSource.Fallback: + return Object.freeze({ ...rule, editor }); + } +} + +/** + * An immutable snapshot containing one effective rule per matching editor and the rule selecting its default. + */ +export class EditorMatches { + readonly matches: readonly EditorMatchRule[]; + readonly defaultRuleIndex: number; + readonly defaultRule: EditorMatchRule; + readonly naturalDefaultRuleIndex: number; + readonly naturalDefaultRule: EditorMatchRule; + readonly conflictingDefault: boolean; + readonly hasExclusiveMatch: boolean; + + constructor(matches: readonly EditorMatchRule[], defaultRuleIndex: number, naturalDefaultRuleIndex: number, conflictingDefault: boolean) { + if (defaultRuleIndex < 0 || defaultRuleIndex >= matches.length) { + throw new RangeError('The default editor rule must be an item in the matches array.'); + } + if (naturalDefaultRuleIndex < 0 || naturalDefaultRuleIndex >= matches.length) { + throw new RangeError('The natural default editor rule must be an item in the matches array.'); + } + if (new Set(matches.map(match => match.editor.id)).size !== matches.length) { + throw new RangeError('Each editor must have exactly one effective match rule.'); + } + if (conflictingDefault && matches[defaultRuleIndex].source !== EditorMatchRuleSource.EditorRegistration) { + throw new RangeError('Only a registered editor default can conflict with another default.'); + } + if (matches.some((match, index) => match.source === EditorMatchRuleSource.Fallback && index !== defaultRuleIndex && index !== naturalDefaultRuleIndex)) { + throw new RangeError('A fallback rule must select the effective or natural default editor.'); + } + + this.matches = Object.freeze(matches.map(freezeEditorMatchRule)); + this.defaultRuleIndex = defaultRuleIndex; + this.defaultRule = this.matches[defaultRuleIndex]; + this.naturalDefaultRuleIndex = naturalDefaultRuleIndex; + this.naturalDefaultRule = this.matches[naturalDefaultRuleIndex]; + this.conflictingDefault = conflictingDefault; + this.hasExclusiveMatch = this.matches.some(match => match.priority === RegisteredEditorPriority.exclusive); + Object.freeze(this); + } +} + export type RegisteredEditorPriorityInfo = { readonly editor: RegisteredEditorPriority; readonly diff: RegisteredEditorPriority; @@ -241,22 +329,19 @@ export interface IEditorResolverService { getAssociationsForResource(resource: URI): EditorAssociations; /** - * Returns the view type of the user-configured default editor for a resource, or `undefined` when - * none is configured. When `forDiffEditor` is `true` the diff editor association setting - * (`workbench.diffEditorAssociations`) is consulted instead of the general one. - * @param resource The resource to match - * @param forDiffEditor Whether to read the diff editor association setting + * Returns an immutable snapshot of the editors matching a resource and the rule selecting its default. */ - getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined; + getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches; /** - * Updates the user's association to include a specific editor ID as a default for the given glob pattern - * @param globPattern The glob pattern (must be a string as settings don't support relative glob) - * @param editorID The ID of the editor to make a user default + * Sets an editor as the default for a resource, removing a redundant association when restoring + * the natural default supplied by editor registrations or the Text Editor fallback. + * @param resource The resource whose editor default is changing. + * @param editorID The ID of the editor to make the default. * @param forDiffEditor When `true`, the diff editor association (`workbench.diffEditorAssociations`) * is updated instead of the general editor association (`workbench.editorAssociations`). */ - updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void; + setDefaultEditor(resource: URI, editorID: string, forDiffEditor?: boolean): void; /** * Emitted when an editor is registered or unregistered. diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index e4a376858277e8..ad502d16415705 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -9,10 +9,11 @@ import { Schemas } from '../../../../../base/common/network.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { EditorPart } from '../../../../browser/parts/editor/editorPart.js'; +import { DEFAULT_EDITOR_ASSOCIATION } from '../../../../common/editor.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { EditorResolverService } from '../../browser/editorResolverService.js'; import { IEditorGroupsService } from '../../common/editorGroupsService.js'; -import { diffEditorsAssociationsAgentsWindowDefault, EditorInputFactoryObject, IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; +import { diffEditorsAssociationsAgentsWindowDefault, EditorInputFactoryObject, EditorMatchRuleSource, EditorMatches, IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { createEditorPart, ITestInstantiationService, TestFileEditorInput, TestServiceAccessor, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; @@ -62,6 +63,46 @@ suite('EditorResolverService', () => { undefined); } + function registerDefaultEditorTestEditors(service: EditorResolverService, additionalDefaultEditorId?: string): void { + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }), + createDiffEditorInput: ({ modified }) => { + if (!modified.resource) { + throw new Error('Expected modified resource.'); + } + return { editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }; + } + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + if (additionalDefaultEditorId) { + disposables.add(service.registerEditor('*.component.html', { + id: additionalDefaultEditorId, + label: 'Additional Component Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + } + } + + function createMutableConfigurationService(configuration: Record): TestConfigurationService & { readonly updateCount: number } { + return new class extends TestConfigurationService { + updateCount = 0; + + override async updateValue(key: string, value: unknown): Promise { + this.updateCount++; + await this.setUserConfiguration(key, value); + } + }(configuration); + } + test('Simple Resolve', async () => { const [part, service] = await createEditorResolverService(); const registeredEditor = service.registerEditor('*.test', @@ -913,6 +954,314 @@ suite('EditorResolverService', () => { }); }); + test('getEditorMatches derives the default rule from the complete match set', async () => { + const [, service] = await createEditorResolverService(); + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }), + createDiffEditorInput: ({ modified }) => { + if (!modified.resource) { + throw new Error('Expected modified resource.'); + } + return { editor: new TestFileEditorInput(modified.resource, TEST_EDITOR_INPUT_ID) }; + } + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('file:/**/*.html', { + id: 'test.browser', + label: 'Browser', + priority: RegisteredEditorPriority.option + }, {}, factory)); + disposables.add(service.registerEditor('*.component.html', { + id: 'test.componentEditor', + label: 'Component Editor', + priority: { + editor: RegisteredEditorPriority.default, + diff: RegisteredEditorPriority.explicit + } + }, {}, factory)); + + const componentResource = URI.file('/workspace/example.component.html'); + const htmlResource = URI.file('/workspace/example.html'); + const summarize = (matches: EditorMatches) => ({ + editorIds: matches.matches.map(match => match.editor.id), + defaultRuleIndex: matches.defaultRuleIndex, + defaultRule: { + editorId: matches.defaultRule.editor.id, + source: matches.defaultRule.source, + associationPattern: matches.defaultRule.associationPattern + }, + defaultIsArrayItem: matches.defaultRule === matches.matches[matches.defaultRuleIndex], + naturalDefaultEditorId: matches.naturalDefaultRule.editor.id, + naturalDefaultIsArrayItem: matches.naturalDefaultRule === matches.matches[matches.naturalDefaultRuleIndex], + immutable: Object.isFrozen(matches) + && Object.isFrozen(matches.matches) + && matches.matches.every(match => Object.isFrozen(match) && Object.isFrozen(match.editor) && Object.isFrozen(match.editor.priority)) + }); + assert.deepStrictEqual({ + component: summarize(service.getEditorMatches(componentResource)), + componentDiff: summarize(service.getEditorMatches(componentResource, { isDiffEditor: true })), + html: summarize(service.getEditorMatches(htmlResource)) + }, { + component: { + editorIds: ['test.componentEditor', DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser'], + defaultRuleIndex: 0, + defaultRule: { + editorId: 'test.componentEditor', + source: EditorMatchRuleSource.EditorRegistration, + associationPattern: '*.component.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: 'test.componentEditor', + naturalDefaultIsArrayItem: true, + immutable: true + }, + componentDiff: { + editorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser', 'test.componentEditor'], + defaultRuleIndex: 0, + defaultRule: { + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + source: EditorMatchRuleSource.Fallback, + associationPattern: '*.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultIsArrayItem: true, + immutable: true + }, + html: { + editorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.browser'], + defaultRuleIndex: 0, + defaultRule: { + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + source: EditorMatchRuleSource.Fallback, + associationPattern: '*.html' + }, + defaultIsArrayItem: true, + naturalDefaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultIsArrayItem: true, + immutable: true + } + }); + assert.throws(() => new EditorMatches([], 0, 0, false), RangeError); + }); + + test('getEditorMatches reports the user association that selected the default', async () => { + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }) + }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + + const matches = service.getEditorMatches(URI.file('/workspace/example.component.html')); + assert.deepStrictEqual({ + defaultIsArrayItem: matches.defaultRule === matches.matches[matches.defaultRuleIndex], + editorId: matches.defaultRule.editor.id, + naturalDefaultEditorId: matches.naturalDefaultRule.editor.id, + source: matches.defaultRule.source, + associationPattern: matches.defaultRule.associationPattern + }, { + defaultIsArrayItem: true, + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + naturalDefaultEditorId: 'test.componentEditor', + source: EditorMatchRuleSource.UserAssociation, + associationPattern: '*.html' + }); + }); + + test('setDefaultEditor removes the association when restoring the natural registered default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id, + updateCount: configurationService.updateCount + }, { + associations: [], + defaultEditorId: 'test.componentEditor', + updateCount: 1 + }); + }); + + test('setDefaultEditor retains the association when a broader rule masks the natural default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id, + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id + }, { + associations: [ + { filenamePattern: '*.html', viewType: DEFAULT_EDITOR_ASSOCIATION.id }, + { filenamePattern: '*.component.html', viewType: 'test.componentEditor' } + ], + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor retains the diff association when the inherited rule masks the natural default', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.html': DEFAULT_EDITOR_ASSOCIATION.id + }, + [diffEditorsAssociationsSettingId]: { + '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor', true); + + assert.deepStrictEqual({ + diffAssociations: Object.entries(configurationService.getValue>(diffEditorsAssociationsSettingId) ?? {}), + defaultEditorId: service.getEditorMatches(resource, { isDiffEditor: true }).defaultRule.editor.id + }, { + diffAssociations: [['*.component.html', 'test.componentEditor']], + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor retains the association supplied by the default configuration layer', async () => { + const defaultAssociations = { '*.component.html': DEFAULT_EDITOR_ASSOCIATION.id }; + let userAssociations: Record | undefined; + const configurationService = new class extends TestConfigurationService { + override inspect(key: string) { + if (key !== editorsAssociationsSettingId) { + return super.inspect(key); + } + const value = { ...defaultAssociations, ...userAssociations }; + return { + value: value as T, + defaultValue: defaultAssociations as T, + userValue: userAssociations as T | undefined, + userLocalValue: userAssociations as T | undefined + }; + } + + override async updateValue(key: string, value: unknown): Promise { + if (key !== editorsAssociationsSettingId || !value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Expected editor associations.'); + } + const entries = Object.entries(value); + if (!entries.every((entry): entry is [string, string] => typeof entry[1] === 'string')) { + throw new Error('Expected editor association values.'); + } + userAssociations = Object.fromEntries(entries); + } + }(); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + + assert.deepStrictEqual({ + userAssociations, + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id + }, { + userAssociations: { + '*.component.html': 'test.componentEditor' + }, + defaultEditorId: 'test.componentEditor' + }); + }); + + test('setDefaultEditor pins the selected editor when natural defaults conflict', async () => { + const configurationService = createMutableConfigurationService({}); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + registerDefaultEditorTestEditors(service, 'test.additionalComponentEditor'); + const resource = URI.file('/workspace/example.component.html'); + + service.setDefaultEditor(resource, 'test.componentEditor'); + service.setDefaultEditor(resource, 'test.componentEditor'); + + const matches = service.getEditorMatches(resource); + assert.deepStrictEqual({ + associations: service.getAllUserAssociations(), + defaultEditorId: matches.defaultRule.editor.id, + conflictingDefault: matches.conflictingDefault, + updateCount: configurationService.updateCount + }, { + associations: [{ filenamePattern: '*.component.html', viewType: 'test.componentEditor' }], + defaultEditorId: 'test.componentEditor', + conflictingDefault: false, + updateCount: 2 + }); + }); + + test('setDefaultEditor ignores a more specific association for an unregistered editor', async () => { + const configurationService = createMutableConfigurationService({ + [editorsAssociationsSettingId]: { + '*.component.html': 'test.unregisteredEditor' + } + }); + const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService }, disposables); + const [, service] = await createEditorResolverService(instantiationService); + const factory: EditorInputFactoryObject = { + createEditorInput: ({ resource }) => ({ editor: new TestFileEditorInput(resource, TEST_EDITOR_INPUT_ID) }) + }; + disposables.add(service.registerEditor('*', { + id: DEFAULT_EDITOR_ASSOCIATION.id, + label: DEFAULT_EDITOR_ASSOCIATION.displayName, + priority: RegisteredEditorPriority.builtin + }, {}, factory)); + disposables.add(service.registerEditor('*.html', { + id: 'test.htmlEditor', + label: 'HTML Editor', + priority: RegisteredEditorPriority.default + }, {}, factory)); + const resource = URI.file('/workspace/example.component.html'); + + const displayedAssociationPattern = service.getEditorMatches(resource).defaultRule.associationPattern; + service.setDefaultEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id); + + assert.deepStrictEqual({ + displayedAssociationPattern, + associations: service.getAllUserAssociations(), + defaultEditorId: service.getEditorMatches(resource).defaultRule.editor.id + }, { + displayedAssociationPattern: '*.html', + associations: [ + { filenamePattern: '*.component.html', viewType: 'test.unregisteredEditor' }, + { filenamePattern: '*.html', viewType: DEFAULT_EDITOR_ASSOCIATION.id } + ], + defaultEditorId: DEFAULT_EDITOR_ASSOCIATION.id + }); + }); + test('getEditors uses the effective diff priority', async () => { const [, service] = await createEditorResolverService(); const resource = URI.file('/workspace/index.html'); 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/chatPills.test.ts b/src/vs/workbench/test/browser/chatPills.test.ts new file mode 100644 index 00000000000000..e5a69d4ff4061d --- /dev/null +++ b/src/vs/workbench/test/browser/chatPills.test.ts @@ -0,0 +1,482 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getWindow } from '../../../base/browser/dom.js'; +import { ensureCodeWindow, mainWindow } from '../../../base/browser/window.js'; +import type { IManagedHoverContent } from '../../../base/browser/ui/hover/hover.js'; +import { timeout } from '../../../base/common/async.js'; +import { Action } from '../../../base/common/actions.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.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'; +import { IActionListDelegate, IActionListItem } from '../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../platform/actionWidget/browser/actionWidget.js'; +import { ChatDropdownPillActionViewItem, ChatPillSingleEntry, createChatSectionPill } from '../../browser/chatDropdownPill.js'; +import { ChatPillsRow, ChatPillsWidget, type IChatPill, type IChatPillEntry, type IChatPillSection } from '../../browser/chatPills.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../browser/labels.js'; +import { workbenchInstantiationService } from './workbenchTestServices.js'; + +const getDropdownPillHoverContents = Reflect.get(ChatDropdownPillActionViewItem.prototype, 'getHoverContents') as (this: ChatDropdownPillActionViewItem) => IManagedHoverContent; + +suite('ChatPills', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps an empty compact pill row keyboard-accessible', async () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.test', { compact: true })); + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + let contextMenuRequests = 0; + let contextMenuTarget: HTMLElement | undefined; + disposables.add(row.onDidRequestContextMenu(target => { + contextMenuRequests++; + contextMenuTarget = target; + })); + + row.setEmpty(true, 'Configure Session Status Pills'); + const event = new mainWindow.KeyboardEvent('keydown', { bubbles: true }); + Object.defineProperty(event, 'keyCode', { value: 13 }); + row.content.dispatchEvent(event); + row.restoreFocus(() => []); + await timeout(0); + const emptyState = { + compact: row.element.classList.contains('compact'), + role: row.content.getAttribute('role'), + ariaLabel: row.content.getAttribute('aria-label'), + ariaHasPopup: row.content.getAttribute('aria-haspopup'), + tabIndex: row.content.tabIndex, + contextMenuRequests, + focused: mainWindow.document.activeElement === row.content, + contextTarget: contextMenuTarget === row.content, + }; + const pill = mainWindow.document.createElement('button'); + row.content.appendChild(pill); + row.setEmpty(false, ''); + row.restoreFocus(() => [pill]); + await timeout(0); + + assert.deepStrictEqual({ + emptyState, + restored: { + role: row.content.getAttribute('role'), + ariaLabel: row.content.getAttribute('aria-label'), + ariaHasPopup: row.content.getAttribute('aria-haspopup'), + tabIndex: row.content.getAttribute('tabindex'), + pillFocused: mainWindow.document.activeElement === pill, + }, + }, { + emptyState: { + compact: true, + role: 'button', + ariaLabel: 'Configure Session Status Pills', + ariaHasPopup: 'menu', + tabIndex: 0, + contextMenuRequests: 1, + focused: true, + contextTarget: true, + }, + restored: { + role: null, + ariaLabel: null, + ariaHasPopup: null, + tabIndex: null, + pillFocused: true, + }, + }); + + disposables.dispose(); + }); + + test('uses the main DOM realm and target auxiliary window', () => { + const disposables = store.add(new DisposableStore()); + const iframe = mainWindow.document.createElement('iframe'); + mainWindow.document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + const auxiliaryWindow = iframe.contentWindow!; + ensureCodeWindow(auxiliaryWindow, 999); + + const row = disposables.add(new ChatPillsRow('ChatPills.auxiliaryWindowTest', { targetWindow: auxiliaryWindow })); + auxiliaryWindow.document.body.appendChild(row.element); + + assert.deepStrictEqual({ + contentDocument: row.content.ownerDocument === auxiliaryWindow.document, + elementDocument: row.element.ownerDocument === auxiliaryWindow.document, + contentUsesMainPrototype: Object.getPrototypeOf(row.content) === mainWindow.HTMLDivElement.prototype, + elementUsesMainPrototype: Object.getPrototypeOf(row.element) === mainWindow.HTMLDivElement.prototype, + windowId: getWindow(row.content).vscodeWindowId, + }, { + contentDocument: true, + elementDocument: true, + contentUsesMainPrototype: true, + elementUsesMainPrototype: true, + windowId: 999, + }); + + disposables.dispose(); + }); + + test('compact rows collapse pill details while retaining icons', () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.compactTest', { compact: true })); + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + + const button = mainWindow.document.createElement('button'); + button.className = 'monaco-button chat-pill-button chat-resource-pill-button'; + const item = mainWindow.document.createElement('div'); + item.className = 'chat-pill-item'; + const icon = mainWindow.document.createElement('span'); + icon.className = 'chat-pill-icon'; + const label = mainWindow.document.createElement('span'); + label.className = 'chat-pill-label'; + const counter = mainWindow.document.createElement('div'); + counter.className = 'monaco-animated-counter'; + const chevron = mainWindow.document.createElement('span'); + chevron.className = 'chat-pill-chevron'; + const resourceIcon = mainWindow.document.createElement('span'); + resourceIcon.className = 'chat-resource-pill-compact-icon'; + const resourceName = mainWindow.document.createElement('span'); + resourceName.className = 'monaco-icon-label'; + button.append(icon, label, counter, chevron, resourceIcon, resourceName); + item.appendChild(button); + row.content.appendChild(item); + + const compactState = { + iconVisible: mainWindow.getComputedStyle(icon).display !== 'none', + labelVisible: mainWindow.getComputedStyle(label).display !== 'none', + counterVisible: mainWindow.getComputedStyle(counter).display !== 'none', + chevronVisible: mainWindow.getComputedStyle(chevron).display !== 'none', + resourceIconVisible: mainWindow.getComputedStyle(resourceIcon).display !== 'none', + resourceNameVisible: mainWindow.getComputedStyle(resourceName).display !== 'none', + }; + row.element.classList.remove('compact'); + + assert.deepStrictEqual({ + compactState, + expandedResourceIconVisible: mainWindow.getComputedStyle(resourceIcon).display !== 'none', + }, { + compactState: { + iconVisible: true, + labelVisible: false, + counterVisible: false, + chevronVisible: false, + resourceIconVisible: true, + resourceNameVisible: false, + }, + expandedResourceIconVisible: false, + }); + + disposables.dispose(); + }); + + test('automatic compact mode follows available width', () => { + const disposables = store.add(new DisposableStore()); + const row = disposables.add(new ChatPillsRow('ChatPills.responsiveTest', { compact: 'auto' })); + row.element.style.width = '600px'; + mainWindow.document.body.appendChild(row.element); + disposables.add(toDisposable(() => row.element.remove())); + + const item = mainWindow.document.createElement('div'); + item.className = 'chat-pill-item'; + const button = mainWindow.document.createElement('button'); + button.className = 'monaco-button chat-pill-button'; + const icon = mainWindow.document.createElement('span'); + icon.className = 'chat-pill-icon'; + const label = mainWindow.document.createElement('span'); + label.className = 'chat-pill-label'; + label.textContent = 'A detailed pill label that needs room'; + button.append(icon, label); + item.appendChild(button); + row.content.appendChild(item); + + row.layout(); + const wideCompact = row.element.classList.contains('compact'); + row.element.style.width = '500px'; + row.layout(); + const mediumCompact = row.element.classList.contains('compact'); + row.element.style.width = '40px'; + row.layout(); + const narrowCompact = row.element.classList.contains('compact'); + row.element.style.width = '600px'; + row.layout(); + + assert.deepStrictEqual({ + wideCompact, + mediumCompact, + narrowCompact, + expandedAgain: !row.element.classList.contains('compact'), + }, { + wideCompact: false, + mediumCompact: false, + narrowCompact: true, + expandedAgain: true, + }); + + disposables.dispose(); + }); + + test('preserves existing pill DOM when membership changes', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const firstPill: IChatPill = { action: disposables.add(new Action('first', 'First')) }; + const secondPill: IChatPill = { action: disposables.add(new Action('second', 'Second')) }; + const pills = observableValue('chatPills.membership', [firstPill]); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const firstButton = widget.getPillElements()[0]; + firstButton.focus(); + + pills.set([firstPill, secondPill], undefined); + const afterAdd = widget.getPillElements(); + const focusPreservedAfterAdd = mainWindow.document.activeElement === firstButton; + pills.set([secondPill], undefined); + const afterRemove = widget.getPillElements(); + + assert.deepStrictEqual({ + afterAddCount: afterAdd.length, + firstPreservedAfterAdd: afterAdd[0] === firstButton, + focusPreservedAfterAdd, + afterRemoveCount: afterRemove.length, + remainingTabIndex: afterRemove[0].tabIndex, + focusMovedAfterRemove: mainWindow.document.activeElement === afterRemove[0], + }, { + afterAddCount: 2, + firstPreservedAfterAdd: true, + focusPreservedAfterAdd: true, + afterRemoveCount: 1, + remainingTabIndex: 0, + focusMovedAfterRemove: true, + }); + + disposables.dispose(); + }); + + test('keeps a section pill stable while its visible presentation is unchanged', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const entry = (id: string): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + open: () => { }, + }); + const sections = observableValue('chatPills.sections', [{ + title: 'Pull Requests', + entries: [entry('1'), entry('2')], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + singleEntry: ChatPillSingleEntry.InlineResource, + }, resourceLabels, instantiationService); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills: pill.map(value => [value]) }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const descriptor = pill.get(); + const button = widget.getPillElements()[0]; + const label = button.querySelector('.chat-pill-label'); + + sections.set([{ + title: 'Pull Requests', + entries: [ + { ...entry('1'), resource: URI.parse('https://github.com/microsoft/vscode/pull/1') }, + entry('2'), + ], + }], undefined); + + assert.deepStrictEqual({ + descriptorPreserved: pill.get() === descriptor, + buttonPreserved: widget.getPillElements()[0] === button, + labelPreserved: button.querySelector('.chat-pill-label') === label, + labelText: label?.textContent, + }, { + descriptorPreserved: true, + buttonPreserved: true, + labelPreserved: true, + labelText: '2 Pull Requests', + }); + + disposables.dispose(); + }); + + test('uses optional rich hover content only for an inline entry', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const richHover: IManagedHoverContent = { element: () => mainWindow.document.createElement('div') }; + const entry = (id: string, pillHover?: IManagedHoverContent): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + tooltip: `https://github.com/microsoft/vscode/pull/${id}`, + ...(pillHover !== undefined ? { pillHover } : {}), + open: () => { }, + }); + const sections = observableValue('chatPills.hoverSections', [{ + title: 'Pull Requests', + entries: [entry('1')], + }]); + const viewItem = disposables.add(instantiationService.createInstance(ChatDropdownPillActionViewItem, action, {}, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + })); + const container = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + viewItem.render(container); + + const fallbackHover = getDropdownPillHoverContents.call(viewItem); + sections.set([{ title: 'Pull Requests', entries: [entry('1', richHover)] }], undefined); + const enrichedHover = getDropdownPillHoverContents.call(viewItem); + sections.set([{ title: 'Pull Requests', entries: [entry('1', richHover), entry('2')] }], undefined); + const summaryHover = getDropdownPillHoverContents.call(viewItem); + + assert.deepStrictEqual({ + fallbackHover, + usesRichHover: enrichedHover === richHover, + summaryHover, + }, { + fallbackHover: 'https://github.com/microsoft/vscode/pull/1', + usesRichHover: true, + summaryHover: 'Show 2 pull requests', + }); + + disposables.dispose(); + }); + + test('updates and closes an open section dropdown when entries change', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + let visible = false; + let onHide: ((didCancel?: boolean) => void) | undefined; + let shownLabels: readonly (string | undefined)[] = []; + let updatedLabels: readonly (string | undefined)[] = []; + let hideCount = 0; + const dropdownFocus = mainWindow.document.createElement('button'); + mainWindow.document.body.appendChild(dropdownFocus); + disposables.add(toDisposable(() => dropdownFocus.remove())); + const actionWidgetService = new class extends mock() { + override get isVisible(): boolean { return visible; } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + visible = true; + shownLabels = items.map(item => item.label); + onHide = delegate.onHide; + dropdownFocus.focus(); + } + override updateItems(items: readonly IActionListItem[]): void { + updatedLabels = items.map(item => item.label); + } + override hide(didCancel?: boolean): void { + hideCount++; + visible = false; + onHide?.(didCancel); + } + }(); + instantiationService.stub(IActionWidgetService, actionWidgetService); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequests', 'Pull Requests')); + const entry = (id: string): IChatPillEntry => ({ + id, + label: `Pull Request #${id}`, + open: () => { }, + }); + const sections = observableValue('chatPills.openSections', [{ + title: 'Pull Requests', + entries: [entry('1'), entry('2')], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + }, resourceLabels, instantiationService); + const siblingPill: IChatPill = { action: disposables.add(new Action('issues', 'Issues')) }; + const includeSibling = observableValue('chatPills.includeSibling', false); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { + pills: derived(reader => [pill.read(reader), ...(includeSibling.read(reader) ? [siblingPill] : [])]), + }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const button = widget.getPillElements()[0]; + + button.click(); + sections.set([{ + title: 'Pull Requests', + entries: [entry('2'), entry('3')], + }], undefined); + includeSibling.set(true, undefined); + const expandedAfterUpdate = button.getAttribute('aria-expanded'); + const dropdownFocusPreserved = mainWindow.document.activeElement === dropdownFocus; + sections.set([], undefined); + + assert.deepStrictEqual({ + shownLabels, + updatedLabels, + expandedAfterUpdate, + dropdownFocusPreserved, + hideCount, + expandedAfterEmpty: button.getAttribute('aria-expanded'), + }, { + shownLabels: ['Pull Requests', 'Pull Request #1', 'Pull Request #2'], + updatedLabels: ['Pull Requests', 'Pull Request #2', 'Pull Request #3'], + expandedAfterUpdate: 'true', + dropdownFocusPreserved: true, + hideCount: 1, + expandedAfterEmpty: null, + }); + + disposables.dispose(); + }); + + test('exposes the description of an inline section entry', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const resourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const action = disposables.add(new Action('pullRequest', 'Pull Request')); + const sections = constObservable([{ + title: 'Pull Requests', + entries: [{ + id: '1', + label: 'Pull Request #1', + ariaLabel: 'Open Pull Request #1', + ariaDescription: 'merged. https://github.com/microsoft/vscode/pull/1', + open: () => { }, + }], + }]); + const pill = createChatSectionPill(action, sections, { + widgetId: 'pullRequests', + icon: Codicon.gitPullRequest, + title: 'Pull Requests', + summaryLabel: count => `${count} Pull Requests`, + summaryAriaLabel: count => `Show ${count} pull requests`, + }, resourceLabels, instantiationService); + const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills: pill.map(value => [value]) }, undefined)); + mainWindow.document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + const button = widget.getPillElements()[0]; + + assert.deepStrictEqual({ + ariaLabel: button.getAttribute('aria-label'), + ariaDescription: button.getAttribute('aria-description'), + }, { + ariaLabel: 'Open Pull Request #1', + ariaDescription: 'merged. https://github.com/microsoft/vscode/pull/1', + }); + + disposables.dispose(); + }); +}); 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..65f5843ac2ef27 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,12 +67,14 @@ 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'; import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { ISessionChatPillVisibilityService, SessionChatPillVisibility } from '../../../../contrib/chat/common/sessionChatPills.js'; import { Target } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { ILanguageModelsService } from '../../../../contrib/chat/common/languageModels.js'; import { ChatAgentService, IChatAgent, IChatAgentNameService, IChatAgentService } from '../../../../contrib/chat/common/participants/chatAgents.js'; @@ -128,6 +131,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; } /** @@ -140,6 +145,7 @@ export interface IChatFixtureServicesOptions { export function registerChatFixtureServices(reg: ServiceRegistration, options: IChatFixtureServicesOptions = {}): void { registerWorkbenchServices(reg); reg.define(IMenuService, FixtureMenuService); + reg.define(ISessionChatPillVisibilityService, SessionChatPillVisibility); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.define(IListService, ListService); reg.defineInstance(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); @@ -163,7 +169,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 +339,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 +377,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 +392,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/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index fddb30d9c0be55..f376a0d6d0dca7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -35,7 +35,7 @@ import { SessionType } from '../../../../contrib/chat/common/chatSessionsService import { IEditSessionEntryDiff } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatResponseFileChangesService, IChatResponseFileEdit } from '../../../../contrib/chat/browser/chatResponseFileChangesService.js'; import { MockChatService } from '../../../../contrib/chat/test/common/chatService/mockChatService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, type ServiceRegistration } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import { ChatTurnStatusPillsSetting, isChatTurnStatusPillsEnabled } from '../../../../contrib/chat/browser/widget/chatTurnPills.js'; import { ITerminalChatService } from '../../../../contrib/terminal/browser/terminal.js'; @@ -110,6 +110,8 @@ export interface IChatWidgetFixtureOptions { */ readonly turnStatusPills?: ChatTurnStatusPillsSetting; readonly linkPresentationService?: ILinkPresentationService; + /** Registers fixture-specific services after the shared chat service graph. */ + readonly additionalServices?: (registration: ServiceRegistration) => void; readonly onRendered?: (handle: IChatWidgetFixtureHandle) => void; /** Selects the input-height consumer used by the ResizeObserver harness. */ readonly hostLayoutMode?: 'none' | 'listOnly' | 'stackedFull' | 'stackedTargeted'; @@ -227,6 +229,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options override async assess(): Promise { return new Promise(() => { }); } }()); } + options.additionalServices?.(reg); }, }); 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/componentFixtures/sessions/openIssue.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts deleted file mode 100644 index 5a36834beedadf..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts +++ /dev/null @@ -1,255 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { toAction } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubInfo, IGitHubIssueRef, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { computeIssueIcon, GitHubIssueState, GitHubIssueStateReason, IGitHubIssue } from '../../../../../sessions/contrib/github/common/types.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; -// eslint-disable-next-line local/code-import-patterns -import { createIssueHoverElement } from '../../../../../sessions/contrib/github/browser/issueHover.js'; -// eslint-disable-next-line local/code-import-patterns -import { GitHubReferenceList } from '../../../../../sessions/contrib/github/browser/githubReferenceList.js'; -// eslint-disable-next-line local/code-import-patterns -import { OpenIssueActionViewItem } from '../../../../../sessions/contrib/github/browser/issueActions.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import { createFixtureGitHubService } from './githubFixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; -import '../../../../../base/browser/ui/actionbar/actionbar.css'; -import '../../../../../base/browser/ui/hover/hoverWidget.css'; -import '../../../../../platform/hover/browser/hover.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockWorkspace(issues: readonly IGitHubIssueRef[]): ISessionWorkspace { - const root = URI.file('/home/user/projects/vscode'); - const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', issues }; - - const gitRepository: ISessionGitRepository = { - uri: root, - workTreeUri: undefined, - baseBranchName: 'main', - gitHubInfo: constObservable(gitHubInfo), - }; - - const folder: ISessionFolder = { - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository, - }; - - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(issues: readonly IGitHubIssueRef[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = observableValue('workspace', createMockWorkspace(issues)); - }(); -} - -function toIssueRef(issue: IGitHubIssue): IGitHubIssueRef { - return { - owner: 'microsoft', - repo: 'vscode', - number: issue.number, - uri: URI.parse(`https://github.com/microsoft/vscode/issues/${issue.number}`), - }; -} - -// ============================================================================ -// Render helpers -// ============================================================================ - -function renderIssuePill(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { - const { container, disposableStore } = ctx; - - const session = observableValue('session', createMockSession(issues.map(toIssueRef))); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - reg.defineInstance(IGitHubService, createFixtureGitHubService([], issues.map(issue => ({ owner: 'microsoft', repo: 'vscode', issue })))); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.openIssue', title: 'Open Issue' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(OpenIssueActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -function renderInHoverWidget(ctx: ComponentFixtureContext, content: HTMLElement, width: string): void { - const { container } = ctx; - - container.style.padding = '24px'; - container.style.width = width; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - - const hover = document.createElement('div'); - hover.classList.add('monaco-hover', 'workbench-hover'); - hover.style.position = 'static'; - hover.style.display = 'inline-block'; - - const row = document.createElement('div'); - row.classList.add('hover-row', 'markdown-hover'); - hover.appendChild(row); - - const contents = document.createElement('div'); - contents.classList.add('hover-contents', 'html-hover-contents'); - contents.appendChild(content); - row.appendChild(contents); - - container.appendChild(hover); -} - -function renderIssueHover(ctx: ComponentFixtureContext, issue: IGitHubIssue): void { - renderInHoverWidget(ctx, createIssueHoverElement({ - owner: 'microsoft', - repo: 'vscode', - number: issue.number, - repositoryHref: 'https://github.com/microsoft/vscode', - issue, - }), '580px'); -} - -function renderIssueList(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { - const list = ctx.disposableStore.add(new GitHubReferenceList(issues.map(issue => ({ - number: issue.number, - title: issue.title, - icon: computeIssueIcon(issue.state, issue.stateReason), - toolbarActions: [toAction({ - id: 'fixture.copyIssueLink', - label: 'Copy Issue Link', - class: ThemeIcon.asClassName(Codicon.copy), - run: () => { }, - })], - })), () => { })); - renderInHoverWidget(ctx, list.element, '480px'); -} - -// ============================================================================ -// Data -// ============================================================================ - -const openIssue: IGitHubIssue = { - number: 12345, - title: 'Terminal hangs when running a long build task in a detached worktree', - body: 'Steps to reproduce: open a session on a worktree, start `npm run watch`, then switch to another session. The terminal stops streaming output and the task never reports completion.', - state: GitHubIssueState.Open, - stateReason: undefined, - author: { login: 'hariharjeevan', avatarUrl: '' }, - createdAt: '2026-06-22T10:00:00Z', - updatedAt: '2026-06-24T12:00:00Z', - closedAt: undefined, -}; - -const shortDescriptionIssue: IGitHubIssue = { - ...openIssue, - body: 'The terminal stops streaming output.', -}; - -const longDescriptionIssue: IGitHubIssue = { - ...openIssue, - body: 'Steps to reproduce: open a session on a worktree, start a long-running build, and switch to another session while output is still streaming. Return to the original session and observe that the terminal no longer updates even though the task is still running. The task also never reports completion, so it is unclear whether the build finished, failed, or remains active in the background. This description is intentionally long enough to exceed three lines and verify that the issue hover clamps the text without revealing any part of a fourth line.', -}; - -const completedIssue: IGitHubIssue = { - number: 678, - title: 'Session header pill should show the referenced issue', - body: 'The session header already surfaces the pull request. It should do the same for the GitHub issues the user referenced in their messages.', - state: GitHubIssueState.Closed, - stateReason: GitHubIssueStateReason.Completed, - author: { login: 'alex', avatarUrl: '' }, - createdAt: '2026-06-05T10:00:00Z', - updatedAt: '2026-06-18T09:30:00Z', - closedAt: '2026-06-18T09:30:00Z', -}; - -const notPlannedIssue: IGitHubIssue = { - number: 42, - title: 'Add a setting to disable issue detection entirely, including for cross-repository references', - body: 'Not planned — the pill is already scoped to explicit references.', - state: GitHubIssueState.Closed, - stateReason: GitHubIssueStateReason.NotPlanned, - author: { login: 'alex', avatarUrl: '' }, - createdAt: '2026-05-30T10:00:00Z', - updatedAt: '2026-06-02T08:00:00Z', - closedAt: '2026-06-02T08:00:00Z', -}; - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - OpenIssue_Single: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [openIssue]), - }), - - OpenIssue_Closed: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [completedIssue]), - }), - - OpenIssue_Multiple: defineComponentFixture({ - render: (ctx) => renderIssuePill(ctx, [openIssue, completedIssue, notPlannedIssue]), - }), - - OpenIssue_Hover: defineComponentFixture({ - render: (ctx) => renderIssueHover(ctx, shortDescriptionIssue), - }), - - OpenIssue_Hover_LongDescription: defineComponentFixture({ - render: (ctx) => renderIssueHover(ctx, longDescriptionIssue), - }), - - OpenIssue_List: defineComponentFixture({ - render: (ctx) => renderIssueList(ctx, [openIssue, completedIssue, notPlannedIssue]), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts deleted file mode 100644 index c3b7fb88c08795..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/openPullRequest.fixture.ts +++ /dev/null @@ -1,271 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { toAction } from '../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; -import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubInfo, IGitHubPullRequestRef, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; -// eslint-disable-next-line local/code-import-patterns -import { computePullRequestIcon, IGitHubPullRequest, GitHubPullRequestState } from '../../../../../sessions/contrib/github/common/types.js'; -// eslint-disable-next-line local/code-import-patterns -import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; -// eslint-disable-next-line local/code-import-patterns -import { createPullRequestHoverElement } from '../../../../../sessions/contrib/github/browser/pullRequestHover.js'; -// eslint-disable-next-line local/code-import-patterns -import { OpenPullRequestActionViewItem } from '../../../../../sessions/contrib/github/browser/pullRequestActions.js'; -// eslint-disable-next-line local/code-import-patterns -import { IPullRequestIconCache } from '../../../../../sessions/contrib/github/browser/pullRequestIconCache.js'; -// eslint-disable-next-line local/code-import-patterns -import { GitHubReferenceList } from '../../../../../sessions/contrib/github/browser/githubReferenceList.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import { createFixtureGitHubService, createFixturePullRequestIconCache } from './githubFixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; -import '../../../../../base/browser/ui/actionbar/actionbar.css'; -import '../../../../../base/browser/ui/hover/hoverWidget.css'; -import '../../../../../platform/hover/browser/hover.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockWorkspace(pullRequest: IGitHubInfo['pullRequest'], pullRequests: readonly IGitHubPullRequestRef[]): ISessionWorkspace { - const root = URI.file('/home/user/projects/vscode'); - const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', pullRequest, pullRequests }; - - const gitRepository: ISessionGitRepository = { - uri: root, - workTreeUri: undefined, - baseBranchName: 'main', - gitHubInfo: constObservable(gitHubInfo), - }; - - const folder: ISessionFolder = { - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository, - }; - - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [folder], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(pullRequest: IGitHubInfo['pullRequest'], pullRequests: readonly IGitHubPullRequestRef[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = observableValue('workspace', createMockWorkspace(pullRequest, pullRequests)); - }(); -} - -// ============================================================================ -// Render helper -// ============================================================================ - -function renderPullRequestPill(ctx: ComponentFixtureContext, pullRequest: IGitHubInfo['pullRequest'], pullRequestDetails: readonly IGitHubPullRequest[]): void { - const { container, disposableStore } = ctx; - - const pullRequests = pullRequestDetails.map(details => ({ - owner: 'microsoft', - repo: 'vscode', - number: details.number, - uri: URI.parse(`https://github.com/microsoft/vscode/pull/${details.number}`), - })); - const session = observableValue('session', createMockSession(pullRequest, pullRequests)); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - reg.defineInstance(ISessionsProvidersService, new class extends mock() { - override readonly onDidChangeProviders = Event.None; - override getProvider() { return undefined; } - }()); - reg.defineInstance(IGitHubService, createFixtureGitHubService(pullRequestDetails.map(details => ({ owner: 'microsoft', repo: 'vscode', pullRequest: details })))); - reg.defineInstance(IPullRequestIconCache, createFixturePullRequestIconCache()); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.openPullRequest', title: 'Open Pull Request' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(OpenPullRequestActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -function renderPullRequestList(ctx: ComponentFixtureContext, pullRequests: readonly IGitHubPullRequest[]): void { - const list = ctx.disposableStore.add(new GitHubReferenceList(pullRequests.map(pullRequest => ({ - number: pullRequest.number, - title: pullRequest.title, - icon: computePullRequestIcon(pullRequest.isDraft ? 'draft' : pullRequest.state), - toolbarActions: [toAction({ - id: 'fixture.copyPullRequestLink', - label: 'Copy Pull Request Link', - class: ThemeIcon.asClassName(Codicon.copy), - run: () => { }, - })], - })), () => { })); - renderInHoverWidget(ctx, list.element, '480px'); -} - -function renderPullRequestHover(ctx: ComponentFixtureContext, pullRequest: IGitHubPullRequest): void { - renderInHoverWidget(ctx, createPullRequestHoverElement({ - owner: 'microsoft', - repo: 'vscode', - number: pullRequest.number, - repositoryHref: 'https://github.com/microsoft/vscode', - pullRequest, - }), '580px'); -} - -function renderInHoverWidget(ctx: ComponentFixtureContext, content: HTMLElement, width: string): void { - const { container } = ctx; - - container.style.padding = '24px'; - container.style.width = width; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - const hover = document.createElement('div'); - hover.classList.add('monaco-hover', 'workbench-hover'); - hover.style.position = 'static'; - hover.style.display = 'inline-block'; - - const row = document.createElement('div'); - row.classList.add('hover-row', 'markdown-hover'); - hover.appendChild(row); - - const contents = document.createElement('div'); - contents.classList.add('hover-contents', 'html-hover-contents'); - contents.appendChild(content); - row.appendChild(contents); - - container.appendChild(hover); -} - -const openPr: IGitHubInfo['pullRequest'] = { - number: 12345, - uri: URI.parse('https://github.com/microsoft/vscode/pull/12345'), - icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }, -}; - -const draftPr: IGitHubInfo['pullRequest'] = { - number: 678, - uri: URI.parse('https://github.com/microsoft/vscode/pull/678'), - icon: { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }, -}; - -const openPullRequestDetails: IGitHubPullRequest = { - number: openPr.number, - title: 'fix: suppress expected EPIPE error on graceful client disconnect', - body: 'Problem On every graceful client disconnect, the server logs an [error] Error: Unexpected EPIPE. This makes the expected disconnect path look like a real server failure and makes log scanning noisy for people investigating connection issues.', - state: GitHubPullRequestState.Open, - author: { login: 'hariharjeevan', avatarUrl: '' }, - headRef: 'fix-suppress-expected-epipe-error', - headSha: 'abc123', - baseRef: 'main', - isDraft: false, - createdAt: '2026-06-22T10:00:00Z', - updatedAt: '2026-06-22T12:00:00Z', - mergedAt: undefined, - mergeable: true, - mergeableState: 'clean', -}; - -const shortDescriptionPullRequest: IGitHubPullRequest = { - ...openPullRequestDetails, - body: 'Suppresses the expected EPIPE error on graceful disconnect.', -}; - -const longDescriptionPullRequest: IGitHubPullRequest = { - ...openPullRequestDetails, - body: 'Every graceful client disconnect currently logs an unexpected EPIPE error. This makes a routine shutdown look like a server failure and adds noise for anyone scanning logs while investigating connection issues. The change recognizes the expected disconnect path and avoids reporting it as an error while preserving diagnostics for unexpected failures. This description is intentionally long enough to exceed three lines and verify that the pull request hover clamps the text without revealing any part of a fourth line.', -}; - -const draftPullRequestDetails: IGitHubPullRequest = { - ...openPullRequestDetails, - number: draftPr.number, - title: 'draft: add session PR hover content', - body: 'Adds the first pass of the session header pull request hover with intentionally long branch names so truncation can be reviewed in component fixtures.', - state: GitHubPullRequestState.Open, - headRef: 'users/alex/very-long-session-pr-hover-fixture-branch-name', - isDraft: true, - createdAt: '2026-06-05T10:00:00Z', -}; - -const mergedPullRequestDetails: IGitHubPullRequest = { - ...openPullRequestDetails, - number: 42, - title: 'refactor: share the GitHub reference picker row', - state: GitHubPullRequestState.Merged, - headRef: 'refactor/github-reference-list', - createdAt: '2026-05-15T10:00:00Z', - mergedAt: '2026-05-18T09:30:00Z', -}; - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - OpenPullRequest_Open: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, openPr, [openPullRequestDetails]), - }), - - OpenPullRequest_Draft: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, draftPr, [draftPullRequestDetails]), - }), - - OpenPullRequest_Multiple: defineComponentFixture({ - render: (ctx) => renderPullRequestPill(ctx, openPr, [openPullRequestDetails, draftPullRequestDetails, mergedPullRequestDetails]), - }), - - OpenPullRequest_Hover: defineComponentFixture({ - render: (ctx) => renderPullRequestHover(ctx, shortDescriptionPullRequest), - }), - - OpenPullRequest_Hover_LongDescription: defineComponentFixture({ - render: (ctx) => renderPullRequestHover(ctx, longDescriptionPullRequest), - }), - - OpenPullRequest_List: defineComponentFixture({ - render: (ctx) => renderPullRequestList(ctx, [openPullRequestDetails, draftPullRequestDetails, mergedPullRequestDetails]), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index d0028c6a387714..efecf2def87d25 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -5,11 +5,14 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { Event } from '../../../../../base/common/event.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; +import { computePullRequestIcon } from '../../../../common/chatPullRequest.js'; import { chatPersistentContentVisibleClass } from '../../../../contrib/chat/browser/widget/chatWidget.js'; import { BrowserEditorInput } from '../../../../contrib/browserView/common/browserEditorInput.js'; import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../contrib/browserView/common/browserView.js'; @@ -22,16 +25,22 @@ import { ISessionChatPillsDebugData } from '../../../../../sessions/contrib/chat // eslint-disable-next-line local/code-import-patterns import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; // eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestModel.js'; +// eslint-disable-next-line local/code-import-patterns import { SessionInputBanners } from '../../../../../sessions/contrib/sessionInputBanners/browser/sessionInputBanners.js'; // eslint-disable-next-line local/code-import-patterns import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../sessions/common/agentHostSessionsProvider.js'; // eslint-disable-next-line local/code-import-patterns -import { ChatOriginKind, ISessionArtifact, ISessionChangeset, ISessionChatCustomization, ISessionTurnFileChange, ISessionWorkspace, IChat, ISessionCapabilities, ISessionFileChange, SessionArtifactKind, SessionCustomizationKind, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +import { IAgentWorkbenchLayoutService } from '../../../../../sessions/browser/workbench.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionChangesService } from '../../../../../sessions/contrib/changes/browser/sessionChangesService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ChatOriginKind, type IGitHubInfo, type IGitHubPullRequestRef, ISessionArtifact, ISessionChangeset, ISessionChatCustomization, ISessionTurnFileChange, ISessionWorkspace, IChat, ISessionCapabilities, ISessionFileChange, ISessionFolder, ISessionGitRepository, SessionArtifactKind, SessionCustomizationKind, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, type ServiceRegistration } from '../fixtureUtils.js'; import { registerChatFixtureServices } from '../chat/chatFixtureUtils.js'; import { IFixtureMessage, renderChatWidget } from '../chat/chatWidget.fixture.js'; @@ -62,6 +71,7 @@ interface ISessionSpec { readonly artifacts?: readonly ISessionArtifact[]; /** Customizations the chat used or read. */ readonly customizations?: readonly ISessionChatCustomization[]; + readonly pullRequests?: readonly IGitHubPullRequestRef[]; } /** A mock session + its viewed chat, as the toolbar consumes them. */ @@ -72,6 +82,33 @@ interface IMockSessionAndChat { } function createMockSession(spec: ISessionSpec): IMockSessionAndChat { + const workspaceRoot = URI.file('/repo'); + const gitHubInfo: IGitHubInfo | undefined = spec.pullRequests ? { + owner: 'microsoft', + repo: 'vscode', + pullRequests: spec.pullRequests, + } : undefined; + const gitRepository: ISessionGitRepository | undefined = gitHubInfo ? { + uri: workspaceRoot, + workTreeUri: undefined, + baseBranchName: 'main', + gitHubInfo: constObservable(gitHubInfo), + } : undefined; + const folder: ISessionFolder = { + root: workspaceRoot, + workingDirectory: workspaceRoot, + name: 'vscode', + description: undefined, + gitRepository, + }; + const workspace: ISessionWorkspace = { + uri: workspaceRoot, + label: 'vscode', + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; const chat = new class extends mock() { override readonly resource = URI.parse('chat:1'); override readonly title = constObservable('Main chat'); @@ -96,8 +133,8 @@ function createMockSession(spec: ISessionSpec): IMockSessionAndChat { override readonly isArchived = constObservable(false); override readonly isRead = constObservable(true); override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: false }); - override readonly workspace: IObservable = constObservable(undefined); - override readonly changes: IObservable = constObservable([]); + override readonly workspace: IObservable = constObservable(workspace); + override readonly changes: IObservable = constObservable(spec.turnChanges ?? []); override readonly changesets: IObservable = constObservable([]); override readonly artifacts: IObservable = constObservable(spec.artifacts ?? []); }(); @@ -125,11 +162,40 @@ function createBrowserViewService(inputs: readonly BrowserEditorInput[]): IBrows }(); } +function registerSessionChatPillFixtureServices(registration: ServiceRegistration, sessionMock: IMockSessionAndChat): void { + registration.defineInstance(ISessionsProvidersService, new class extends mock() { + override getProvider() { return undefined; } + }()); + registration.defineInstance(IBrowserViewWorkbenchService, createBrowserViewService(sessionMock.browsers)); + registration.defineInstance(IAgentWorkbenchLayoutService, new class extends mock() { + override revealEditorPartExplicitly(): void { } + }()); + registration.defineInstance(ISessionChangesService, new class extends mock() { + override async openChangesEditor(): Promise { return undefined; } + }()); + registration.defineInstance(IGitHubService, new class extends mock() { + override readonly activeSessionPullRequestObs = constObservable(undefined); + override readonly activeSessionPullRequestCIObs = constObservable(undefined); + override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); + override createPullRequestModelReference(owner: string, repo: string, prNumber: number) { + const model = new class extends mock() { + override readonly pullRequest = constObservable(undefined); + override readonly owner = owner; + override readonly repo = repo; + override readonly prNumber = prNumber; + override refresh(): Promise { return Promise.resolve(); } + override startPolling() { return Disposable.None; } + }(); + return { object: model, dispose: () => { } }; + } + }()); +} + // ============================================================================ // Render helpers // ============================================================================ -function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndChat, options?: { readonly debugData?: ISessionChatPillsDebugData; readonly enabled?: boolean; readonly width?: string }): void { +function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndChat, options?: { readonly compact?: boolean | 'auto'; readonly debugData?: ISessionChatPillsDebugData; readonly enabled?: boolean; readonly width?: string }): void { const { container, disposableStore } = ctx; const instantiationService = createEditorServices(disposableStore, { @@ -140,16 +206,8 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC // services) the artifact pill needs, on top of the base editor services // (which register a partial ISessionsService). registerChatFixtureServices(reg); - reg.defineInstance(ISessionsProvidersService, new class extends mock() { - override getProvider() { return undefined; } - }()); - reg.defineInstance(IBrowserViewWorkbenchService, createBrowserViewService(sessionMock.browsers)); + registerSessionChatPillFixtureServices(reg, sessionMock); if (options?.debugData) { - reg.defineInstance(IGitHubService, new class extends mock() { - override readonly activeSessionPullRequestObs = constObservable(undefined); - override readonly activeSessionPullRequestCIObs = constObservable(undefined); - override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); - }()); reg.defineInstance(IAgentFeedbackService, new class extends mock() { override readonly onDidChangeFeedback = Event.None; override readonly onDidChangeFeedbackVisibility = Event.None; @@ -165,7 +223,7 @@ function renderPills(ctx: ComponentFixtureContext, sessionMock: IMockSessionAndC (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, options?.enabled ?? true); - const pills = disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, options?.compact ?? false, undefined)); pills.setSession(sessionMock.session, sessionMock.chat); pills.setDebugData(options?.debugData); container.appendChild(pills.element); @@ -186,6 +244,7 @@ async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMock messages, height: options?.height, persistentContentHeight: SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, + additionalServices: registration => registerSessionChatPillFixtureServices(registration, mock), onRendered: scrollOffsetFromBottom ? handle => { const maximumScrollTop = Math.max(0, handle.listWidget.scrollHeight - handle.listWidget.renderHeight); @@ -198,7 +257,7 @@ async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMock instantiationService.invokeFunction(accessor => { (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); }); - const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); const updateChatPillsVisibility = (visible: boolean) => { inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); }; @@ -348,6 +407,34 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { })), }), + SessionChatPills_PullRequests: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + pullRequests: [{ + owner: 'microsoft', + repo: 'vscode', + number: 333, + uri: URI.parse('https://github.com/microsoft/vscode/pull/333'), + icon: computePullRequestIcon('open'), + state: 'open', + title: 'Keep stable pills', + }, { + owner: 'microsoft', + repo: 'vscode', + number: 222, + uri: URI.parse('https://github.com/microsoft/vscode/pull/222'), + icon: computePullRequestIcon('merged'), + state: 'merged', + }, { + owner: 'microsoft', + repo: 'vscode', + number: 111, + uri: URI.parse('https://github.com/microsoft/vscode/pull/111'), + icon: computePullRequestIcon('closed'), + state: 'closed', + }], + })), + }), + SessionChatPills_ArtifactsEveryType: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ artifacts: [ @@ -476,6 +563,44 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), }), + SessionChatPills_Compact: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [ + { id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }, + ], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: true, + width: '280px', + }), + }), + + SessionChatPills_ResponsiveWide: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: 'auto', + width: '600px', + }), + }), + + SessionChatPills_ResponsiveNarrow: defineComponentFixture({ + render: ctx => renderPills(ctx, createMockSession({ + status: SessionStatus.NeedsInput, + turnChanges: [editedFile('app.ts', 452, 85), editedFile('util.ts', 8, 2)], + artifacts: [{ id: 'a1', kind: SessionArtifactKind.File, label: 'Implementation plan', isArtifact: true, uri: URI.file('/repo/docs/plan.md') }], + browsers: [{ title: 'Project Preview' }, { title: 'Component Explorer' }], + }), { + compact: 'auto', + width: '180px', + }), + }), + // --- Gating ------------------------------------------------------------- SessionChatPills_NotAgentHost_Hidden: defineComponentFixture({ @@ -525,11 +650,12 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { messages: FULL_VIEW_MESSAGES, inputVisible: false, persistentContentHeight: SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, + additionalServices: registration => registerSessionChatPillFixtureServices(registration, mock), decorateInputPart: (inputPart, instantiationService) => { instantiationService.invokeFunction(accessor => { (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); }); - const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); pills.setSession(mock.session, mock.chat); inputPart.persistentContentContainerElement.appendChild(pills.element); }, diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts deleted file mode 100644 index 3936a82c1cebed..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/viewAllChanges.fixture.ts +++ /dev/null @@ -1,147 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { Codicon } from '../../../../../base/common/codicons.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; -// eslint-disable-next-line local/code-import-patterns -import { BRANCH_CHANGES_CHANGESET_ID, ISessionChangeset, ISessionFileChange, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; -// eslint-disable-next-line local/code-import-patterns -import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; -// eslint-disable-next-line local/code-import-patterns -import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; -// eslint-disable-next-line local/code-import-patterns -import { ViewAllChangesActionViewItem } from '../../../../../sessions/contrib/changes/browser/changesActions.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; - -// eslint-disable-next-line local/code-import-patterns -import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; - -// ============================================================================ -// Mock helpers -// ============================================================================ - -function createMockChange(insertions: number, deletions: number): ISessionFileChange { - return { - modifiedUri: URI.file(`/repo/file-${Math.random().toString(36).slice(2)}.ts`), - insertions, - deletions, - }; -} - -function createMockBranchChangeset(changes: readonly ISessionFileChange[]): ISessionChangeset { - return new class extends mock() { - override readonly id = BRANCH_CHANGES_CHANGESET_ID; - override readonly changes: IObservable = constObservable(changes); - override readonly isEnabled: IObservable = constObservable(true); - override readonly isDefault: IObservable = constObservable(true); - }(); -} - -function createMockWorkspace(): ISessionWorkspace { - const root = URI.file('/repo'); - return { - uri: root, - label: 'vscode', - icon: Codicon.folder, - folders: [{ - root, - workingDirectory: root, - name: 'vscode', - description: undefined, - gitRepository: { - uri: root, - workTreeUri: undefined, - branchName: 'feature/session-changes', - baseBranchName: 'main', - hasGitHubRemote: false, - gitHubInfo: constObservable(undefined), - }, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function createMockSession(changes: readonly ISessionFileChange[]): IActiveSession { - return new class extends mock() { - override readonly resource = URI.parse('session:1'); - override readonly workspace: IObservable = constObservable(createMockWorkspace()); - override readonly changes: IObservable = observableValue('changes', changes); - override readonly changesets: IObservable = constObservable([createMockBranchChangeset(changes)]); - }(); -} - -// ============================================================================ -// Render helper -// ============================================================================ - -function renderDiffStats(ctx: ComponentFixtureContext, changes: readonly ISessionFileChange[]): void { - const { container, disposableStore } = ctx; - - const session = observableValue('session', createMockSession(changes)); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(ISessionContext, new SessionContext(session)); - }, - }); - - // Build the real menu item action the session header contributes, then - // render the production action view item against it. - const action = instantiationService.createInstance( - MenuItemAction, - { id: 'workbench.agentSessions.action.viewChanges', title: 'View All Changes' }, - undefined, - undefined, - undefined, - undefined, - ); - - const item = disposableStore.add(instantiationService.createInstance(ViewAllChangesActionViewItem, action, {})); - - // Host the metadata action with its inline-label styling. - const toolbar = document.createElement('div'); - toolbar.classList.add('session-metadata-pill-toolbar'); - container.appendChild(toolbar); - item.render(toolbar); - - container.style.padding = '8px'; - container.style.backgroundColor = 'var(--vscode-sideBar-background)'; -} - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - - ViewAllChanges_SingleFile: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(12, 3)]), - }), - - ViewAllChanges_MultipleFiles: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [ - createMockChange(42, 7), - createMockChange(118, 64), - createMockChange(5, 0), - ]), - }), - - ViewAllChanges_OnlyInsertions: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(256, 0)]), - }), - - ViewAllChanges_OnlyDeletions: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, [createMockChange(0, 89)]), - }), - - ViewAllChanges_NoChanges: defineComponentFixture({ - render: (ctx) => renderDiffStats(ctx, []), - }), -}); 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/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts index 3618a7ffd12356..5e79afadc32ce3 100644 --- a/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SubmenuAction } from '../../../../../base/common/actions.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { DEFAULT_EDITOR_ASSOCIATION, IEditorInputWithDiffResources } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { getAvailableEditorTypes } from '../../../../browser/parts/editor/editorTypePicker.js'; -import { IEditorResolverService, IEditorResolverServiceGetAllEditorsOptions, IEditorResolverServiceGetEditorsOptions, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { createEditorTypeActions, getAvailableEditorTypes } from '../../../../browser/parts/editor/editorTypePicker.js'; +import { EditorMatchRuleSource, EditorMatches, IEditorResolverService, IEditorResolverServiceGetEditorMatchesOptions, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; suite('Editor Type Picker', () => { @@ -28,6 +31,18 @@ suite('Editor Type Picker', () => { }; } + function editorMatches(editors: readonly RegisteredEditorInfo[], defaultEditorId = DEFAULT_EDITOR_ASSOCIATION.id): EditorMatches { + const matches = editors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration as const, + globPattern: '*.test', + associationPattern: '*.test' + })); + const defaultRuleIndex = matches.findIndex(match => match.editor.id === defaultEditorId); + return new EditorMatches(matches, defaultRuleIndex, defaultRuleIndex, false); + } + test('inline custom diff editor is classified as a diff editor', () => { const original = URI.file('/original/test.md'); const modified = URI.file('/modified/test.md'); @@ -35,6 +50,7 @@ suite('Editor Type Picker', () => { editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), editor('test.markdownEditor', RegisteredEditorPriority.option, RegisteredEditorPriority.explicit), ]; + const matches = editorMatches(registeredEditors); const input = disposables.add(new class extends EditorInput implements IEditorInputWithDiffResources { override get typeId(): string { return 'test.inlineCustomDiffEditor'; } override get editorId(): string { return 'test.markdownEditor'; } @@ -43,14 +59,12 @@ suite('Editor Type Picker', () => { override getName(): string { return 'test'; } }()); const requestedResources: URI[] = []; - const requestedOptions: (IEditorResolverServiceGetEditorsOptions | undefined)[] = []; + const requestedOptions: (IEditorResolverServiceGetEditorMatchesOptions | undefined)[] = []; const editorResolverService = new class extends mock() { - override getEditors(resourceOrOptions?: URI | IEditorResolverServiceGetAllEditorsOptions, options?: IEditorResolverServiceGetEditorsOptions): RegisteredEditorInfo[] { - if (URI.isUri(resourceOrOptions)) { - requestedResources.push(resourceOrOptions); - } + override getEditorMatches(resource: URI, options?: IEditorResolverServiceGetEditorMatchesOptions): EditorMatches { + requestedResources.push(resource); requestedOptions.push(options); - return registeredEditors; + return matches; } }; @@ -59,8 +73,6 @@ suite('Editor Type Picker', () => { assert.deepStrictEqual({ requestedResources, requestedOptions, result }, { requestedResources: [modified], requestedOptions: [{ - excludeUnconfiguredUniversalOptionalEditors: true, - currentEditorId: 'test.markdownEditor', isDiffEditor: true, }], result: { @@ -69,6 +81,7 @@ suite('Editor Type Picker', () => { originalResource: original, modifiedResource: modified, currentId: 'test.markdownEditor', + editorMatches: matches, editors: registeredEditors, } }); @@ -80,6 +93,7 @@ suite('Editor Type Picker', () => { editor('test.markdownEditor', RegisteredEditorPriority.option), editor('test.markdownPreview', RegisteredEditorPriority.option), ]; + const matches = editorMatches(registeredEditors); class TestEditorInput extends EditorInput { constructor(private readonly id: string) { super(); @@ -91,8 +105,8 @@ suite('Editor Type Picker', () => { override getName(): string { return 'test'; } } const editorResolverService = new class extends mock() { - override getEditors(): RegisteredEditorInfo[] { - return registeredEditors; + override getEditorMatches(): EditorMatches { + return matches; } }; const markdownEditor = disposables.add(new TestEditorInput('test.markdownEditor')); @@ -108,4 +122,124 @@ suite('Editor Type Picker', () => { }); }); -}); \ No newline at end of file + test('exclusive matches suppress the editor type picker', () => { + const resource = URI.file('/workspace/test.hex'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.hexEditor', RegisteredEditorPriority.exclusive), + ]; + const matches = editorMatches(registeredEditors, 'test.hexEditor'); + const input = disposables.add(new class extends EditorInput { + override get typeId(): string { return 'test.hexEditorInput'; } + override get editorId(): string { return 'test.hexEditor'; } + override get resource(): URI { return resource; } + override getName(): string { return 'test'; } + }()); + const editorResolverService = new class extends mock() { + override getEditorMatches(): EditorMatches { + return matches; + } + }; + + assert.strictEqual(getAvailableEditorTypes(input, editorResolverService), undefined); + }); + + test('unconfigured universal optional matches only make the picker visible while active', () => { + const resource = URI.file('/workspace/test.md'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.universalPreview', RegisteredEditorPriority.option), + ]; + const matches = new EditorMatches(registeredEditors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: '*', + associationPattern: '*.md' + })), 0, 0, false); + class TestEditorInput extends EditorInput { + constructor(private readonly id: string) { + super(); + } + + override get typeId(): string { return 'test.editorInput'; } + override get editorId(): string { return this.id; } + override get resource(): URI { return resource; } + override getName(): string { return 'test'; } + } + const editorResolverService = new class extends mock() { + override getEditorMatches(): EditorMatches { + return matches; + } + }; + const textEditor = disposables.add(new TestEditorInput(DEFAULT_EDITOR_ASSOCIATION.id)); + const universalPreview = disposables.add(new TestEditorInput('test.universalPreview')); + + assert.deepStrictEqual({ + inactive: getAvailableEditorTypes(textEditor, editorResolverService), + activeEditorIds: getAvailableEditorTypes(universalPreview, editorResolverService)?.editors.map(editor => editor.id) + }, { + inactive: undefined, + activeEditorIds: [DEFAULT_EDITOR_ASSOCIATION.id, 'test.universalPreview'] + }); + }); + + test('set default uses the effective default scope instead of the active editor type', async () => { + const resource = URI.file('/workspace/example.component.html'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.componentEditor', RegisteredEditorPriority.default), + ]; + const updates: Array<{ resource: URI; editorId: string; forDiffEditor: boolean | undefined }> = []; + const commands: Array<{ id: string; args: unknown[] }> = []; + const matches = new EditorMatches(registeredEditors.map(editor => ({ + editor, + priority: editor.priority.editor, + source: EditorMatchRuleSource.EditorRegistration, + globPattern: editor.id === 'test.componentEditor' ? '*.component.html' : '*', + associationPattern: editor.id === 'test.componentEditor' ? '*.component.html' : '*.html' + })), 1, 1, false); + const editorResolverService = new class extends mock() { + override setDefaultEditor(resource: URI, editorId: string, forDiffEditor?: boolean): void { + updates.push({ resource, editorId, forDiffEditor }); + } + }; + const commandService = new class extends mock() { + override async executeCommand(id: string, ...args: unknown[]): Promise { + commands.push({ id, args }); + return undefined; + } + }; + const actions = createEditorTypeActions({ + resource, + isDiffEditor: false, + currentId: DEFAULT_EDITOR_ASSOCIATION.id, + editorMatches: matches, + editors: registeredEditors + }, editorResolverService, commandService, new class extends mock() { }); + const setDefaultSubmenu = actions.find((action): action is SubmenuAction => action instanceof SubmenuAction); + assert.ok(setDefaultSubmenu); + + await setDefaultSubmenu.actions[0].run(); + + assert.deepStrictEqual({ + label: setDefaultSubmenu.label, + checked: setDefaultSubmenu.actions.map(action => action.checked), + updates, + commands + }, { + label: 'Set Default for \'*.component.html\'', + checked: [false, true], + updates: [{ + resource, + editorId: DEFAULT_EDITOR_ASSOCIATION.id, + forDiffEditor: false + }], + commands: [{ + id: 'reopenActiveEditorWith', + args: [DEFAULT_EDITOR_ASSOCIATION.id] + }] + }); + }); + +}); 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)