diff --git a/.github/instructions/design-tokens.instructions.md b/.github/instructions/design-tokens.instructions.md index 22281bbc890963..b38a5f5ff4d406 100644 --- a/.github/instructions/design-tokens.instructions.md +++ b/.github/instructions/design-tokens.instructions.md @@ -76,16 +76,32 @@ of a shorthand is checked independently (`0 5px → 0 6px`). `auto`, `%`, ## Font size — `font-size` -Generic UI chrome (fixed px): +Generic UI ramp — pair a **size** token with a **weight** token; "Strong" +reuses the matching size token + `fontWeight.semiBold`, **never** a separate +"strong" size: -| px | Variable | -|----|----------| -| 13 | `--vscode-bodyFontSize` (base) | -| 12 | `--vscode-bodyFontSize-small` | -| 11 | `--vscode-bodyFontSize-xSmall` | - -Agents window ramp (`src/vs/sessions/**`) — pair size with a weight token, -**never** add a separate "strong" size: +| px | Size var | Weight | +|----|----------|--------| +| 26 | `--vscode-fontSize-heading1` | semiBold | +| 18 | `--vscode-fontSize-heading2` | semiBold | +| 13 | `--vscode-fontSize-heading3` | semiBold | +| 13 | `--vscode-fontSize-body1` | regular | +| 11 | `--vscode-fontSize-body2` | regular | +| 12 | `--vscode-fontSize-label1` | regular | +| 11 | `--vscode-fontSize-label2` | regular | +| 10 | `--vscode-fontSize-label3` | regular | + +**Deprecated** — the legacy `--vscode-bodyFontSize*` tokens are deprecated. Use +the generic ramp above instead: + +| Deprecated | px | Use instead | +|------------|----|-------------| +| `--vscode-bodyFontSize` | 13 | `--vscode-fontSize-body1` | +| `--vscode-bodyFontSize-small` | 12 | `--vscode-fontSize-label1` | +| `--vscode-bodyFontSize-xSmall` | 11 | `--vscode-fontSize-body2` | + +Agents window ramp (`src/vs/sessions/**`) — identical values, `agents-`-prefixed +(pair size with a weight token, **never** add a separate "strong" size): | px | Size var | Weight | |----|----------|--------| @@ -105,26 +121,26 @@ is no medium (500). "Strong" = same size token + `semiBold`. See ## Font weight — `font-weight` -The agents window uses a **two-weight ramp** — there are no other weights. -Pair every text style with one of these: +Both the generic and agents ramps use a **two-weight ramp** — there are no other +weights. Pair every text style with one of these: -| weight | Variable | Use | -|--------|----------|-----| -| 400 | `--vscode-agents-fontWeight-regular` | body, labels, metadata | -| 600 | `--vscode-agents-fontWeight-semiBold` | headings, "strong" emphasis | +| weight | Generic var | Agents var | Use | +|--------|-------------|------------|-----| +| 400 | `--vscode-fontWeight-regular` | `--vscode-agents-fontWeight-regular` | body, labels, metadata | +| 600 | `--vscode-fontWeight-semiBold` | `--vscode-agents-fontWeight-semiBold` | headings, "strong" emphasis | - **No medium (500).** `font-weight: 500` is **off the ramp** — snap it to `semiBold` (600). The same goes for `700`/`bold` and any other numeric weight: round to the nearer of 400/600. - **"Strong" is not a separate size.** A "Body 1 Strong" / "Label 2 Strong" - style reuses the matching `--vscode-agents-fontSize-*` token paired with - `semiBold`. Never introduce a separate strong *size* token. + style reuses the matching `--vscode-fontSize-*` (or `--vscode-agents-fontSize-*`) + token paired with `semiBold`. Never introduce a separate strong *size* token. - `normal` ≡ 400 → `regular`. **Leave untouched:** `inherit`, `lighter`, `bolder`, and any `var()`/`calc()` expression. Preserve `!important`. ```css /* avoid */ font-weight: 500; /* not on the 400/600 ramp */ -/* prefer */ font-weight: var(--vscode-agents-fontWeight-semiBold); +/* prefer */ font-weight: var(--vscode-fontWeight-semiBold); ``` ## Codicon size — icon `font-size` diff --git a/.github/skills/ux-css-layout/SKILL.md b/.github/skills/ux-css-layout/SKILL.md index 537b9d74ccdafd..0e5776524535e3 100644 --- a/.github/skills/ux-css-layout/SKILL.md +++ b/.github/skills/ux-css-layout/SKILL.md @@ -297,11 +297,28 @@ scale value, **ties round up** (`5px → 6px`, `3px → 4px`, `1px → 2px`, ### Font size & weight -Generic UI chrome (fixed px): `13 → --vscode-bodyFontSize` (base), -`12 → --vscode-bodyFontSize-small`, `11 → --vscode-bodyFontSize-xSmall`. +Generic UI ramp — pair a **size** token with a **weight** token (mirrors the +agents ramp; "Strong" = matching size token + `semiBold`, never a separate size): -Agents window (`src/vs/sessions/**`) ramp — pair a **size** token with a -**weight** token: +| px | Size var | Weight | +|----|----------|--------| +| 26 | `--vscode-fontSize-heading1` | semiBold | +| 18 | `--vscode-fontSize-heading2` | semiBold | +| 13 | `--vscode-fontSize-heading3` | semiBold | +| 13 | `--vscode-fontSize-body1` | regular | +| 11 | `--vscode-fontSize-body2` | regular | +| 12 | `--vscode-fontSize-label1` | regular | +| 11 | `--vscode-fontSize-label2` | regular | +| 10 | `--vscode-fontSize-label3` | regular | + +Generic weights: `--vscode-fontWeight-regular` (400), +`--vscode-fontWeight-semiBold` (600). + +**Deprecated** — `--vscode-bodyFontSize` (13) → `--vscode-fontSize-body1`, +`--vscode-bodyFontSize-small` (12) → `--vscode-fontSize-label1`, +`--vscode-bodyFontSize-xSmall` (11) → `--vscode-fontSize-body2`. + +Agents window (`src/vs/sessions/**`) ramp — identical values, `agents-`-prefixed: | px | Size var | Weight | |----|----------|--------| @@ -314,14 +331,15 @@ Agents window (`src/vs/sessions/**`) ramp — pair a **size** token with a | 11 | `--vscode-agents-fontSize-label2` | regular | | 10 | `--vscode-agents-fontSize-label3` | regular | -The agents weight ramp is **two weights only**: -`--vscode-agents-fontWeight-regular` (400) and -`--vscode-agents-fontWeight-semiBold` (600). +Both weight ramps are **two weights only**: `regular` (400) and +`semiBold` (600) — generic `--vscode-fontWeight-*`, agents +`--vscode-agents-fontWeight-*`. - **No medium (500).** `font-weight: 500` is off the ramp — snap to `semiBold`. Likewise `700`/`bold` → round to the nearer of 400/600. - **"Strong" is not a separate size.** "Body 1 Strong" = the matching - `--vscode-agents-fontSize-*` size token + `semiBold`. Never add a strong *size*. + `--vscode-fontSize-*` (or `--vscode-agents-fontSize-*`) size token + `semiBold`. + Never add a strong *size*. - `normal` ≡ 400 → `regular`. Leave `inherit`, `lighter`, `bolder`, `var()`/`calc()` untouched. diff --git a/.github/skills/ux-theming/SKILL.md b/.github/skills/ux-theming/SKILL.md index f674aafb13da6c..274f478966b4a4 100644 --- a/.github/skills/ux-theming/SKILL.md +++ b/.github/skills/ux-theming/SKILL.md @@ -134,8 +134,8 @@ Reviewers will always flag hardcoded colors, shadows, sizes that should use them | `border: 1px solid …` (width) | `var(--vscode-strokeThickness)` for the 1px width | | `border-radius: 6px` | `var(--vscode-cornerRadius-medium)` (radius ramp) | | `padding: 8px 12px` (off-scale) | spacing ramp (`--vscode-spacing-size*`) | -| `font-size: 14px` (arbitrary) | size ramp (`--vscode-bodyFontSize`, agents `--vscode-agents-fontSize-*`) | -| `font-weight: 500` | agents `--vscode-agents-fontWeight-semiBold` (no 500) | +| `font-size: 14px` (arbitrary) | size ramp (`--vscode-fontSize-*`, agents `--vscode-agents-fontSize-*`) | +| `font-weight: 500` | `--vscode-fontWeight-semiBold` (agents `--vscode-agents-fontWeight-semiBold`; no 500) | | codicon `font-size: 14px` | `--vscode-codiconFontSize` (16) / `-compact` (12) | **Rule:** If a value relates to color, shadow, or border — it must come from a CSS variable or registered color token. The only exception is `0` (zero) values and purely structural measurements like `100%`. diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 930248b9da5e26..4ef43f8ce6c5b8 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1113,6 +1113,16 @@ "--vscode-cornerRadius-small", "--vscode-cornerRadius-xLarge", "--vscode-cornerRadius-xSmall", + "--vscode-fontSize-body1", + "--vscode-fontSize-body2", + "--vscode-fontSize-heading1", + "--vscode-fontSize-heading2", + "--vscode-fontSize-heading3", + "--vscode-fontSize-label1", + "--vscode-fontSize-label2", + "--vscode-fontSize-label3", + "--vscode-fontWeight-regular", + "--vscode-fontWeight-semiBold", "--vscode-keyboard-height", "--vscode-spacing-sizeNone", "--vscode-spacing-size20", diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts index e6f8dc5ed3da2b..f948ac03f1828e 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts @@ -1141,7 +1141,20 @@ export class TelemetrySender implements IDisposable { "promptLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the prompt", "isMeasurement": true }, "promptCharCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of characters in the prompt", "isMeasurement": true }, "nDiffsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of diffs included in the prompt", "isMeasurement": true }, - "diffTokensInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of tokens consumed by diffs in the prompt", "isMeasurement": true }, + "promptSectionTokensSystemPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the system prompt section", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensRecentlyViewed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the recently-viewed code snippets section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensRecentlyViewedFiles": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the recently-viewed/edited files (from xtab history) subsection of the recently-viewed section; does not sum exactly to promptSectionTokensRecentlyViewed because of the section tags and inter-snippet newline glue", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensLanguageContext": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the language-context snippets subsection of the recently-viewed section (Snippet-kind items from the language server; distinct from the related-information traits section)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensNeighborFiles": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the neighbor (similar) files subsection of the recently-viewed section", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensCurrentFile": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the current-file section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensLintErrors": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the lint-errors section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensEditHistory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the rendered edit-diff-history section of the user prompt (including its section tags)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensAreaAroundCodeToEdit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the area-around-code-to-edit section of the user prompt (0 when absent for the strategy)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensCursorLocation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the cursor-location section of the user prompt (0 when absent for the strategy)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensRelatedInformation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the related-information (language traits) section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensPostScript": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the post-script section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "userPromptOverheadTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of user-prompt formatting glue not attributed to a section (joining newlines, backtick fences, trim); userPromptTotalTokens minus the sum of the section counts", "isMeasurement": true, "owner": "ulugbekna" }, + "userPromptTotalTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the whole trimmed user prompt (summary total, not a section)", "isMeasurement": true, "owner": "ulugbekna" }, "nNeighborSnippetsComputed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of neighbor (similar files) snippets computed before budget filtering", "isMeasurement": true }, "nNeighborSnippetsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of neighbor (similar files) snippets actually included in the prompt", "isMeasurement": true }, "neighborSnippetIndicesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "JSON-encoded array of original input indices (ascending) of neighbor snippets included in the prompt" }, @@ -1268,7 +1281,20 @@ export class TelemetrySender implements IDisposable { nextCursorLineDistance: telemetry.nextCursorPrediction?.nextCursorLineDistance, xtabUserHappinessScore, nDiffsInPrompt: telemetry.nDiffsInPrompt, - diffTokensInPrompt: telemetry.diffTokensInPrompt, + promptSectionTokensSystemPrompt: telemetry.promptSectionTokens?.systemPrompt, + promptSectionTokensRecentlyViewed: telemetry.promptSectionTokens?.recentlyViewed, + promptSubsectionTokensRecentlyViewedFiles: telemetry.promptSectionTokens?.recentlyViewedSubsections.recentlyViewedFiles, + promptSubsectionTokensLanguageContext: telemetry.promptSectionTokens?.recentlyViewedSubsections.languageContext, + promptSubsectionTokensNeighborFiles: telemetry.promptSectionTokens?.recentlyViewedSubsections.neighborFiles, + promptSectionTokensCurrentFile: telemetry.promptSectionTokens?.currentFile, + promptSectionTokensLintErrors: telemetry.promptSectionTokens?.lintErrors, + promptSectionTokensEditHistory: telemetry.promptSectionTokens?.editHistory, + promptSectionTokensAreaAroundCodeToEdit: telemetry.promptSectionTokens?.areaAroundCodeToEdit, + promptSectionTokensCursorLocation: telemetry.promptSectionTokens?.cursorLocation, + promptSectionTokensRelatedInformation: telemetry.promptSectionTokens?.relatedInformation, + promptSectionTokensPostScript: telemetry.promptSectionTokens?.postScript, + userPromptOverheadTokens: telemetry.promptSectionTokens?.overhead, + userPromptTotalTokens: telemetry.promptSectionTokens?.userPromptTotal, nNeighborSnippetsComputed: telemetry.nNeighborSnippetsComputed, nNeighborSnippetsInPrompt: telemetry.nNeighborSnippetsInPrompt, } diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts index 2bbd46cbef1ebc..54f7a091a4419d 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts @@ -471,7 +471,7 @@ describe('TelemetrySender', () => { cursorJumpPrompt: undefined, cursorJumpResponse: undefined, nDiffsInPrompt: undefined, - diffTokensInPrompt: undefined, + promptSectionTokens: undefined, nNeighborSnippetsComputed: undefined, nNeighborSnippetsInPrompt: undefined, neighborSnippetIndicesInPrompt: undefined, diff --git a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx index a500c3a11be060..06759ad9756ecc 100644 --- a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx @@ -37,6 +37,7 @@ interface IFindTextInFilesToolParams { isRegexp?: boolean; includePattern?: string; maxResults?: number; + defaultMaxResults?: number; /** Whether to include files that would normally be ignored according to .gitignore, other ignore files and `files.exclude` and `search.exclude` settings. */ includeIgnoredFiles?: boolean; } @@ -100,7 +101,7 @@ export class FindTextInFilesTool implements ICopilotTool maxResultsCap; - const maxResults = Math.min(options.input.maxResults ?? defaultMaxResults, maxResultsCap); + const maxResults = Math.min(options.input.maxResults ?? options.input.defaultMaxResults ?? defaultMaxResults, maxResultsCap); const isRegExp = options.input.isRegexp ?? true; const queryIsValidRegex = this.isValidRegex(options.input.query); const includeIgnoredFiles = options.input.includeIgnoredFiles ?? false; @@ -448,7 +449,7 @@ Then if you want to include those files you can call the tool again by setting " } return { - maxResults: mode === CopilotToolMode.FullContext ? this.getMaxResultsCap() : this.getDefaultMaxResults(), + defaultMaxResults: mode === CopilotToolMode.FullContext ? this.getMaxResultsCap() : this.getDefaultMaxResults(), ...input, includePattern, }; diff --git a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md index 0394f2e899ee54..fb75ab6c319aba 100644 --- a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md +++ b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md @@ -158,7 +158,7 @@ current-file clip: neighborSnippetsResult, // unchanged telemetry payload editDiffHistory, nDiffsInPrompt, - diffTokensInPrompt, + subsections, // recentlyViewed/langCtx/neighbor strings, for per-subsection token reporting finalSurplus, // end-of-loop surplus, reused by the current-file clip } ``` diff --git a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts index aaa17174a0f0c7..c9ef4d842a8884 100644 --- a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts +++ b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts @@ -5,6 +5,7 @@ import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId'; import { LanguageContextResponse } from '../../../platform/inlineEdits/common/dataTypes/languageContext'; +import { PromptSectionTokenCounts } from '../../../platform/inlineEdits/common/dataTypes/promptSectionTokens'; import * as xtabPromptOptions from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { AggressivenessLevel, CurrentFileOptions, GlobalBudgetOptions, PromptingStrategy, PromptOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { StatelessNextEditDocument } from '../../../platform/inlineEdits/common/statelessNextEditProvider'; @@ -18,7 +19,7 @@ import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRa import { getEditDiffHistory } from './diffHistoryForPrompt'; import { LintErrors } from './lintErrors'; import { countTokensForLines, toUniquePath } from './promptCraftingUtils'; -import { appendLanguageContextSnippets, appendNeighborFileSnippets, AppendNeighborFileSnippetsResult, buildCodeSnippetsUsingPagedClipping, getRecentCodeSnippets, prepareRecentCodeSnippets } from './recentFilesForPrompt'; +import { appendLanguageContextSnippets, appendNeighborFileSnippets, AppendNeighborFileSnippetsResult, buildCodeSnippetsUsingPagedClipping, getRecentCodeSnippets, prepareRecentCodeSnippets, RecentlyViewedSubsectionSnippets } from './recentFilesForPrompt'; import { INeighborFileSnippet } from './similarFilesContextService'; import { PromptTags } from './tags'; import { CurrentDocument } from './xtabCurrentDocument'; @@ -54,8 +55,13 @@ export class PromptPieces { export interface UserPromptResult { readonly prompt: string; readonly nDiffsInPrompt: number; - readonly diffTokensInPrompt: number; readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; + /** + * Approximate per-section token counts for the user prompt. `systemPrompt` is + * left at 0 here (the system message is not part of the user prompt) and is + * filled in by the provider. + */ + readonly sectionTokens: PromptSectionTokenCounts; } export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { @@ -64,11 +70,11 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { const currentFileContent = taggedCurrentDocLines.join('\n'); let recentlyViewedCodeSnippets: string; + let recentlyViewedSubsections: RecentlyViewedSubsectionSnippets; let docsInPrompt: Set; let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; let editDiffHistory: string; let nDiffsInPrompt: number; - let diffTokensInPrompt: number; if (opts.globalBudget !== undefined) { // Reuse a cascade the caller already ran (the provider runs it first so it can @@ -76,14 +82,15 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { // that set a global budget without precomputing (e.g. tests). const cascade = precomputedCascade ?? runGlobalBudgetCascade(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets, opts.globalBudget); recentlyViewedCodeSnippets = cascade.codeSnippets; + recentlyViewedSubsections = cascade.subsections; docsInPrompt = cascade.documents; neighborSnippetsResult = cascade.neighborSnippetsResult; editDiffHistory = cascade.editDiffHistory; nDiffsInPrompt = cascade.nDiffsInPrompt; - diffTokensInPrompt = cascade.diffTokensInPrompt; } else { const r = getRecentCodeSnippets(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets); recentlyViewedCodeSnippets = r.codeSnippets; + recentlyViewedSubsections = r.subsections; docsInPrompt = r.documents; neighborSnippetsResult = r.neighborSnippetsResult; @@ -92,7 +99,6 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { const diff = getEditDiffHistory(activeDoc, xtabHistory, docsInPrompt, computeTokens, opts.diffHistory); editDiffHistory = diff.promptPiece; nDiffsInPrompt = diff.nDiffs; - diffTokensInPrompt = diff.totalTokens; } const relatedInformation = getRelatedInformation(langCtx); @@ -103,20 +109,20 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { const lintsWithNewLinePadding = opts.lintOptions ? `\n${lintErrors.getFormattedLintErrors(opts.lintOptions)}\n` : ''; - const basePrompt = `${PromptTags.RECENT_FILES.start} -${recentlyViewedCodeSnippets} -${PromptTags.RECENT_FILES.end} + // Build each rendered section string exactly once and assemble `basePrompt` + // from those same locals, so the per-section token counts below are derived + // from the identical text that goes into the prompt (single source of truth, + // no rebuild and no drift). `areaAroundCodeToEdit`/`cursorLocation` are + // mutually exclusive and appended per-strategy below. + const recentFilesSection = `${PromptTags.RECENT_FILES.start}\n${recentlyViewedCodeSnippets}\n${PromptTags.RECENT_FILES.end}`; + const currentFileSection = `${PromptTags.CURRENT_FILE.start}\ncurrent_file_path: ${currentFilePath}\n${currentFileContent}\n${PromptTags.CURRENT_FILE.end}`; + const editHistorySection = `${PromptTags.EDIT_HISTORY.start}\n${editDiffHistory}\n${PromptTags.EDIT_HISTORY.end}`; -${PromptTags.CURRENT_FILE.start} -current_file_path: ${currentFilePath} -${currentFileContent} -${PromptTags.CURRENT_FILE.end} -${lintsWithNewLinePadding} -${PromptTags.EDIT_HISTORY.start} -${editDiffHistory} -${PromptTags.EDIT_HISTORY.end}`; + const basePrompt = `${recentFilesSection}\n\n${currentFileSection}\n${lintsWithNewLinePadding}\n${editHistorySection}`; let mainPrompt: string; + let cursorLocationSection = ''; + let areaAroundSection = ''; switch (opts.promptingStrategy) { case PromptingStrategy.PatchBased01: mainPrompt = basePrompt; @@ -147,10 +153,12 @@ ${PromptTags.EDIT_HISTORY.end}`; `${lineNumbering}${cursorLineWithTag}`, PromptTags.CURSOR_LOCATION.end ].join('\n'); + cursorLocationSection = lineWithCursorSnippet; mainPrompt = basePrompt + `\n\n${lineWithCursorSnippet}`; break; } default: + areaAroundSection = areaAroundCodeToEdit; mainPrompt = basePrompt + `\n\n${areaAroundCodeToEdit}`; break; } @@ -168,7 +176,36 @@ ${PromptTags.EDIT_HISTORY.end}`; const trimmedPrompt = prompt.trim(); - return { prompt: trimmedPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult }; + const recentlyViewedTokens = computeTokens(recentFilesSection); + const currentFileTokens = computeTokens(currentFileSection); + const lintErrorsTokens = computeTokens(lintsWithNewLinePadding); + const editHistoryTokens = computeTokens(editHistorySection); + const areaAroundCodeToEditTokens = computeTokens(areaAroundSection); + const cursorLocationTokens = computeTokens(cursorLocationSection); + const relatedInformationTokens = computeTokens(relatedInformation); + const postScriptTokens = computeTokens(postScript); + const userPromptTotalTokens = computeTokens(trimmedPrompt); + const sectionsSum = recentlyViewedTokens + currentFileTokens + lintErrorsTokens + editHistoryTokens + areaAroundCodeToEditTokens + cursorLocationTokens + relatedInformationTokens + postScriptTokens; + const sectionTokens: PromptSectionTokenCounts = { + recentlyViewed: recentlyViewedTokens, + currentFile: currentFileTokens, + lintErrors: lintErrorsTokens, + editHistory: editHistoryTokens, + areaAroundCodeToEdit: areaAroundCodeToEditTokens, + cursorLocation: cursorLocationTokens, + relatedInformation: relatedInformationTokens, + postScript: postScriptTokens, + overhead: userPromptTotalTokens - sectionsSum, + userPromptTotal: userPromptTotalTokens, + systemPrompt: 0, + recentlyViewedSubsections: { + recentlyViewedFiles: computeTokens(recentlyViewedSubsections.recentlyViewedFiles), + languageContext: computeTokens(recentlyViewedSubsections.languageContext), + neighborFiles: computeTokens(recentlyViewedSubsections.neighborFiles), + }, + }; + + return { prompt: trimmedPrompt, nDiffsInPrompt, neighborSnippetsResult, sectionTokens }; } export interface CascadeResult { @@ -177,7 +214,8 @@ export interface CascadeResult { readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; readonly editDiffHistory: string; readonly nDiffsInPrompt: number; - readonly diffTokensInPrompt: number; + /** Per-source breakdown of {@link codeSnippets} for per-subsection token reporting. */ + readonly subsections: RecentlyViewedSubsectionSnippets; /** * Budget left unused after the last part in `order` ran. The provider adds it * to the current file's clip budget (`currentFileBudget + finalSurplus`) so the @@ -226,7 +264,6 @@ export function runGlobalBudgetCascade( let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; let editDiffHistory = ''; let nDiffsInPrompt = 0; - let diffTokensInPrompt = 0; const preparedRecent = prepareRecentCodeSnippets(activeDoc, xtabHistory, opts); @@ -265,7 +302,6 @@ export function runGlobalBudgetCascade( const r = getEditDiffHistory(activeDoc, xtabHistory, docsInPrompt, computeTokens, overriddenDiff); editDiffHistory = r.promptPiece; nDiffsInPrompt = r.nDiffs; - diffTokensInPrompt = r.totalTokens; tokensConsumed = r.totalTokens; break; } @@ -288,7 +324,11 @@ export function runGlobalBudgetCascade( neighborSnippetsResult, editDiffHistory, nDiffsInPrompt, - diffTokensInPrompt, + subsections: { + recentlyViewedFiles: recentlyViewedSnippets.join('\n\n'), + languageContext: langCtxSnippets.join('\n\n'), + neighborFiles: neighborOutSnippets.join('\n\n'), + }, finalSurplus: surplus, }; } diff --git a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts index 6db4b0bbd8fea4..65fc454c4a6683 100644 --- a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts +++ b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts @@ -26,7 +26,12 @@ export function getRecentCodeSnippets( computeTokens: (code: string) => number, opts: PromptOptions, neighborSnippets?: readonly INeighborFileSnippet[], -): { codeSnippets: string; documents: Set; neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined } { +): { + codeSnippets: string; + documents: Set; + neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; + subsections: RecentlyViewedSubsectionSnippets; +} { const { includeViewedFiles, nDocuments, clippingStrategy } = opts.recentlyViewedDocuments; @@ -40,24 +45,49 @@ export function getRecentCodeSnippets( recentlyViewedCodeSnippets = docsBesidesActiveDoc.map(d => historyEntryToCodeSnippet(d)); } + // Keep the three sources in separate arrays (like `runGlobalBudgetCascade`) + // so per-subsection token counts can be reported. The appenders only read + // `docsInPrompt` for de-duplication, so splitting the output arrays does not + // change which snippets are selected; concatenating them in the same order + // (recent files, language context, neighbor files) reproduces the previous + // single-array output byte-for-byte. const { snippets, docsInPrompt } = buildCodeSnippetsUsingPagedClipping(recentlyViewedCodeSnippets, computeTokens, opts); + const langCtxSnippets: string[] = []; if (langCtx) { - appendLanguageContextSnippets(langCtx, snippets, opts.languageContext.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); + appendLanguageContextSnippets(langCtx, langCtxSnippets, opts.languageContext.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); } + const neighborOutSnippets: string[] = []; let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; if (opts.neighborFiles.enabled && neighborSnippets && neighborSnippets.length > 0) { - neighborSnippetsResult = appendNeighborFileSnippets(neighborSnippets, snippets, docsInPrompt, opts.neighborFiles.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); + neighborSnippetsResult = appendNeighborFileSnippets(neighborSnippets, neighborOutSnippets, docsInPrompt, opts.neighborFiles.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); } return { - codeSnippets: snippets.join('\n\n'), + codeSnippets: [...snippets, ...langCtxSnippets, ...neighborOutSnippets].join('\n\n'), documents: docsInPrompt, neighborSnippetsResult, + subsections: { + recentlyViewedFiles: snippets.join('\n\n'), + languageContext: langCtxSnippets.join('\n\n'), + neighborFiles: neighborOutSnippets.join('\n\n'), + }, }; } +/** + * Rendered strings for the three sources that make up the + * `recently_viewed_code_snippets` block, kept separate so per-subsection token + * counts can be reported. Each is the `\n\n`-joined snippets for that source + * (empty string when the source contributed nothing). + */ +export interface RecentlyViewedSubsectionSnippets { + readonly recentlyViewedFiles: string; + readonly languageContext: string; + readonly neighborFiles: string; +} + function formatLinesWithLineNumbers( lines: string[], includeLineNumbers: xtabPromptOptions.IncludeLineNumbersOption, diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index fa773eb2c09055..0917677798f251 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -492,10 +492,9 @@ export class XtabProvider implements IStatelessNextEditProvider { precomputedCascade, ); - const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult } = getUserPrompt(promptPieces); + const { prompt: userPrompt, nDiffsInPrompt, neighborSnippetsResult, sectionTokens } = getUserPrompt(promptPieces); telemetry.setNDiffsInPrompt(nDiffsInPrompt); - telemetry.setDiffTokensInPrompt(diffTokensInPrompt); if (neighborSnippetsResult) { telemetry.setNNeighborSnippetsComputed(neighborSnippetsResult.nComputed); telemetry.setNNeighborSnippetsInPrompt(neighborSnippetsResult.nIncluded); @@ -506,14 +505,22 @@ export class XtabProvider implements IStatelessNextEditProvider { const prediction = this.getPredictedOutput(activeDocument, currentDocument.cursorLineOffset, editWindowLines, cursorLineInEditWindowOffset, responseFormat); + const systemMsg = pickSystemPrompt(promptOptions.promptingStrategy); const messages = constructMessages({ - systemMsg: pickSystemPrompt(promptOptions.promptingStrategy), + systemMsg, userMsg: userPrompt, }); logContext.setPrompt(messages); telemetry.setPrompt(messages); + // Report approximate (char/4) per-section token counts, filling in the + // system-prompt count which getUserPrompt cannot know. Do this BEFORE the + // HARD_CHAR_LIMIT early-return so oversized prompts still report counts. + const promptSectionTokens = { ...sectionTokens, systemPrompt: XtabProvider.computeTokens(systemMsg) }; + telemetry.setPromptSectionTokens(promptSectionTokens); + logContext.setPromptSectionTokens(promptSectionTokens); + const HARD_CHAR_LIMIT = 30000 * 4; // 30K tokens, assuming 4 chars per token -- we use approximation here because counting tokens exactly is time-consuming const promptCharCount = charCount(messages); if (promptCharCount > HARD_CHAR_LIMIT) { diff --git a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts index 9deffc6eaed5f8..a8788a1ee6f4d9 100644 --- a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts @@ -9,6 +9,7 @@ import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; import { AggressivenessLevel, CurrentFileOptions, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, PromptingStrategy, PromptOptions } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { LanguageContextResponse } from '../../../../platform/inlineEdits/common/dataTypes/languageContext'; +import { PromptSectionTokenCounts } from '../../../../platform/inlineEdits/common/dataTypes/promptSectionTokens'; import { ContextKind } from '../../../../platform/languageServer/common/languageContextService'; import { StatelessNextEditDocument } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; import { TestLanguageDiagnosticsService } from '../../../../platform/languages/common/testLanguageDiagnosticsService'; @@ -821,6 +822,71 @@ describe('getUserPrompt', () => { expect(prompt).toContain('<|aggressive|>medium<|/aggressive|>'); }); }); + + describe('sectionTokens', () => { + + const sectionsSum = (t: PromptSectionTokenCounts): number => + t.recentlyViewed + t.currentFile + t.lintErrors + t.editHistory + + t.areaAroundCodeToEdit + t.cursorLocation + t.relatedInformation + t.postScript; + + test('total matches the assembled prompt, counts reconcile, and systemPrompt is left at 0 across strategies', () => { + const summary = [PromptingStrategy.PatchBased01, PromptingStrategy.PatchBased02, undefined].map(strategy => { + const { prompt, sectionTokens } = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy })); + return { + totalMatchesPrompt: sectionTokens.userPromptTotal === computeTokens(prompt), + reconciles: sectionsSum(sectionTokens) + sectionTokens.overhead === sectionTokens.userPromptTotal, + systemPromptZero: sectionTokens.systemPrompt === 0, + }; + }); + + assert.deepStrictEqual(summary, [ + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + ]); + }); + + test('reports the strategy-dependent tail section (area vs cursor) mutually exclusively', () => { + const tailShape = (strategy: PromptingStrategy | undefined) => { + const { sectionTokens } = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy })); + return { areaPresent: sectionTokens.areaAroundCodeToEdit > 0, cursorPresent: sectionTokens.cursorLocation > 0 }; + }; + + assert.deepStrictEqual( + { + patchBased01: tailShape(PromptingStrategy.PatchBased01), + patchBased02: tailShape(PromptingStrategy.PatchBased02), + default: tailShape(undefined), + }, + { + patchBased01: { areaPresent: false, cursorPresent: false }, + patchBased02: { areaPresent: false, cursorPresent: true }, + default: { areaPresent: true, cursorPresent: false }, + }, + ); + }); + + test('reports 0 for absent optional sections and non-zero for present ones', () => { + // Fixture has no language context (relatedInformation empty) and always renders a current file. + const withPostScript = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy: PromptingStrategy.PatchBased02 })).sectionTokens; + const withoutPostScript = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy: PromptingStrategy.PatchBased02, includePostScript: false })).sectionTokens; + + assert.deepStrictEqual( + { + relatedInformation: withPostScript.relatedInformation, + currentFilePresent: withPostScript.currentFile > 0, + postScriptPresent: withPostScript.postScript > 0, + postScriptAbsent: withoutPostScript.postScript, + }, + { + relatedInformation: 0, + currentFilePresent: true, + postScriptPresent: true, + postScriptAbsent: 0, + }, + ); + }); + }); }); describe('getUserPrompt — globalBudget cascade', () => { @@ -1006,4 +1072,35 @@ describe('getUserPrompt — globalBudget cascade', () => { expect(precomputed.prompt).toBe(internal.prompt); expect(precomputed.nDiffsInPrompt).toBe(internal.nDiffsInPrompt); }); + + test('reports the recently-viewed subsection token breakdown, matching the legacy path', () => { + // Empty history and disabled neighbor files leave the recently-viewed-files + // and neighbor-files subsections at 0; the language-context snippet populates + // the language-context subsection. Large budgets keep the two assembly paths + // (cascade vs legacy `getRecentCodeSnippets`) byte-identical, so their + // subsection counts must match. + const snippet = 'const sharedCtxMarker = 42;'; + const globalBudget: PromptOptions['globalBudget'] = { + totalTokens: 100000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + const cascaded = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) })).sectionTokens.recentlyViewedSubsections; + const legacy = getUserPrompt(makePieces(undefined, { langCtx: makeLangCtxWithSnippet(snippet) })).sectionTokens.recentlyViewedSubsections; + + assert.deepStrictEqual( + { + recentlyViewedFilesZero: cascaded.recentlyViewedFiles === 0, + languageContextPopulated: cascaded.languageContext > 0, + neighborFilesZero: cascaded.neighborFiles === 0, + legacyEqualsCascade: JSON.stringify(legacy) === JSON.stringify(cascaded), + }, + { + recentlyViewedFilesZero: true, + languageContextPopulated: true, + neighborFilesZero: true, + legacyEqualsCascade: true, + }, + ); + }); }); diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts new file mode 100644 index 00000000000000..0a4a922caf0c0e --- /dev/null +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Approximate token counts for each section of the NES ("xtab") prompt. + * + * All values are computed with the same char/4 approximation used for prompt + * budgeting (see `XtabProvider.computeTokens`), not an exact tokenizer, so they + * are diagnostics rather than a precise reproduction of the endpoint's + * `promptTokens`. Every count except `systemPrompt` is scoped to the user + * message and reflects the rendered section (including its `<|...|>` tags and + * any inline headers such as `current_file_path:`). + */ +export interface PromptSectionTokenCounts { + /** `recently_viewed_code_snippets` block (in global-budget mode this merges recent docs, language-context snippets and neighbor files). */ + readonly recentlyViewed: number; + /** `current_file_content` block, including the `current_file_path:` header. */ + readonly currentFile: number; + /** Lint errors block (with its surrounding newline padding); 0 when absent. */ + readonly lintErrors: number; + /** Rendered `edit_diff_history` block (including its section tags). */ + readonly editHistory: number; + /** `area_around_code_to_edit` block; 0 for strategies that emit `cursor_location` or neither. */ + readonly areaAroundCodeToEdit: number; + /** `cursor_location` block; 0 for strategies that emit `area_around_code_to_edit` or neither. */ + readonly cursorLocation: number; + /** Related-information language traits (`getRelatedInformation`); 0 when absent. */ + readonly relatedInformation: number; + /** Trailing instruction postScript; 0 when `includePostScript` is false. */ + readonly postScript: number; + /** Formatting glue not attributed to any section: `userPromptTotal - sum(sections)` (newlines, optional backtick fences, trim, and char/4 rounding). May be slightly negative. */ + readonly overhead: number; + /** Total tokens of the final trimmed user prompt. `sum(sections) + overhead === userPromptTotal`. */ + readonly userPromptTotal: number; + /** Tokens of the system message. Not part of the user prompt; filled in by the provider. */ + readonly systemPrompt: number; + + /** + * Breakdown of the {@link recentlyViewed} section into the three sources it + * is assembled from. These do not sum exactly to {@link recentlyViewed} + * because that section also includes the `<|recently_viewed_code_snippets|>` + * tags and the `\n\n` glue joining the individual snippets. + */ + readonly recentlyViewedSubsections: RecentlyViewedSubsectionTokenCounts; +} + +/** + * Approximate (char/4) token counts for the constituent subsections of the + * `recently_viewed_code_snippets` block. Rendered in the order below (recent + * files first, neighbor files last, closest to the current file). + */ +export interface RecentlyViewedSubsectionTokenCounts { + /** Recently-viewed/edited files reconstructed from the xtab edit/view history. */ + readonly recentlyViewedFiles: number; + /** Language-context *snippets* from the language server (the `Snippet`-kind items; distinct from the `relatedInformation` traits section). */ + readonly languageContext: number; + /** Neighbor ("similar") file snippets from the Completions-style provider. */ + readonly neighborFiles: number; +} diff --git a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts index 8b5ee481c9248a..3fa58e7837e45c 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -15,6 +15,7 @@ import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRa import { SerializedEdit } from './dataTypes/editUtils'; import { FetchCancellationError } from './dataTypes/fetchCancellationError'; import { LanguageContextResponse, SerializedContextResponse, serializeLanguageContext } from './dataTypes/languageContext'; +import { PromptSectionTokenCounts } from './dataTypes/promptSectionTokens'; import { RootedLineEdit } from './dataTypes/rootedLineEdit'; import { DebugRecorderBookmark } from './debugRecorderBookmark'; import { ISerializedNextEditRequest, StatelessNextEditRequest } from './statelessNextEditProvider'; @@ -177,6 +178,31 @@ export class InlineEditRequestLogContext { lines.push('\n\n'); } + if (this._promptSectionTokens) { + const t = this._promptSectionTokens; + lines.push(`## Prompt section tokens ${fromCacheStatus}`); + lines.push('
Click to view\n'); + lines.push('Approximate (char/4) token counts per prompt section.\n'); + lines.push('Indented `↳` rows break down `recently_viewed_code_snippets` by source; they do not sum exactly to the section total (section tags and inter-snippet newline glue are excluded).\n'); + lines.push('| Section | Tokens |'); + lines.push('| --- | --- |'); + lines.push(`| system_prompt | ${t.systemPrompt} |`); + lines.push(`| recently_viewed_code_snippets | ${t.recentlyViewed} |`); + lines.push(`|   ↳ recently viewed files (xtab history) | ${t.recentlyViewedSubsections.recentlyViewedFiles} |`); + lines.push(`|   ↳ language context | ${t.recentlyViewedSubsections.languageContext} |`); + lines.push(`|   ↳ neighbor files | ${t.recentlyViewedSubsections.neighborFiles} |`); + lines.push(`| current_file_content | ${t.currentFile} |`); + lines.push(`| lint_errors | ${t.lintErrors} |`); + lines.push(`| edit_diff_history | ${t.editHistory} |`); + lines.push(`| area_around_code_to_edit | ${t.areaAroundCodeToEdit} |`); + lines.push(`| cursor_location | ${t.cursorLocation} |`); + lines.push(`| related_information | ${t.relatedInformation} |`); + lines.push(`| post_script | ${t.postScript} |`); + lines.push(`| overhead (newlines / backticks / trim) | ${t.overhead} |`); + lines.push(`| **user_prompt_total** | **${t.userPromptTotal}** |`); + lines.push('\n
\n'); + } + if (this._isAccepted !== undefined) { lines.push(`## Accepted : ${this._isAccepted ? 'Yes' : 'No'}`); } @@ -302,6 +328,7 @@ export class InlineEditRequestLogContext { if (logContextOfCachedEdit._prompt) { this._prompt = logContextOfCachedEdit._prompt; } + this._promptSectionTokens = logContextOfCachedEdit._promptSectionTokens ?? this._promptSectionTokens; this.response = logContextOfCachedEdit.response ?? this.response; this._responseResults = logContextOfCachedEdit._responseResults ?? this._responseResults; if (logContextOfCachedEdit.fullResponsePromise) { @@ -331,6 +358,7 @@ export class InlineEditRequestLogContext { if (logContextOfReusedRequest._prompt) { this._prompt = logContextOfReusedRequest._prompt; } + this._promptSectionTokens = logContextOfReusedRequest._promptSectionTokens ?? this._promptSectionTokens; this.response = logContextOfReusedRequest.response ?? this.response; this._responseResults = logContextOfReusedRequest._responseResults ?? this._responseResults; if (logContextOfReusedRequest.fullResponsePromise) { @@ -365,6 +393,7 @@ export class InlineEditRequestLogContext { public _prompt: string | undefined = undefined; private _rawMessages: Raw.ChatMessage[] | undefined = undefined; + public _promptSectionTokens: PromptSectionTokenCounts | undefined = undefined; get prompt(): string | undefined { return this._prompt; @@ -385,6 +414,11 @@ export class InlineEditRequestLogContext { this.fireDidChange(); } + setPromptSectionTokens(counts: PromptSectionTokenCounts) { + this._promptSectionTokens = counts; + this.fireDidChange(); + } + /** * Raw chat messages used to construct the cursor-jump (next-cursor-line * prediction) prompt, and the document-line offset range the model can diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index b4d3cca449b2c7..706ec30283281f 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -21,6 +21,7 @@ import { DocumentId } from './dataTypes/documentId'; import { Edits } from './dataTypes/edit'; import { SerializedEdit } from './dataTypes/editUtils'; import { LanguageId } from './dataTypes/languageId'; +import { PromptSectionTokenCounts } from './dataTypes/promptSectionTokens'; import { DebugRecorderBookmark } from './debugRecorderBookmark'; import { InlineEditRequestLogContext } from './inlineEditLogContext'; import { stringifyChatMessages } from './utils/stringifyChatMessages'; @@ -420,7 +421,9 @@ export interface IStatelessNextEditTelemetry { /* diff history info */ readonly nDiffsInPrompt: number | undefined; - readonly diffTokensInPrompt: number | undefined; + + /* prompt section token counts (approximate char/4 counts; see PromptSectionTokenCounts) */ + readonly promptSectionTokens: PromptSectionTokenCounts | undefined; /* neighbor (similar files) snippets info */ readonly nNeighborSnippetsComputed: number | undefined; @@ -519,7 +522,7 @@ export class StatelessNextEditTelemetryBuilder { cursorJumpPrompt: this._cursorJumpPrompt ? JSON.stringify(this._cursorJumpPrompt.map(({ role, content }) => ({ role, content }))) : undefined, cursorJumpResponse: this._cursorJumpResponse, nDiffsInPrompt: this._nDiffsInPrompt, - diffTokensInPrompt: this._diffTokensInPrompt, + promptSectionTokens: this._promptSectionTokens, nNeighborSnippetsComputed: this._nNeighborSnippetsComputed, nNeighborSnippetsInPrompt: this._nNeighborSnippetsInPrompt, neighborSnippetIndicesInPrompt: this._neighborSnippetIndicesInPrompt, @@ -714,9 +717,9 @@ export class StatelessNextEditTelemetryBuilder { return this; } - private _diffTokensInPrompt: number | undefined; - public setDiffTokensInPrompt(n: number): this { - this._diffTokensInPrompt = n; + private _promptSectionTokens: PromptSectionTokenCounts | undefined; + public setPromptSectionTokens(counts: PromptSectionTokenCounts): this { + this._promptSectionTokens = counts; return this; } diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index ad40dd76657d0b..5fa103a9566116 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -34,7 +34,7 @@ "input.background": "#191A1B", "input.border": "#333536FF", "input.foreground": "#bfbfbf", - "input.placeholderForeground": "#828282", + "input.placeholderForeground": "#555555", "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", @@ -288,7 +288,7 @@ "agentsChatInput.foreground": "#bfbfbf", "agentsChatInput.border": "#333536", "agentsChatInput.focusBorder": "#3994BCB3", - "agentsChatInput.placeholderForeground": "#828282", + "agentsChatInput.placeholderForeground": "#555555", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#bfbfbf", "agentsNewSessionButton.border": "#333536", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 1f7a6ba4792ca5..1cb521076fde97 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -36,7 +36,7 @@ "input.background": "#FFFFFF", "input.border": "#D8D8D866", "input.foreground": "#202020", - "input.placeholderForeground": "#767676", + "input.placeholderForeground": "#999999", "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", "inputOption.activeBorder": "#F0F1F2FF", @@ -296,7 +296,7 @@ "agentsChatInput.foreground": "#202020", "agentsChatInput.border": "#D8D8D8", "agentsChatInput.focusBorder": "#0069CCFF", - "agentsChatInput.placeholderForeground": "#767676", + "agentsChatInput.placeholderForeground": "#999999", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#202020", "agentsNewSessionButton.border": "#D8D8D8", diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index c767d36a7976a9..4e3a0c3f3c5c9c 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -680,6 +680,8 @@ export class ChannelClient implements IChannelClient, IDisposable { const emitter = new Emitter({ onWillAddFirstListener: () => { + const handler: IHandler = (res: IRawResponse) => emitter.fire((res as IRawEventFireResponse).data); + this.handlers.set(id, handler); const doRequest = () => { this.activeRequests.add(emitter); this.sendRequest(request); @@ -706,9 +708,6 @@ export class ChannelClient implements IChannelClient, IDisposable { } }); - const handler: IHandler = (res: IRawResponse) => emitter.fire((res as IRawEventFireResponse).data); - this.handlers.set(id, handler); - return emitter.event; } diff --git a/src/vs/base/parts/ipc/test/common/ipc.test.ts b/src/vs/base/parts/ipc/test/common/ipc.test.ts index 47a88f12ab13e4..90140a90c735a1 100644 --- a/src/vs/base/parts/ipc/test/common/ipc.test.ts +++ b/src/vs/base/parts/ipc/test/common/ipc.test.ts @@ -320,6 +320,27 @@ suite('Base IPC', function () { assert.deepStrictEqual(messages, ['hello', 'world']); }); + test('listen to events (resubscribe)', async function () { + const onPong = ipcService.onPong; + const messages: string[] = []; + + const disposable1 = onPong(msg => messages.push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, []); + service.ping('hello'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + disposable1.dispose(); + + const disposable2 = onPong(msg => (messages as string[]).push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + service.ping('world'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello', 'world']); + disposable2.dispose(); + }); + test('buffers in arrays', async function () { const r = await ipcService.buffersLength([VSBuffer.alloc(2), VSBuffer.alloc(3)]); return assert.strictEqual(r, 5); @@ -443,6 +464,27 @@ suite('Base IPC', function () { assert.deepStrictEqual(messages, ['hello', 'world']); }); + test('listen to events (resubscribe)', async function () { + const onPong = ipcService.onPong; + const messages: string[] = []; + + const disposable1 = onPong(msg => messages.push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, []); + service.ping('hello'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + disposable1.dispose(); + + const disposable2 = onPong(msg => (messages as string[]).push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + service.ping('world'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello', 'world']); + disposable2.dispose(); + }); + test('marshalling uri', async function () { const uri = URI.file('foobar'); const r = await ipcService.marshall(uri); diff --git a/src/vs/platform/theme/common/sizes/baseSizes.ts b/src/vs/platform/theme/common/sizes/baseSizes.ts index b783ac1d4aa411..bb583270dd9dea 100644 --- a/src/vs/platform/theme/common/sizes/baseSizes.ts +++ b/src/vs/platform/theme/common/sizes/baseSizes.ts @@ -8,17 +8,75 @@ import { registerSize, sizeForAllThemes } from '../sizeUtils.js'; // ------ Font Sizes +/** @deprecated Use {@link fontSizeBody1} instead. */ export const bodyFontSize = registerSize('bodyFontSize', sizeForAllThemes(13, 'px'), - nls.localize('bodyFontSize', "Base font size. This size is used if not overridden by a component.")); + nls.localize('bodyFontSize', "Base font size. This size is used if not overridden by a component."), + nls.localize('bodyFontSize.deprecated', "Deprecated: use `fontSize.body1` instead.")); +/** @deprecated Use {@link fontSizeLabel1} instead. */ export const bodyFontSizeSmall = registerSize('bodyFontSize.small', sizeForAllThemes(12, 'px'), - nls.localize('bodyFontSizeSmall', "Small font size for secondary content.")); + nls.localize('bodyFontSizeSmall', "Small font size for secondary content."), + nls.localize('bodyFontSizeSmall.deprecated', "Deprecated: use `fontSize.label1` instead.")); +/** @deprecated Use {@link fontSizeBody2} instead. */ export const bodyFontSizeXSmall = registerSize('bodyFontSize.xSmall', sizeForAllThemes(11, 'px'), - nls.localize('bodyFontSizeXSmall', "Extra small font size for less prominent content.")); + nls.localize('bodyFontSizeXSmall', "Extra small font size for less prominent content."), + nls.localize('bodyFontSizeXSmall.deprecated', "Deprecated: use `fontSize.body2` instead.")); + +// ------ Font ramp +// +// A generic font-size ramp (headings, body and labels) mirroring the agents +// window ramp. "Strong" variants are NOT separate size tokens: reuse the +// matching size token paired with `fontWeight.semiBold` (600). Regular text +// pairs with `fontWeight.regular` (400). + +export const fontSizeHeading1 = registerSize('fontSize.heading1', + sizeForAllThemes(26, 'px'), + nls.localize('fontSizeHeading1', "Heading 1 font size (largest heading).")); + +export const fontSizeHeading2 = registerSize('fontSize.heading2', + sizeForAllThemes(18, 'px'), + nls.localize('fontSizeHeading2', "Heading 2 font size (title).")); + +export const fontSizeHeading3 = registerSize('fontSize.heading3', + sizeForAllThemes(13, 'px'), + nls.localize('fontSizeHeading3', "Heading 3 font size (subtitle).")); + +export const fontSizeBody1 = registerSize('fontSize.body1', + sizeForAllThemes(13, 'px'), + nls.localize('fontSizeBody1', "Primary body font size.")); + +export const fontSizeBody2 = registerSize('fontSize.body2', + sizeForAllThemes(11, 'px'), + nls.localize('fontSizeBody2', "Secondary body font size.")); + +export const fontSizeLabel1 = registerSize('fontSize.label1', + sizeForAllThemes(12, 'px'), + nls.localize('fontSizeLabel1', "Label 1 font size (section title, tabs).")); + +export const fontSizeLabel2 = registerSize('fontSize.label2', + sizeForAllThemes(11, 'px'), + nls.localize('fontSizeLabel2', "Label 2 font size (metadata).")); + +export const fontSizeLabel3 = registerSize('fontSize.label3', + sizeForAllThemes(10, 'px'), + nls.localize('fontSizeLabel3', "Label 3 font size (badge).")); + +// ------ Font weights +// +// A two-weight ramp (regular/semiBold). "Strong" emphasis reuses the matching +// font-size token paired with `fontWeight.semiBold`. + +export const fontWeightRegular = registerSize('fontWeight.regular', + sizeForAllThemes(400, ''), + nls.localize('fontWeightRegular', "Regular font weight (400) for body, labels and metadata.")); + +export const fontWeightSemiBold = registerSize('fontWeight.semiBold', + sizeForAllThemes(600, ''), + nls.localize('fontWeightSemiBold', "SemiBold font weight (600) for headings and strong emphasis.")); export const codiconFontSize = registerSize('codiconFontSize', sizeForAllThemes(16, 'px'), diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index aafc2e7dc8fa7a..03a4e976aa5268 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -116,7 +116,7 @@ When multiple remote agent hosts are known, a dropdown pill in the left toolbar ### Blocked Sessions (Center) -When the primary side bar is hidden and at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to an orange "N sessions require input" state (orange foreground, background and border), and blinks twice whenever a newly blocked session appears. A session counts as blocked when it needs input, or — while not in progress — has failing CI checks or unresolved pull request comments. Detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`) that the widget instantiates, which reuses the shared, background-polled GitHub CI / review-thread models via `computePullRequestIconStatus`. Clicking the widget opens those sessions rendered exactly like the sessions list but flat — no sections, groups or workspace headers — via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. When the side bar is visible or no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven directly by the `BlockedSessions` model's `blockedSessions` observable together with `Parts.SIDEBAR_PART` visibility. +When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to an orange "N sessions require input" state (orange foreground, background and border), and blinks twice whenever a newly blocked session appears. A session counts as blocked when it needs input, or — while not in progress — has failing CI checks or unresolved pull request comments. Detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`) that the widget instantiates, which reuses the shared, background-polled GitHub CI / review-thread models via `computePullRequestIconStatus`. Clicking the widget opens those sessions rendered exactly like the sessions list but flat — no sections, groups or workspace headers — via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. The flat list passes `toolbarActions: false` so its rows render no inline action toolbar (pin, mark as done, etc.), which don't apply to blocked sessions. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven directly by the `BlockedSessions` model's `blockedSessions` observable. ### Account Widget (Right) diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index 99951cae413a6a..626e3803c02e03 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -23,7 +23,7 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; -import { SessionHasChangesContext } from '../../../common/contextkeys.js'; +import { SessionHasChangesContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; @@ -58,7 +58,7 @@ class ViewAllChangesAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: 0, - when: SessionHasChangesContext + when: ContextKeyExpr.and(SessionHasChangesContext, IsQuickChatSessionContext.negate()) }, }); } @@ -95,6 +95,44 @@ class ViewAllChangesAction extends Action2 { } registerAction2(ViewAllChangesAction); +// --- Open File action (per-file toolbar in the session changes multi-diff editor) + +/** + * Opens the file shown in a diff row of the Agents window's session Changes + * multi-diff editor as a regular editor. The workbench {@link GoToFileAction} + * only appears for the generic {@link MultiDiffEditor}, so the session Changes + * editor needs its own entry in the per-file toolbar. + */ +class OpenChangedFileAction extends Action2 { + + static readonly ID = 'workbench.agentSessions.changes.openFile'; + + constructor() { + super({ + id: OpenChangedFileAction.ID, + title: localize2('agentSessions.changes.openFile', 'Open File'), + icon: Codicon.goToFile, + f1: false, + menu: { + id: MenuId.MultiDiffEditorFileToolbar, + when: ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + group: 'navigation', + order: 22, + }, + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const resource = args[0]; + if (!(resource instanceof URI)) { + return; + } + + await accessor.get(IEditorService).openEditor({ resource }); + } +} +registerAction2(OpenChangedFileAction); + // --- View All Changes action view item (session header diff stats) interface IDiffStats { diff --git a/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts index 27364ef64ed79d..5812b2b60bc321 100644 --- a/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts @@ -7,12 +7,11 @@ import './media/changesSummaryWidget.css'; import * as dom from '../../../../base/browser/dom.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { ISessionChangesSummary } from '../../../services/sessions/common/session.js'; import { IChangesViewService } from '../common/changesViewService.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { Throttler } from '../../../../base/common/async.js'; +import { AnimatedCounterWidget } from '../../../../workbench/browser/animatedCounterWidget.js'; export class ChangesSummaryWidget extends Disposable { private readonly _summaryObs: IObservable; @@ -76,132 +75,3 @@ export class ChangesSummaryWidget extends Disposable { })); } } - -interface IAnimatedCounterWidgetOptions { - readonly prefix?: string; - readonly cssClassName?: string; - /** - * The direction of the animation when the count - * increases. The direction will be the opposite - * when the count decreases. - * */ - readonly direction?: 'topToBottom' | 'bottomToTop'; - readonly duration?: number; - readonly count: IObservable; -} - -class AnimatedCounterWidget extends Disposable { - private _element: HTMLElement; - private _count: number | undefined; - private readonly _animationOptions: KeyframeAnimationOptions; - private readonly _updateThrottler = this._register(new Throttler()); - - constructor( - container: HTMLElement, - private readonly _options: IAnimatedCounterWidgetOptions, - @IAccessibilityService private readonly _accessibilityService: IAccessibilityService - ) { - super(); - - const { cssClassName, duration } = _options; - - this._element = cssClassName - ? dom.$(`div.${cssClassName}`) - : dom.$('div'); - - this._element.appendChild(dom.$(`div`)); - container.appendChild(this._element); - - this._animationOptions = { - duration: duration ?? 240, - easing: 'cubic-bezier(0.22, 1, 0.36, 1)', - fill: 'both', - } satisfies KeyframeAnimationOptions; - - this._register(autorun(reader => { - const count = this._options.count.read(reader); - this._updateThrottler.queue(() => this._update(count)); - })); - } - - private async _update(count: number | undefined): Promise { - if (!this._element || this._element.children.length === 0) { - return; - } - - const outgoingElement = this._element.children[0]; - - if (count === undefined) { - outgoingElement.textContent = ''; - this._count = undefined; - return; - } - - // Create incoming element - const incomingElementText = `${this._options.prefix ?? ''}${count}`; - - // Skip the animation when it is disabled (duration of 0) or when - // the user prefers reduced motion, just update the text content. - if (this._options.duration === 0 || this._accessibilityService.isMotionReduced()) { - outgoingElement.textContent = incomingElementText; - this._count = count; - return; - } - - // Measure the current width before adding the incoming element so - // that a change in the number of digits can be animated smoothly. - const previousWidth = this._element.getBoundingClientRect().width; - - const incomingElement = dom.$(`div`, undefined, incomingElementText); - this._element?.appendChild(incomingElement); - - // The incoming element is content-sized, so its width is the width the - // container will have once the outgoing element is removed. Animate the - // container between the two widths for both growing and shrinking digit - // counts. - const nextWidth = incomingElement.getBoundingClientRect().width; - - if (Math.abs(previousWidth - nextWidth) > 0.5) { - this._element.animate([ - { width: `${previousWidth}px` }, - { width: `${nextWidth}px` }, - ], this._animationOptions); - } - - const directionOption = this._options.direction ?? 'topToBottom'; - const directionTopBottom = directionOption === 'topToBottom' - ? count > (this._count ?? 0) - : count < (this._count ?? 0); - - const enterFrom = directionTopBottom ? '-100%' : '100%'; - const exitTo = directionTopBottom ? '100%' : '-100%'; - - incomingElement.animate([ - { transform: `translateY(${enterFrom})`, opacity: 0 }, - { transform: 'translateY(0)', opacity: 1 }, - ], this._animationOptions); - - const exit = outgoingElement.animate([ - { transform: 'translateY(0)', opacity: 1 }, - { transform: `translateY(${exitTo})`, opacity: 0 }, - ], this._animationOptions); - - await new Promise(resolve => { - let didCleanup = false; - - const cleanup = () => { - if (didCleanup) { - return; - } - - didCleanup = true; - this._count = count; - this._element?.removeChild(outgoingElement); - resolve(); - }; - - exit.addEventListener('cancel', cleanup); - exit.addEventListener('finish', cleanup); - }); - } -} diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 2df62f7a9ae0fe..43001bccf58f14 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -503,6 +503,11 @@ export class ChangesViewPane extends ViewPane { private splitViewContainer: HTMLElement | undefined; private readonly treePaneSizeChange = this._register(new Emitter()); + /** Computes the CI pane's default height (content, capped to a third of the split). */ + private computeCIPreferredHeight: (() => number) | undefined; + /** Once the user drags a sash we stop imposing the CI pane's default height. */ + private ciPaneUserResized = false; + private readonly isMergeBaseBranchProtectedContextKey: IContextKey; private readonly isolationModeContextKey: IContextKey; private readonly hasGitRepositoryContextKey: IContextKey; @@ -673,6 +678,19 @@ export class ChangesViewPane extends ViewPane { const getSessionFilesPreferredHeight = () => Math.max(getSessionFilesMinimumHeight(), SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT); const getCIContentHeight = () => Math.max(CIStatusWidget.HEADER_HEIGHT, this.ciStatusWidget?.desiredHeight ?? 0); const getCIMinimumHeight = () => this.ciStatusWidget?.collapsed ? CIStatusWidget.HEADER_HEIGHT : Math.min(ciMinHeight, getCIContentHeight()); + // Preferred default size for the CI pane: content height, capped to a third of the split. + const getCIPreferredHeight = () => { + const contentHeight = getCIContentHeight(); + if (this.ciStatusWidget?.collapsed) { + return CIStatusWidget.HEADER_HEIGHT; + } + const availableHeight = this.getSplitViewAvailableHeight(); + if (availableHeight > 0) { + return Math.max(getCIMinimumHeight(), Math.min(contentHeight, Math.round(availableHeight / 3))); + } + return contentHeight; + }; + this.computeCIPreferredHeight = getCIPreferredHeight; const thisView = this; // Top pane: file tree @@ -731,6 +749,9 @@ export class ChangesViewPane extends ViewPane { updateSplitViewStyles(); this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles)); + // A manual sash drag hands layout control to the user: stop imposing the CI default size. + this._register(this.splitView.onDidSashChange(() => { this.ciPaneUserResized = true; })); + // Initially hide the other files and CI panes until content arrives this.splitView.setViewVisible(1, false); this.splitView.setViewVisible(2, false); @@ -740,7 +761,7 @@ export class ChangesViewPane extends ViewPane { this._register(this.sessionFilesWidget.onDidChangeHeight(() => this.fireTreePaneSizeChange())); // CI checks pane (index 2) - this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, getCIContentHeight); + this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, getCIPreferredHeight, () => { this.ciPaneUserResized = false; }); this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { @@ -1033,21 +1054,49 @@ export class ChangesViewPane extends ViewPane { this.treePaneSizeChange.fire(undefined); } + /** Compute the height available to the SplitView within the body. */ + private getSplitViewAvailableHeight(): number { + const bodyHeight = this.currentBodyHeight; + if (bodyHeight <= 0) { + return 0; + } + const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body + const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; + const actionsMargin = actionsHeight > 0 ? 8 : 0; + return Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin); + } + /** Layout the SplitView to fill available body space. */ private layoutSplitView(): void { if (!this.splitView || !this.splitViewContainer) { return; } - const bodyHeight = this.currentBodyHeight; - if (bodyHeight <= 0) { + const availableHeight = this.getSplitViewAvailableHeight(); + if (availableHeight <= 0) { return; } - const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body - const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; - const actionsMargin = actionsHeight > 0 ? 8 : 0; - const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin); this.splitViewContainer.style.height = `${availableHeight}px`; this.splitView.layout(availableHeight); + this.applyCIDefaultSize(); + } + + /** + * Re-assert the CI pane's default height (capped to a third of the split) after layout. + * This is where the split height is reliably known — the preferred height can otherwise be + * evaluated during wiring when the body height is still 0, yielding an uncapped fallback. + * Once the user drags a sash we back off and preserve their chosen size. + */ + private applyCIDefaultSize(): void { + if (!this.splitView || this.ciPaneUserResized || !this.computeCIPreferredHeight) { + return; + } + if (!this.ciStatusWidget?.visible || this.ciStatusWidget.collapsed) { + return; + } + const preferred = this.computeCIPreferredHeight(); + if (this.splitView.getViewSize(2) !== preferred) { + this.splitView.resizeView(2, preferred); + } } /** @@ -1061,6 +1110,7 @@ export class ChangesViewPane extends ViewPane { paneIndex: number, headerHeight: number, getPreferredHeight: () => number, + onDidBecomeVisible?: () => void, ): void { let savedPaneHeight = getPreferredHeight(); @@ -1091,6 +1141,7 @@ export class ChangesViewPane extends ViewPane { if (visible !== isCurrentlyVisible) { this.splitView.setViewVisible(paneIndex, visible); if (visible && !widget.collapsed) { + onDidBecomeVisible?.(); savedPaneHeight = getPreferredHeight(); this.splitView.resizeView(paneIndex, savedPaneHeight); } diff --git a/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css index 04f98c64f7b6b4..34b544301b40b9 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css @@ -7,20 +7,6 @@ display: inline-flex; gap: 4px; - .changes-summary-lines-added, - .changes-summary-lines-removed { - position: relative; - display: inline-grid; - overflow: hidden; - justify-items: start; - - div { - grid-area: 1 / 1; - display: block; - will-change: transform, opacity; - } - } - .changes-summary-lines-added { color: var(--vscode-chat-linesAddedForeground); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 716dba2249fb7e..f4aeadaaa8c510 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -17,7 +17,7 @@ import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/co import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsConfig, openChatPreviewFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionStatus, TURN_CHANGES_CHANGESET_ID } from '../../../services/sessions/common/session.js'; +import { SessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/browser/changesActions.js'; import './media/sessionChatInputToolbar.css'; @@ -33,20 +33,17 @@ const EMPTY_TURN_DATA: ITurnData = { stats: EMPTY_DIFF_STATS, previewFiles: [] } /** * Compute the current turn's diff stats and previewable files from the session's - * "Last Turn Changes" changeset ({@link TURN_CHANGES_CHANGESET_ID}). Files are - * classified as created vs. edited with the same rules as the Changes view (an - * addition has no original; a deletion has no modified resource). Created files - * are listed before edited ones so the primary (first) file is the first created - * one, falling back to the first edited one. Returns {@link EMPTY_TURN_DATA} when - * the session exposes no turn changeset (e.g. before its first turn). + * last-turn changes ({@link ISession.lastTurnChanges}), which the provider + * derives from the live output stream. Files are classified as created vs. + * edited with the same rules as the Changes view (an addition has no original; a + * deletion has no modified resource). Created files are listed before edited ones + * so the primary (first) file is the first created one, falling back to the first + * edited one. Returns {@link EMPTY_TURN_DATA} when the session exposes no + * last-turn changes (e.g. before its first turn, or a provider that can't + * determine them). */ function computeTurnData(session: IActiveSession, reader: IReader): ITurnData { - const turnChangeset = session.changesets.read(reader)?.find(cs => cs.id === TURN_CHANGES_CHANGESET_ID); - if (!turnChangeset) { - return EMPTY_TURN_DATA; - } - - const changes = turnChangeset.changes.read(reader); + const changes = session.lastTurnChanges?.read(reader) ?? []; let insertions = 0, deletions = 0; const created: IPreviewFile[] = []; @@ -82,8 +79,8 @@ function turnDataEqual(a: ITurnData, b: ITurnData): boolean { * session status as clickable pills (see {@link ChatTurnPillsWidget}). Only shown * for agent host sessions while a turn is actively in progress; once the turn * completes the pills disappear here and reappear inside the completed response. - * The pills are scoped to the session's "Last Turn Changes" changeset so they - * reflect only what the most recent request produced. + * The pills are scoped to the session's last-turn changes so they reflect only + * what the most recent request produced. */ export class SessionChatInputToolbar extends Disposable { diff --git a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts index 2b188815ab74b2..3e0c6150dee9a0 100644 --- a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts +++ b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts @@ -16,12 +16,13 @@ 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 { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; -import { SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; +import { SessionHasWorkspaceContext, IsQuickChatSessionContext } 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'; @@ -45,7 +46,7 @@ class OpenFilesViewAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: -10, - when: SessionHasWorkspaceContext + when: ContextKeyExpr.and(SessionHasWorkspaceContext, IsQuickChatSessionContext.negate()) }, }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index e532f9ea86c66b..4e1fdf6e515208 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -96,6 +96,29 @@ export function createActiveSessionSubscriptionObs( }); } +/** + * Selects the URI of the session's most recently modified chat — the one that + * holds the session's "last turn". Falls back to the session's default chat (or + * the synthesized default chat URI) when the state is absent/errored or no chat + * is more recent. + * + * Shared by {@link AgentHostLastTurnChangeset} and the output-stream-derived + * last-turn changes so the "Last Turn Changes" changeset and the chat input + * status pills always resolve the same chat. + */ +export function selectMostRecentChatUri(sessionState: SessionState | Error | undefined | null, sessionUri: URI): URI { + if (!sessionState || sessionState instanceof Error) { + return URI.parse(buildDefaultChatUri(sessionUri)); + } + + // `modifiedAt` is ISO 8601, so lexicographic compare is chronological. + const mostRecentChat = sessionState.chats.reduce( + (best, c) => !best || c.modifiedAt > best.modifiedAt ? c : best, + undefined + ); + return URI.parse(mostRecentChat?.resource ?? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri)); +} + function toSessionChangesetOperationScope(scope: ChangesetOperationScope): SessionChangesetOperationScope { switch (scope) { case ChangesetOperationScope.Changeset: return SessionChangesetOperationScope.Changeset; @@ -354,16 +377,7 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { const mostRecentChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { const sessionState = sessionStateObs.read(reader).read(reader); - if (!sessionState || sessionState instanceof Error) { - return URI.parse(buildDefaultChatUri(sessionUri)); - } - - // `modifiedAt` is ISO 8601, so lexicographic compare is chronological. - const mostRecentChat = sessionState.chats.reduce( - (best, c) => !best || c.modifiedAt > best.modifiedAt ? c : best, - undefined - ); - return URI.parse(mostRecentChat?.resource ?? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri)); + return selectMostRecentChatUri(sessionState, sessionUri); }); const chatStateObs = createActiveSessionSubscriptionObs( diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts index 5c8877a7dfb967..760f9d19a7be96 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts @@ -21,8 +21,9 @@ import { ToolCallStatus, ToolResultContentType, } from '../../../../../platform/agentHost/common/state/sessionState.js'; -import { ISessionFile, ISessionWorkspace, SessionFileOperation } from '../../../../services/sessions/common/session.js'; -import { createActiveSessionSubscriptionObs } from './agentHostSessionChangesets.js'; +import { IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionFile, ISessionFileChange, ISessionWorkspace, SessionFileOperation, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; +import { createActiveSessionSubscriptionObs, selectMostRecentChatUri } from './agentHostSessionChangesets.js'; import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; /** @@ -38,33 +39,62 @@ export interface IParsedFileEdit { readonly beforeUri?: URI; /** Before-content URI, used to render a diff for modified files. */ readonly beforeContentUri?: URI; + /** Lines added by this edit, from the protocol diff metadata (0 when absent). */ + readonly insertions: number; + /** Lines removed by this edit, from the protocol diff metadata (0 when absent). */ + readonly deletions: number; } /** - * Builds the observable list of files that were created, edited or deleted - * **outside** the session's workspace folders during the session. + * The observable outputs derived from an agent-host session's live output + * stream (its chat-state turns). Both are parsed from the same underlying + * per-chat subscriptions so the stream is only walked once. + */ +export interface ISessionOutputObs { + /** + * Files created, edited or deleted **outside** the session workspace folders + * during the session (e.g. config files in the user's home directory), + * reduced across every chat and turn. + */ + readonly externalFiles: IObservable; + /** + * File changes produced by the session's **last turn** only — the in-progress + * turn of the most recently modified chat, or (when idle) that chat's last + * completed turn. Used by the chat input status pills to reflect just what the + * most recent request produced. + */ + readonly lastTurnChanges: IObservable; +} + +/** + * Builds the observable outputs derived from a session's live output stream. + * + * The data is parsed from the agent-host chat-state turns: each turn's response + * parts are scanned for tool calls, and each tool call's file-edit results (and + * pending edits) are collected. Two views are produced from the same parse: * - * The data is parsed from the agent-host chat-state turns: each turn's - * response parts are scanned for tool calls, and each tool call's file-edit - * results (and pending edits) are collected. The resulting edits are reduced - * per file so that a file first created and then edited is reported as - * {@link SessionFileOperation.Created}, while a deleted file is removed from - * the list entirely. + * - {@link ISessionOutputObs.externalFiles}: edits reduced per file across all + * chats/turns so that a file first created and then edited is reported as + * {@link SessionFileOperation.Created} while a deleted file is removed; only + * files outside the workspace folders are kept. + * - {@link ISessionOutputObs.lastTurnChanges}: the last turn's edits reduced per + * file into {@link ISessionFileChange | changes} (with diff stats), mirroring + * the "Last Turn Changes" changeset without depending on it. * * Computation only happens for the active, non-archived session: archived * sessions never open a live chat-state subscription, so no parsing work is * done for them. */ -export function createSessionFilesObs( +export function createSessionOutputObs( sessionUri: URI, options: IAgentHostAdapterOptions, isActiveSessionObs: IObservable, isArchivedObs: IObservable, workspaceObs: IObservable, -): IObservable { +): ISessionOutputObs { const mapDiffUri = options.mapDiffUri; - // Session files are only computed for the active, non-archived session. The + // Session output is only computed for the active, non-archived session. The // subscriptions and parsing below are all gated on this so an archived // session does no work. const enabledObs = derivedOpts({ equalsFn: (a, b) => a === b }, reader => @@ -99,14 +129,25 @@ export function createSessionFilesObs( return [...uris.values()]; }); + // The chat that holds the session's "last turn": the most recently modified + // chat (its in-progress turn, or last completed turn when idle). Mirrors the + // "Last Turn Changes" changeset's chat selection via the shared helper. + const mostRecentChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { + if (!enabledObs.read(reader)) { + return URI.parse(buildDefaultChatUri(sessionUri)); + } + const sessionState = sessionStateObs.read(reader).read(reader); + return selectMostRecentChatUri(sessionState, sessionUri); + }); + // One observable of parsed edits per chat, subscribing to that chat's state. // // Completed turns (`chatState.turns`) are immutable once finalized, so each // is parsed exactly once and memoized by turn id in a closure-scoped cache // that lives for the chat's lifetime. Only the in-progress `activeTurn` is // re-parsed on every streamed delta, making delta updates O(active turn) - // rather than O(all turns). The `equalsFn` ensures the downstream reducer - // only re-runs when the parsed edits actually change (e.g. not for markdown + // rather than O(all turns). The `equalsFn` ensures the downstream reducers + // only re-run when the parsed edits actually change (e.g. not for markdown // or reasoning deltas that carry no file edits). const editsPerChatObs = mapObservableArrayCached(undefined, chatUrisObs, (chatUri) => { const chatStateObs = createActiveSessionSubscriptionObs( @@ -116,26 +157,39 @@ export function createSessionFilesObs( constObservable(chatUri), ); const parse = createIncrementalChatFileEditsParser(mapDiffUri); - return derivedOpts({ equalsFn: parsedFileEditsEqual }, reader => { + return derivedOpts({ equalsFn: (a, b) => isEqual(a.chatUri, b.chatUri) && chatFileEditsEqual(a, b) }, reader => { const chatState = chatStateObs.read(reader).read(reader); if (!chatState || chatState instanceof Error) { - return []; + return { chatUri, allEdits: [], lastTurnEdits: [] }; } - return parse(chatState); + return { chatUri, ...parse(chatState) }; }); }, chatUri => chatUri.toString()); - return derivedOpts({ equalsFn: sessionFilesEqual }, reader => { + const externalFiles = derivedOpts({ equalsFn: sessionFilesEqual }, reader => { const workspace = workspaceObs.read(reader); const folderRoots = (workspace?.folders ?? []).map(f => f.workingDirectory); const allEdits: IParsedFileEdit[] = []; for (const chatEditsObs of editsPerChatObs.read(reader)) { - allEdits.push(...chatEditsObs.read(reader)); + allEdits.push(...chatEditsObs.read(reader).allEdits); } return reduceSessionFiles(allEdits, folderRoots); }); + + const lastTurnChanges = derivedOpts({ equalsFn: sessionFileChangesEqual }, reader => { + const mostRecentChatUri = mostRecentChatUriObs.read(reader); + for (const chatEditsObs of editsPerChatObs.read(reader)) { + const chatEdits = chatEditsObs.read(reader); + if (isEqual(chatEdits.chatUri, mostRecentChatUri)) { + return reduceTurnChanges(chatEdits.lastTurnEdits); + } + } + return []; + }); + + return { externalFiles, lastTurnChanges }; } /** @@ -158,14 +212,32 @@ export interface IFileEditChatState { export type ParseTurnFileEdits = (responseParts: Turn['responseParts']) => readonly IParsedFileEdit[]; /** - * Creates a stateful parser that turns a chat state into its ordered list of - * file edits, **parsing each completed turn at most once**. + * The file edits parsed from a chat's output stream, split into the full set + * (across all turns) and the last turn's edits alone. + */ +export interface IChatFileEdits { + /** All file edits across the chat's turns, in stream order. */ + readonly allEdits: readonly IParsedFileEdit[]; + /** + * File edits of the chat's last turn only — the in-progress `activeTurn` when + * present, otherwise the most recently completed turn. + */ + readonly lastTurnEdits: readonly IParsedFileEdit[]; +} + +/** + * Creates a stateful parser that turns a chat state into its file edits, + * **parsing each completed turn at most once**. * * Completed turns (`chatState.turns`) are immutable once finalized, so each is * parsed once and memoized by turn id in the returned closure. Only the * in-progress `activeTurn` is re-parsed on every call, making streamed-delta * updates O(active turn) rather than O(all turns). * + * Returns both the full edit list (for session-wide reductions) and the last + * turn's edits alone (for turn-scoped reductions); the active turn is parsed + * once and reused for both. + * * @param mapDiffUri Optional URI mapper applied while parsing. * @param parseTurn Per-turn parse function. Defaults to {@link parseResponseParts}; * injectable so tests can observe how often each turn is (re)parsed. @@ -173,11 +245,11 @@ export type ParseTurnFileEdits = (responseParts: Turn['responseParts']) => reado export function createIncrementalChatFileEditsParser( mapDiffUri?: (uri: URI) => URI, parseTurn: ParseTurnFileEdits = responseParts => parseResponseParts(responseParts, mapDiffUri), -): (chatState: IFileEditChatState) => IParsedFileEdit[] { +): (chatState: IFileEditChatState) => IChatFileEdits { const completedTurnCache = new Map(); - return (chatState: IFileEditChatState): IParsedFileEdit[] => { - const edits: IParsedFileEdit[] = []; + return (chatState: IFileEditChatState): IChatFileEdits => { + const allEdits: IParsedFileEdit[] = []; const turns: readonly IFileEditTurn[] = chatState.turns ?? []; // Evict cache entries for turns that are no longer completed (e.g. a turn @@ -197,15 +269,24 @@ export function createIncrementalChatFileEditsParser( completedTurnCache.set(turn.id, parsed); } if (parsed.length > 0) { - edits.push(...parsed); + allEdits.push(...parsed); } } + // The last turn is the in-progress one when streaming, else the most + // recently completed turn. The active turn is parsed a single time and + // reused for both `allEdits` and `lastTurnEdits`. + let lastTurnEdits: readonly IParsedFileEdit[]; if (chatState.activeTurn) { - edits.push(...parseTurn(chatState.activeTurn.responseParts)); + lastTurnEdits = parseTurn(chatState.activeTurn.responseParts); + allEdits.push(...lastTurnEdits); + } else if (turns.length > 0) { + lastTurnEdits = completedTurnCache.get(turns[turns.length - 1].id) ?? []; + } else { + lastTurnEdits = []; } - return edits; + return { allEdits, lastTurnEdits }; }; } @@ -262,6 +343,8 @@ function parseFileEdit(fileEdit: FileEdit, mapDiffUri?: (uri: URI) => URI): IPar afterUri: map(normalized.afterUri), beforeUri: map(normalized.beforeUri), beforeContentUri: map(normalized.beforeContentUri), + insertions: fileEdit.diff?.added ?? 0, + deletions: fileEdit.diff?.removed ?? 0, }; } @@ -356,6 +439,113 @@ export function reduceSessionFiles(edits: readonly IParsedFileEdit[], folderRoot return files; } +interface IMutableTurnChange { + uri: URI; + modifiedUri: URI | undefined; + originalUri: URI | undefined; + /** Whether the file was created during the turn (kept across later edits). */ + created: boolean; + insertions: number; + deletions: number; +} + +/** + * Reduces a single turn's parsed file edits into one {@link ISessionFileChange} + * per file, aggregating diff stats. Mirrors the "Last Turn Changes" changeset + * so consumers (e.g. the chat input status pills) can reflect the last turn + * straight from the output stream. + * + * Rules: + * - Repeated edits to the same file collapse into a single change whose + * insertions/deletions are the sum of the individual edits. + * - A file created during the turn stays a creation (no original side) even if + * edited afterwards. + * - A create/edit followed by a delete in the same turn nets out; a pre-existing + * file deleted during the turn is surfaced as a deletion (no modified side to + * preview) but still counted in the stats. + * - Renames drop the source and surface the target as an edit of its + * before-content, matching the changeset's classification. + */ +export function reduceTurnChanges(edits: readonly IParsedFileEdit[]): IChatSessionFileChange2[] { + const byUri = new Map(); + + const setCreated = (uri: URI, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + const existing = byUri.get(key); + if (existing) { + existing.created = true; + existing.modifiedUri = uri; + existing.originalUri = undefined; + existing.insertions += insertions; + existing.deletions += deletions; + return; + } + byUri.set(key, { uri, modifiedUri: uri, originalUri: undefined, created: true, insertions, deletions }); + }; + + const setModified = (uri: URI, originalUri: URI | undefined, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + const existing = byUri.get(key); + if (existing) { + existing.insertions += insertions; + existing.deletions += deletions; + if (!existing.created) { + // Keep the earliest known original content for the diff. + existing.originalUri = existing.originalUri ?? originalUri; + } + return; + } + byUri.set(key, { uri, modifiedUri: uri, originalUri, created: false, insertions, deletions }); + }; + + const setDeleted = (uri: URI, originalUri: URI | undefined, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + if (byUri.has(key)) { + // Created/edited earlier in the same turn and now deleted: nets out. + byUri.delete(key); + return; + } + // Pre-existing file deleted during the turn: no modified side to preview. + byUri.set(key, { uri, modifiedUri: undefined, originalUri, created: false, insertions, deletions }); + }; + + for (const edit of edits) { + switch (edit.kind) { + case FileEditKind.Create: + if (edit.afterUri) { + setCreated(edit.afterUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Edit: + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Delete: + if (edit.beforeUri) { + setDeleted(edit.beforeUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Rename: + if (edit.beforeUri) { + byUri.delete(getComparisonKey(edit.beforeUri)); + } + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + } + } + + return [...byUri.values()].map(c => ({ + uri: c.uri, + modifiedUri: c.modifiedUri, + originalUri: c.originalUri, + insertions: c.insertions, + deletions: c.deletions, + } satisfies IChatSessionFileChange2)); +} + function sessionFilesEqual(a: readonly ISessionFile[], b: readonly ISessionFile[]): boolean { if (a.length !== b.length) { return false; @@ -371,9 +561,10 @@ function sessionFilesEqual(a: readonly ISessionFile[], b: readonly ISessionFile[ } /** - * Structural equality over parsed edits, used as the per-chat observable's - * `equalsFn` so streamed deltas that carry no file-edit change (e.g. markdown - * or reasoning content) don't re-run the downstream reducer. + * Structural equality over parsed edits, used (via {@link chatFileEditsEqual}) + * as the per-chat observable's `equalsFn` so streamed deltas that carry no + * file-edit change (e.g. markdown or reasoning content) don't re-run the + * downstream reducers. */ function parsedFileEditsEqual(a: readonly IParsedFileEdit[], b: readonly IParsedFileEdit[]): boolean { if (a === b) { @@ -384,6 +575,8 @@ function parsedFileEditsEqual(a: readonly IParsedFileEdit[], b: readonly IParsed } for (let i = 0; i < a.length; i++) { if (a[i].kind !== b[i].kind + || a[i].insertions !== b[i].insertions + || a[i].deletions !== b[i].deletions || !isEqual(a[i].afterUri, b[i].afterUri) || !isEqual(a[i].beforeUri, b[i].beforeUri) || !isEqual(a[i].beforeContentUri, b[i].beforeContentUri)) { @@ -392,3 +585,8 @@ function parsedFileEditsEqual(a: readonly IParsedFileEdit[], b: readonly IParsed } return true; } + +/** Structural equality over a chat's parsed edits (full set and last turn). */ +function chatFileEditsEqual(a: IChatFileEdits, b: IChatFileEdits): boolean { + return parsedFileEditsEqual(a.allEdits, b.allEdits) && parsedFileEditsEqual(a.lastTurnEdits, b.lastTurnEdits); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 2ff379deb2c8f2..161fe4b81f1a06 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -26,7 +26,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType, type ProgressParams } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, withSessionWorkspaceless, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -53,7 +53,7 @@ import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/ import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; import { mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; -import { createSessionFilesObs } from './agentHostSessionFiles.js'; +import { createSessionOutputObs } from './agentHostSessionFiles.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -78,6 +78,13 @@ interface ISerializedSessionMetadata { /** @deprecated Legacy name for `isArchived`. */ readonly isDone?: boolean; readonly project?: { readonly uri: string; readonly displayName: string }; + /** + * Whether the session is a workspace-less quick chat. Persisted because the + * adapter's session-kind is fixed at construction from this tag (see + * {@link AgentHostSessionAdapter}); dropping it on restore would leak the + * host's scratch dir as a workspace folder. + */ + readonly workspaceless?: boolean; } function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { @@ -90,6 +97,7 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad isRead: meta.isRead, isArchived: meta.isArchived, project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + workspaceless: readSessionWorkspaceless(meta._meta) || undefined, }; } @@ -104,6 +112,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta isRead: raw.isRead, isArchived: raw.isArchived ?? raw.isDone, project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + ...(raw.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }; } catch { return undefined; @@ -393,6 +402,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly changes: IObservable; readonly changesets: ISettableObservable; readonly externalChanges: IObservable; + readonly lastTurnChanges: IObservable; readonly modelId: ISettableObservable; modelSelection: ModelSelection | undefined; readonly mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -671,11 +681,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // changeset. this.changes = this._createChangesObs(); - // Files created/edited/deleted outside the workspace, parsed from the - // chat-state turns. Computed lazily from the same active-session - // subscriptions used for changes. + // Files created/edited/deleted outside the workspace, plus the last turn's + // changes, parsed from the chat-state turns. Computed lazily from the same + // active-session subscriptions used for changes. const sessionUri = AgentSession.uri(this.sessionType, rawId); - this.externalChanges = createSessionFilesObs(sessionUri, this._options, this.isActiveSessionObs, this.isArchived, this.workspace); + const sessionOutput = createSessionOutputObs(sessionUri, this._options, this.isActiveSessionObs, this.isArchived, this.workspace); + this.externalChanges = sessionOutput.externalFiles; + this.lastTurnChanges = sessionOutput.lastTurnChanges; const mainChat: IChat = { resource: this.resource, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts index e8834a51029ad7..7b940df991efcd 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts @@ -21,6 +21,7 @@ import { IParsedFileEdit, parseResponseParts, reduceSessionFiles, + reduceTurnChanges, } from '../../browser/agentHostSessionFiles.js'; // ── Protocol fixture helpers ──────────────────────────────────────────────── @@ -62,28 +63,31 @@ function pendingConfirmationToolCallPart(items: object[]): ResponsePart { }); } -function createEdit(uri: string): object { - return { type: ToolResultContentType.FileEdit, after: { uri, content: { uri: `${uri}.after` } } }; +function createEdit(uri: string, diff?: { added?: number; removed?: number }): object { + return { type: ToolResultContentType.FileEdit, after: { uri, content: { uri: `${uri}.after` } }, diff }; } -function editEdit(uri: string): object { +function editEdit(uri: string, diff?: { added?: number; removed?: number }): object { return { type: ToolResultContentType.FileEdit, before: { uri, content: { uri: `${uri}.before` } }, after: { uri, content: { uri: `${uri}.after` } }, + diff, }; } -function deleteEdit(uri: string): object { - return { type: ToolResultContentType.FileEdit, before: { uri, content: { uri: `${uri}.before` } } }; +function deleteEdit(uri: string, diff?: { added?: number; removed?: number }): object { + return { type: ToolResultContentType.FileEdit, before: { uri, content: { uri: `${uri}.before` } }, diff }; } -function parsedEdit(kind: FileEditKind, uris: { after?: string; before?: string; beforeContent?: string }): IParsedFileEdit { +function parsedEdit(kind: FileEditKind, uris: { after?: string; before?: string; beforeContent?: string }, diff?: { insertions?: number; deletions?: number }): IParsedFileEdit { return { kind, afterUri: uris.after ? URI.file(uris.after) : undefined, beforeUri: uris.before ? URI.file(uris.before) : undefined, beforeContentUri: uris.beforeContent ? URI.file(uris.beforeContent) : undefined, + insertions: diff?.insertions ?? 0, + deletions: diff?.deletions ?? 0, }; } @@ -138,7 +142,7 @@ suite('agentHostSessionFiles', () => { ); }); - test('incremental parser keeps completed-turn edits while a new turn streams', () => { + test('incremental parser keeps completed-turn edits while a new turn streams and tracks the last turn', () => { const parse = createIncrementalChatFileEditsParser(); const t1Parts = [completedToolCallPart([createEdit('file:///a.txt')])]; @@ -152,12 +156,19 @@ suite('agentHostSessionFiles', () => { assert.deepStrictEqual( { - first: first.map(e => e.afterUri?.toString()), - streaming: streaming.map(e => e.afterUri?.toString()), + firstAll: first.allEdits.map(e => e.afterUri?.toString()), + firstLastTurn: first.lastTurnEdits.map(e => e.afterUri?.toString()), + streamingAll: streaming.allEdits.map(e => e.afterUri?.toString()), + streamingLastTurn: streaming.lastTurnEdits.map(e => e.afterUri?.toString()), }, { - first: ['file:///a.txt'], - streaming: ['file:///a.txt', 'file:///b.txt'], + // When idle, the last turn is the most recently completed turn. + firstAll: ['file:///a.txt'], + firstLastTurn: ['file:///a.txt'], + // While streaming, `allEdits` unions every turn but `lastTurnEdits` + // reflects only the in-progress turn. + streamingAll: ['file:///a.txt', 'file:///b.txt'], + streamingLastTurn: ['file:///b.txt'], }, ); }); @@ -230,4 +241,58 @@ suite('agentHostSessionFiles', () => { assert.deepStrictEqual(files, []); }); + + test('reduceTurnChanges collapses repeated edits per file and aggregates diff stats', () => { + const edits: IParsedFileEdit[] = [ + // created then edited → one created change, summed diffs, no original side + parsedEdit(FileEditKind.Create, { after: '/repo/new.ts' }, { insertions: 10 }), + parsedEdit(FileEditKind.Edit, { after: '/repo/new.ts', beforeContent: '/repo/new.ts.before' }, { insertions: 3, deletions: 1 }), + // pre-existing file edited twice → one modified change keeping the first original + parsedEdit(FileEditKind.Edit, { after: '/repo/existing.ts', beforeContent: '/repo/existing.ts.before' }, { insertions: 2, deletions: 4 }), + parsedEdit(FileEditKind.Edit, { after: '/repo/existing.ts', beforeContent: '/repo/existing.ts.before2' }, { insertions: 1 }), + // pre-existing file deleted → surfaced as a deletion (no modified side) + parsedEdit(FileEditKind.Delete, { before: '/repo/gone.ts', beforeContent: '/repo/gone.ts.before' }, { deletions: 8 }), + ]; + + const changes = reduceTurnChanges(edits).map(c => ({ + uri: c.uri.path, + modified: c.modifiedUri?.path, + original: c.originalUri?.path, + insertions: c.insertions, + deletions: c.deletions, + })); + + assert.deepStrictEqual(changes, [ + { uri: '/repo/new.ts', modified: '/repo/new.ts', original: undefined, insertions: 13, deletions: 1 }, + { uri: '/repo/existing.ts', modified: '/repo/existing.ts', original: '/repo/existing.ts.before', insertions: 3, deletions: 4 }, + { uri: '/repo/gone.ts', modified: undefined, original: '/repo/gone.ts.before', insertions: 0, deletions: 8 }, + ]); + }); + + test('reduceTurnChanges nets out a file created and then deleted in the same turn', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Create, { after: '/repo/scratch.tmp' }, { insertions: 5 }), + parsedEdit(FileEditKind.Delete, { before: '/repo/scratch.tmp' }), + ]; + + assert.deepStrictEqual(reduceTurnChanges(edits), []); + }); + + test('reduceTurnChanges reports a rename as an edit of the target and drops the source', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Rename, { before: '/repo/old.ts', after: '/repo/renamed.ts', beforeContent: '/repo/old.ts.before' }, { insertions: 1, deletions: 2 }), + ]; + + const changes = reduceTurnChanges(edits).map(c => ({ + uri: c.uri.path, + modified: c.modifiedUri?.path, + original: c.originalUri?.path, + insertions: c.insertions, + deletions: c.deletions, + })); + + assert.deepStrictEqual(changes, [ + { uri: '/repo/renamed.ts', modified: '/repo/renamed.ts', original: '/repo/old.ts.before', insertions: 1, deletions: 2 }, + ]); + }); }); 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 6fdf31a2ae7fff..607def92a2da66 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 @@ -1077,6 +1077,36 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrated quick chat stays workspace-less after reload despite a scratch working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Regression #324581: a committed quick chat persisted into the startup + // cache carries a scratch cwd. The adapter's session-kind is fixed at + // construction from `_meta.workspaceless`, so the tag must survive the + // serialize/deserialize round-trip — otherwise the restored session + // leaks the scratch dir as a workspace folder. + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [ + createSession('quick-cached', { + summary: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-cached'), + quickChat: true, + }), + ]); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === 'quick-cached'); + assert.deepStrictEqual({ + workspace: session?.workspace.get(), + isQuickChat: session?.isQuickChat?.get(), + }, { + workspace: undefined, + isQuickChat: true, + }); + })); + test('reconciles hydrated sessions against the authoritative list, pruning stale entries', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); await persistCachedSessions(disposables, storageService, [createSession('stale-1', { summary: 'Stale' })]); diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts index d43d2856e51a48..b87dd8520eab24 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts @@ -85,6 +85,7 @@ export class BlockedSessionsList extends Disposable { onSessionOpen: options.onSessionOpen, approvalModel: options.approvalModel, approvalRowMaxLines: BLOCKED_LIST_APPROVAL_ROW_MAX_LINES, + toolbarActions: false, })); this._register(this._list.onDidChangeContentHeight(() => { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 3f4c782ca19dcf..f4b8d78d423c16 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -113,11 +113,14 @@ .agent-sessions-header-actions { display: flex; align-items: center; - gap: 4px; margin-left: auto; flex-shrink: 0; } + .agent-sessions-header-actions .actions-container { + gap: 4px; + } + /* Inline search input — replaces label + actions when active */ .agent-sessions-find-widget-container { flex: 1; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index fcd9610aa6974f..d6b545818a3561 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -18,7 +18,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { autorun, derived, IObservable, IReader, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { URI } from '../../../../base/common/uri.js'; @@ -26,7 +26,7 @@ import { AnchorAlignment, AnchorPosition } from '../../../../base/common/layout. import { ThemeIcon } from '../../../../base/common/themables.js'; import { IContextViewService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; @@ -71,13 +71,12 @@ const enum RequiresInputKind { * - Kind icon at the beginning (provider type icon) * - Repository folder name and active branch/worktree name when available * - * When the primary side bar is hidden and at least one session is blocked - * (needs input, has failing CI checks, or has unresolved pull request comments), - * the widget instead adopts an orange "N sessions require input" state and, on - * click, reveals those sessions as a flat list in a dropdown anchored below the - * command center box. A short blink animation plays whenever a new session - * becomes blocked. In every other case it behaves as the active-session pill and - * opens the sessions picker on click. + * When at least one session is blocked (needs input, has failing CI checks, or + * has unresolved pull request comments), the widget instead adopts an orange + * "N sessions require input" state and, on click, reveals those sessions as a + * flat list in a dropdown anchored below the command center box. A short blink + * animation plays whenever a new session becomes blocked. In every other case it + * behaves as the active-session pill and opens the sessions picker on click. * * Session actions (changes, terminal, etc.) are rendered via the * SessionTitleActions menu toolbar next to this widget. @@ -97,16 +96,12 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { private _isRendering = false; /** - * Last observed blocked-session count, tracked on every render regardless of - * side-bar visibility. Because the count is captured even while the - * requires-input state is hidden (side bar visible), toggling the side bar to - * reveal the indicator leaves the count unchanged and never blinks. + * Last observed blocked-session count, tracked on every render. Used to detect + * when a *new* blocked session pushes the count up so the attention blink only + * fires for genuinely new blocks. */ private _lastBlockedCount = 0; - /** Reactive primary-side-bar visibility. */ - private readonly _sidebarVisible: IObservable; - /** * Blocked sessions that are NOT currently visible on screen. A session the * user can already see doesn't need the titlebar indicator or a dropdown row, @@ -174,12 +169,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // test seam so fixtures can supply a preset instance. this._blockedSessionsModel = blockedSessions ?? this._register(this.instantiationService.createInstance(BlockedSessions)); - this._sidebarVisible = observableFromEvent( - this, - this.layoutService.onDidChangePartVisibility, - () => this.layoutService.isVisible(Parts.SIDEBAR_PART), - ); - // A session that is currently visible on screen is not treated as blocked: // exclude visible sessions from the requires-input indicator and the dropdown. // The blocked-sessions feature is only enabled outside of stable builds. @@ -265,11 +254,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._render(); })); - // Re-render when the set of blocked sessions or the side bar visibility changes; - // both feed the "N sessions require input" state. Keep an open dropdown in sync. + // Re-render when the set of blocked sessions changes; it feeds the + // "N sessions require input" state. Keep an open dropdown in sync. this._register(autorun(reader => { const blocked = this._blockedSessions.read(reader); - this._sidebarVisible.read(reader); this._sessionActionFeedback.approvedCount.read(reader); this._requiresInputKind.read(reader); if (this._openContextView && this._blockedList) { @@ -328,7 +316,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { try { const approvedCount = this._sessionActionFeedback.approvedCount.get(); const blockedCount = this._blockedSessions.get().length; - const requiresInput = blockedCount > 0 && !this._sidebarVisible.get(); + const requiresInput = blockedCount > 0; // The transient "Approved N sessions" confirmation takes precedence over the // requires-input state while it is showing. @@ -337,9 +325,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // The attention blink fires only when a *new* blocked session pushes the // count up while the requires-input state is shown — including the very first - // one. The count is tracked even while the state is hidden (side bar visible), - // so merely revealing the indicator by hiding the side bar (count unchanged) - // never blinks. + // one. const previousBlockedCount = this._lastBlockedCount; this._lastBlockedCount = blockedCount; const shouldBlink = showRequiresInput && blockedCount > previousBlockedCount; @@ -365,8 +351,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._lastRenderState = renderState; // Close the open blocked-sessions dropdown only when there are no blocked - // sessions left to show (or the requires-input UI no longer applies, e.g. - // the side bar became visible). Note this keys off `requiresInput`, not + // sessions left to show. Note this keys off `requiresInput`, not // `showRequiresInput`: approving a session shows the transient green state // (suppressing `showRequiresInput`) but the dropdown must stay open while // other sessions remain blocked — it just drops the approved row. @@ -635,7 +620,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { * sessions when the requires-input state applies, otherwise the sessions picker. */ private _activateDefaultAction(): void { - const requiresInput = this._blockedSessions.get().length > 0 && !this._sidebarVisible.get(); + const requiresInput = this._blockedSessions.get().length > 0; if (requiresInput) { this._toggleBlockedSessions(); } else { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 61fa8a3a6027a6..51f8f344b3607f 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -279,7 +279,7 @@ interface ISessionItemTemplate { readonly statusIcon: SessionStatusIcon; readonly title: HighlightedLabel; readonly titleContainer: HTMLElement; - readonly titleToolbar: MenuWorkbenchToolBar; + readonly titleToolbar: MenuWorkbenchToolBar | undefined; readonly detailsRow: HTMLElement; readonly approvalRow: HTMLElement; readonly approvalLabel: HTMLElement; @@ -319,7 +319,7 @@ class SessionItemRenderer implements ITreeRenderer = this._onDidApproveSession.event; constructor( - private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; isInChatsSection: (session: ISession) => boolean; showHover: boolean; approvalRowMaxLines: number }, + private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; isInChatsSection: (session: ISession) => boolean; showHover: boolean; approvalRowMaxLines: number; toolbarActions: boolean }, private readonly approvalModel: AgentSessionApprovalModel | undefined, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, @@ -373,11 +373,16 @@ class SessionItemRenderer implements ITreeRenderer accessor.get(IAgentSessionsService)); const sessionRenderer = new SessionItemRenderer( - { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), isInChatsSection: s => this._chatsSectionSessionIds.has(s.resource.toString()), showHover: true, approvalRowMaxLines: DEFAULT_APPROVAL_ROW_MAX_LINES }, + { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), isInChatsSection: s => this._chatsSectionSessionIds.has(s.resource.toString()), showHover: true, approvalRowMaxLines: DEFAULT_APPROVAL_ROW_MAX_LINES, toolbarActions: true }, approvalModel, instantiationService, contextKeyService, @@ -3237,6 +3244,12 @@ export interface ISessionsFlatListOptions { * blocked-sessions dropdown passes a larger value. */ readonly approvalRowMaxLines?: number; + /** + * Whether each session row renders its inline action toolbar (pin, mark as done, + * etc.). Defaults to `true`; set to `false` for surfaces where those actions + * don't apply (e.g. the blocked-sessions dropdown), which renders no toolbar. + */ + readonly toolbarActions?: boolean; } /** @@ -3293,6 +3306,7 @@ export class SessionsFlatList extends Disposable { showHover: this.options.showSessionHover ?? true, isInChatsSection: s => false, approvalRowMaxLines: this.options.approvalRowMaxLines ?? DEFAULT_APPROVAL_ROW_MAX_LINES, + toolbarActions: this.options.toolbarActions ?? true, }, approvalModel, instantiationService, diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index fd05f1177f6744..22696f05c2f4f4 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -263,6 +263,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get changesets() { return this._session.changesets; } get changes() { return this._session.changes; } get externalChanges() { return this._session.externalChanges; } + get lastTurnChanges() { return this._session.lastTurnChanges; } get modelId() { return this._activeChatModelId; } get mode() { return this._activeChatMode; } get loading() { return this._session.loading; } @@ -304,6 +305,7 @@ class ResourceOverrideSession implements ISession { get changes() { return this._session.changes; } get changesets() { return this._session.changesets; } get externalChanges() { return this._session.externalChanges; } + get lastTurnChanges() { return this._session.lastTurnChanges; } get modelId() { return this._session.modelId; } get mode() { return this._session.mode; } get loading() { return this._session.loading; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 1531c7a0db8e14..af368af88dfd8b 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -476,6 +476,14 @@ export interface ISession { * cannot determine this report an empty array (or omit the observable). */ readonly externalChanges?: IObservable; + /** + * File changes produced by the session's **last turn** only (as opposed to + * the cumulative session {@link changes}). Derived from the session's live + * output stream so consumers — e.g. the chat input status pills — can reflect + * just what the most recent request produced. Providers that cannot determine + * this omit the observable. + */ + readonly lastTurnChanges?: IObservable; /** Currently selected model identifier. */ readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; diff --git a/src/vs/workbench/browser/animatedCounterWidget.ts b/src/vs/workbench/browser/animatedCounterWidget.ts new file mode 100644 index 00000000000000..711572e018c0f4 --- /dev/null +++ b/src/vs/workbench/browser/animatedCounterWidget.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/animatedCounterWidget.css'; +import * as dom from '../../base/browser/dom.js'; +import { Throttler } from '../../base/common/async.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../base/common/observable.js'; +import { IAccessibilityService } from '../../platform/accessibility/common/accessibility.js'; + +export interface IAnimatedCounterWidgetOptions { + readonly prefix?: string; + readonly cssClassName?: string; + /** + * The direction of the animation when the count + * increases. The direction will be the opposite + * when the count decreases. + * */ + readonly direction?: 'topToBottom' | 'bottomToTop'; + readonly duration?: number; + readonly count: IObservable; +} + +/** + * A small widget that renders a number and animates transitions between values: + * the container width tweens as the number of digits changes and the outgoing / + * incoming digits slide in the configured direction. Respects reduced motion. + */ +export class AnimatedCounterWidget extends Disposable { + private _element: HTMLElement; + private _count: number | undefined; + private readonly _animationOptions: KeyframeAnimationOptions; + private readonly _updateThrottler = this._register(new Throttler()); + + constructor( + container: HTMLElement, + private readonly _options: IAnimatedCounterWidgetOptions, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService + ) { + super(); + + const { cssClassName, duration } = _options; + + this._element = cssClassName + ? dom.$(`div.monaco-animated-counter.${cssClassName}`) + : dom.$('div.monaco-animated-counter'); + + this._element.appendChild(dom.$(`div`)); + container.appendChild(this._element); + + this._animationOptions = { + duration: duration ?? 240, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'both', + } satisfies KeyframeAnimationOptions; + + this._register(autorun(reader => { + const count = this._options.count.read(reader); + this._updateThrottler.queue(() => this._update(count)); + })); + } + + private async _update(count: number | undefined): Promise { + if (!this._element || this._element.children.length === 0) { + return; + } + + const outgoingElement = this._element.children[0]; + + if (count === undefined) { + outgoingElement.textContent = ''; + this._count = undefined; + return; + } + + // Create incoming element + const incomingElementText = `${this._options.prefix ?? ''}${count}`; + + // Skip the animation when it is disabled (duration of 0) or when + // the user prefers reduced motion, just update the text content. + if (this._options.duration === 0 || this._accessibilityService.isMotionReduced()) { + outgoingElement.textContent = incomingElementText; + this._count = count; + return; + } + + // Measure the current width before adding the incoming element so + // that a change in the number of digits can be animated smoothly. + const previousWidth = this._element.getBoundingClientRect().width; + + const incomingElement = dom.$(`div`, undefined, incomingElementText); + this._element?.appendChild(incomingElement); + + // The incoming element is content-sized, so its width is the width the + // container will have once the outgoing element is removed. Animate the + // container between the two widths for both growing and shrinking digit + // counts. + const nextWidth = incomingElement.getBoundingClientRect().width; + + if (Math.abs(previousWidth - nextWidth) > 0.5) { + this._element.animate([ + { width: `${previousWidth}px` }, + { width: `${nextWidth}px` }, + ], this._animationOptions); + } + + const directionOption = this._options.direction ?? 'topToBottom'; + const directionTopBottom = directionOption === 'topToBottom' + ? count > (this._count ?? 0) + : count < (this._count ?? 0); + + const enterFrom = directionTopBottom ? '-100%' : '100%'; + const exitTo = directionTopBottom ? '100%' : '-100%'; + + incomingElement.animate([ + { transform: `translateY(${enterFrom})`, opacity: 0 }, + { transform: 'translateY(0)', opacity: 1 }, + ], this._animationOptions); + + const exit = outgoingElement.animate([ + { transform: 'translateY(0)', opacity: 1 }, + { transform: `translateY(${exitTo})`, opacity: 0 }, + ], this._animationOptions); + + await new Promise(resolve => { + let didCleanup = false; + + const cleanup = () => { + if (didCleanup) { + return; + } + + didCleanup = true; + this._count = count; + this._element?.removeChild(outgoingElement); + resolve(); + }; + + exit.addEventListener('cancel', cleanup); + exit.addEventListener('finish', cleanup); + }); + } +} diff --git a/src/vs/workbench/browser/media/animatedCounterWidget.css b/src/vs/workbench/browser/media/animatedCounterWidget.css new file mode 100644 index 00000000000000..6b96e4a59bca26 --- /dev/null +++ b/src/vs/workbench/browser/media/animatedCounterWidget.css @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Structural styles for the AnimatedCounterWidget. The stacked incoming/outgoing + value elements share a single grid cell so they can cross-fade and slide while + the container width tweens. Callers add their own class for color/typography. */ + +.monaco-animated-counter { + position: relative; + display: inline-grid; + overflow: hidden; + justify-items: start; +} + +.monaco-animated-counter > div { + grid-area: 1 / 1; + display: block; + will-change: transform, opacity; +} diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 0fe89b57236975..368a90a066c5f5 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2338,6 +2338,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { if (this.isMoveOperation(e, de.identifier.groupId, editor)) { sourceGroup.moveEditor(editor, this.groupView, { ...options, index: targetEditorIndex }); + + if (this.tabsModel instanceof UnstickyEditorGroupModel && this.groupView.isSticky(editor)) { + this.groupView.unstickEditor(editor); + } } else { sourceGroup.copyEditor(editor, this.groupView, { ...options, index: targetEditorIndex }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index c400fe1dbf7205..fa2d3f515a3c6e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -35,6 +35,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import product from '../../../../../platform/product/common/product.js'; import { GitHubPaths, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; @@ -451,7 +452,7 @@ abstract class OpenChatGlobalAction extends Action2 { if (!chatModeCheck) { return; } - chatWidget.input.setChatMode(switchToMode.id); + chatWidget.input.setChatMode(switchToMode.id, true, true); if (chatModeCheck.needToClearSession) { await commandService.executeCommand(ACTION_ID_NEW_CHAT); @@ -573,12 +574,10 @@ export abstract class ModeOpenChatGlobalAction extends OpenChatGlobalAction { export function registerChatActions() { /** * Returns the session URI to use when opening a brand-new chat editor, - * honoring the experimental {@link ChatConfiguration.EditorDefaultProvider} - * setting. Falls back to a new local session when the setting selects - * `local` or the chosen provider is unavailable. + * honoring the remembered harness preference and then the configured default. */ function getNewChatEditorSessionUri(accessor: ServicesAccessor): URI { - return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService)); + return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService)); } registerAction2(PrimaryOpenChatGlobalAction); @@ -1760,15 +1759,13 @@ export interface IClearEditingSessionConfirmationOptions { } /** - * Clears the current chat session and starts a new one, preserving - * the session type (e.g. Claude, Cloud, Background) for non-local sessions - * in the sidebar. + * Clears the current chat session and starts a new one using the shared + * new-session harness resolver. */ -export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType: string | undefined, configurationService: IConfigurationService, chatSessionsService: IChatSessionsService): Promise { +export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType: string | undefined, configurationService: IConfigurationService, chatSessionsService: IChatSessionsService, storageService: IStorageService): Promise { const currentResource = widget.viewModel?.model.sessionResource; - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService); const currentSessionType = currentResource ? getChatSessionType(currentResource) : undefined; - const newSessionType = sessionType ?? (currentSessionType === localChatSessionType && defaultType !== localChatSessionType ? defaultType : currentSessionType ?? defaultType); + const newSessionType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { explicitOverride: sessionType, currentSessionType }); if (isIChatViewViewContext(widget.viewContext) && newSessionType !== localChatSessionType) { // For the sidebar, we need to explicitly load a session with the same type const newResource = URI.from({ scheme: newSessionType, path: `/untitled-${generateUuid()}` }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts index 4f50a80996066f..366b044d9a6bb5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts @@ -7,6 +7,7 @@ import { Schemas } from '../../../../../base/common/network.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { getDefaultNewChatSessionResource } from '../../common/constants.js'; @@ -17,6 +18,7 @@ export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInpu const editorService = accessor.get(IEditorService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); if (!chatEditorInput) { const editorInput = editorService.activeEditor; @@ -28,7 +30,7 @@ export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInpu // Otherwise create a generic new chat editor. const resource = chatEditorInput.sessionResource && chatEditorInput.sessionResource.scheme !== Schemas.vscodeLocalChatSession ? chatEditorInput.sessionResource.with({ path: `/untitled-${generateUuid()}` }) - : getDefaultNewChatSessionResource(configurationService, chatSessionsService); + : getDefaultNewChatSessionResource(configurationService, chatSessionsService, storageService); // A chat editor can only be open in one group const identifier = editorService.findEditors(chatEditorInput.resource)[0]; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 7a4f4381060d6d..9709530790063b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -21,6 +21,7 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; @@ -347,7 +348,7 @@ class ToggleChatModeAction extends Action2 { isClaudeAgent }); - widget.input.setChatMode(switchToMode.id); + widget.input.setChatMode(switchToMode.id, true, true); if (chatModeCheck.needToClearSession) { await commandService.executeCommand(ACTION_ID_NEW_CHAT); @@ -942,6 +943,7 @@ class SendToNewChatAction extends Action2 { const chatService = accessor.get(IChatService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { return; @@ -963,7 +965,7 @@ class SendToNewChatAction extends Action2 { // Clear the input from the current session before creating a new one widget.setInput(''); - await clearChatSessionPreservingType(widget, viewsService, undefined, configurationService, chatSessionsService); + await clearChatSessionPreservingType(widget, viewsService, undefined, configurationService, chatSessionsService, storageService); widget.acceptInput(inputBeforeClear, { storeToHistory: true }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 90386345d09790..8b96ee2b676fae 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -13,6 +13,7 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; @@ -317,6 +318,7 @@ async function runNewChatAction( const viewsService = accessor.get(IViewsService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); const { editingSession, chatWidget: widget } = context ?? {}; if (!widget) { @@ -333,7 +335,7 @@ async function runNewChatAction( await editingSession?.stop(); // Create a new session, preserving the session type (or using the specified one) - await clearChatSessionPreservingType(widget, viewsService, sessionType, configurationService, chatSessionsService); + await clearChatSessionPreservingType(widget, viewsService, sessionType, configurationService, chatSessionsService, storageService); widget.attachmentModel.clear(true); widget.focusInput(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts index f102fadf86a2ee..25db0ef757f443 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; @@ -14,11 +15,12 @@ import { IWorkbenchEnvironmentService } from '../../../../../services/environmen import { IChatWidget, IChatWidgetService } from '../../chat.js'; import { ChatMode, IChatMode, IChatModes } from '../../../common/chatModes.js'; import { ChatModeKind } from '../../../common/constants.js'; +import type { IChatModeChangeEvent } from '../../widget/input/chatInputPart.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; const AGENT_HOST_SESSION_SCHEME_PREFIX = 'agent-host-'; -class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribution { +export class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.agentHostModeSynchronizer'; private readonly _widgetListeners = this._register(new DisposableMap()); @@ -59,13 +61,21 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu } const store = new DisposableStore(); - store.add(widget.input.onDidChangeCurrentChatMode(() => this._onWidgetModeChanged(widget))); + store.add(widget.input.onDidChangeCurrentChatMode(e => this._onWidgetModeChanged(widget, e))); store.add(widget.onDidChangeViewModel(() => this._syncWidgetFromBackend(widget))); + store.add(autorun(reader => { + const modes = widget.input.currentChatModesObs.read(reader); + reader.store.add(modes.onDidChange(() => this._syncWidgetFromBackend(widget))); + })); this._widgetListeners.set(widget, store); this._syncWidgetFromBackend(widget); } - private _onWidgetModeChanged(widget: IChatWidget): void { + private _onWidgetModeChanged(widget: IChatWidget, e: IChatModeChangeEvent): void { + if (!e.isUserInitiated) { + return; + } + if (this._updatingWidgets.has(widget)) { return; } @@ -92,10 +102,13 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu } const agentUri = this._readSelectedAgent(sessionResource); + if (agentUri === undefined) { + return; + } void this._applyMode(widget, sessionResource, agentUri); } - private async _applyMode(widget: IChatWidget, sessionResource: URI, agentUri: string | undefined): Promise { + private async _applyMode(widget: IChatWidget, sessionResource: URI, agentUri: string): Promise { const modes = widget.input.currentChatModesObs.get(); await modes.waitForPendingUpdates(); @@ -103,8 +116,7 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu return; } - const modeId = agentUri ?? ChatMode.Agent.id; - const mode = this._findMode(modes, modeId); + const mode = this._findMode(modes, agentUri); if (!mode || widget.input.currentModeObs.get().id === mode.id) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts index 5951d12b5e4177..e4de09881d9f99 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { addDisposableListener } from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -150,6 +151,13 @@ export class MicCaptureService extends Disposable implements IMicCaptureService private _pttAcquiring = false; private _pttReleasedDuringAcquire = false; + // --- Hardware mute detection. --- + // A hardware microphone kill switch (e.g. on Framework laptops) leaves + // `getUserMedia` succeeding with a track whose `muted` flag is set, so no + // acquisition error surfaces. Track the mute state to warn the user. + private readonly _micTrackListeners = this._register(new DisposableStore()); + private _micMutedNotified = false; + // --- Drain state (post-release continued streaming). --- // Drain length is enforced primarily by counting samples shipped // since `pttUp` (immune to main-thread jitter that would skew a @@ -336,6 +344,20 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } this._micStream = micStream; + // Detect a hardware-muted microphone (e.g. a physical kill switch). + // `getUserMedia` succeeds in this case but the track produces silence, + // so without this check PTT would appear to work while capturing nothing. + this._micTrackListeners.clear(); + this._micMutedNotified = false; + const audioTrack = micStream.getAudioTracks()[0]; + if (audioTrack) { + if (audioTrack.muted) { + this._notifyMicrophoneMuted(); + } + this._micTrackListeners.add(addDisposableListener(audioTrack, 'mute', () => this._notifyMicrophoneMuted())); + this._micTrackListeners.add(addDisposableListener(audioTrack, 'unmute', () => { this._micMutedNotified = false; })); + } + if (!this._micCtx) { this._micCtx = new window.AudioContext({ sampleRate: 16000 }); } @@ -422,6 +444,18 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } } + private _notifyMicrophoneMuted(): void { + if (this._micMutedNotified) { + return; + } + this._micMutedNotified = true; + this.logService.warn('[mic] Microphone track is muted — likely a hardware mute switch is enabled'); + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('mic.hardwareMuted', "Your microphone appears to be muted or disabled, possibly by a hardware switch. Voice Mode won't hear you until it's re-enabled."), + }); + } + stopCapture(): void { // Cancel any in-flight drain; do NOT fire `_onPttEnd` here // because callers (reconnect / disconnect / dispose) have @@ -444,6 +478,8 @@ export class MicCaptureService extends Disposable implements IMicCaptureService this._micStream.getTracks().forEach(t => t.stop()); this._micStream = null; } + this._micTrackListeners.clear(); + this._micMutedNotified = false; this._isCapturing = false; this._pttHeld = false; this._pttStreaming = false; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts index c58c26a974f507..5c2f804194e47f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts @@ -26,6 +26,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { AnimatedCounterWidget } from '../../../../browser/animatedCounterWidget.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../browser/labels.js'; import { ChatConfiguration } from '../../common/constants.js'; import '../media/chatTurnPills.css'; @@ -144,11 +145,13 @@ export function observeTurnStatusPillsConfig(configurationService: IConfiguratio class ChangesPillActionViewItem extends BaseActionViewItem { private _button: Button | undefined; + private _filesLabel: HTMLElement | undefined; constructor( action: IAction, options: IActionViewItemOptions, private readonly _statsObs: IObservable, + private readonly _instantiationService: IInstantiationService, ) { super(undefined, action, options); } @@ -165,27 +168,43 @@ class ChangesPillActionViewItem extends BaseActionViewItem { } })); + // Build the label structure once so the animated counters persist across + // updates and can transition smoothly between values instead of being + // torn down and rebuilt on every stats change. + this._filesLabel = $('span.chat-turn-pill-meta-label'); + reset( + button.element, + $(`span.chat-turn-pill-meta-icon${ThemeIcon.asCSSSelector(Codicon.diffMultiple)}`), + this._filesLabel, + ); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, button.element, { + prefix: '+', + direction: 'topToBottom', + cssClassName: 'chat-turn-pill-meta-added', + count: derived(this, reader => this._statsObs.read(reader).insertions), + })); + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, button.element, { + prefix: '-', + direction: 'bottomToTop', + cssClassName: 'chat-turn-pill-meta-removed', + count: derived(this, reader => this._statsObs.read(reader).deletions), + })); + this._register(autorun(reader => { - this._statsObs.read(reader); - this._updateLabel(); + this._updateLabel(this._statsObs.read(reader)); })); } - private _updateLabel(): void { - if (!this._button) { + private _updateLabel(stats: IDiffStats): void { + if (!this._button || !this._filesLabel) { return; } - const { files, insertions, deletions } = this._statsObs.get(); + const { files, insertions, deletions } = stats; const filesLabel = files === 1 ? localize('chatTurnPills.changes.file', "{0} File", files) : localize('chatTurnPills.changes.files', "{0} Files", files); - reset( - this._button.element, - $(`span.chat-turn-pill-meta-icon${ThemeIcon.asCSSSelector(Codicon.diffMultiple)}`), - $('span.chat-turn-pill-meta-label', undefined, filesLabel), - $('span.chat-turn-pill-meta-added', undefined, `+${insertions}`), - $('span.chat-turn-pill-meta-removed', undefined, `-${deletions}`), - ); + this._filesLabel.textContent = filesLabel; this._button.setTitle(localize('chatTurnPills.changes.tooltip', "View Changes")); this._button.element.setAttribute('aria-label', localize('chatTurnPills.changes.ariaLabel', "View Changes: {0}, +{1}, -{2}", filesLabel, insertions, deletions)); } @@ -303,7 +322,7 @@ export class ChatTurnPillsWidget extends Disposable { constructor( private readonly _model: IChatTurnPillsModel, @IContextMenuService private readonly _contextMenuService: IContextMenuService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -311,7 +330,7 @@ export class ChatTurnPillsWidget extends Disposable { // themed icon — the label always computes the file-icon classes, but they // only paint when an ancestor opts in. this.element = $('.chat-turn-pills.show-file-icons.hidden'); - this._resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + this._resourceLabels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); this._changesAction = this._register(new Action(CHANGES_PILL_ACTION_ID, localize('chatTurnPills.changes.tooltip', "View Changes"), undefined, true, async () => this._model.openChanges())); this._previewAction = this._register(new Action(PREVIEW_PILL_ACTION_ID, localize('chatTurnPills.preview.label', "Open Preview"), undefined, true, async () => this._openPrimaryPreview())); @@ -321,7 +340,7 @@ export class ChatTurnPillsWidget extends Disposable { ariaLabel: localize('chatTurnPills.ariaLabel', "Turn status"), actionViewItemProvider: (action, options) => { if (action.id === CHANGES_PILL_ACTION_ID) { - return new ChangesPillActionViewItem(action, options, this._model.stats); + return new ChangesPillActionViewItem(action, options, this._model.stats, this._instantiationService); } if (action.id === PREVIEW_PILL_ACTION_ID) { return new PreviewPillActionViewItem(action, options, this._model.previewFiles, this._resourceLabels, file => this._model.openPreviewFile(file), anchor => this._showAllPreviews(anchor)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts index 998f6ef06c3edb..fc680aa9a89e75 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts @@ -11,14 +11,11 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ACTIVE_GROUP, IEditorService, type PreferredGroup } from '../../../../services/editor/common/editorService.js'; import { IEditorGroup, IEditorGroupsService, isEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatLastUsedEditorSessionTypeStorageKey } from '../../common/constants.js'; -import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ChatAgentLocation } from '../../common/constants.js'; import { ChatViewId, ChatViewPaneTarget, IChatWidget, IChatWidgetService, IQuickChatService, isIChatViewViewContext } from '../chat.js'; import { ChatEditor, IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; @@ -51,7 +48,6 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService @IEditorService private readonly editorService: IEditorService, @IChatService private readonly chatService: IChatService, @ILogService private readonly logService: ILogService, - @IStorageService private readonly storageService: IStorageService, ) { super(); } @@ -237,30 +233,10 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService } this._lastFocusedWidget = widget; - this.recordLastUsedSessionType(widget); this._onDidChangeFocusedWidget.fire(widget); this._onDidChangeFocusedSession.fire(); } - /** - * Stores the session type (agent) of the last-focused user-facing chat so new chat editors can reuse it (excluding Quick Chat). - */ - private recordLastUsedSessionType(widget: IChatWidget | undefined): void { - const sessionResource = widget?.viewModel?.sessionResource; - if (!sessionResource) { - return; - } - if (this.quickChatService.sessionResource && isEqual(sessionResource, this.quickChatService.sessionResource)) { - return; - } - const sessionType = getChatSessionType(sessionResource); - // Only remember non-local agents to avoid clobbering the user's last-used agent. - if (sessionType === localChatSessionType) { - return; - } - this.storageService.store(ChatLastUsedEditorSessionTypeStorageKey, sessionType, StorageScope.PROFILE, StorageTarget.USER); - } - register(newWidget: IChatWidget): IDisposable { if (this._widgets.some(widget => widget === newWidget)) { throw new Error('Cannot register the same widget multiple times'); @@ -277,7 +253,6 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => { if (this._lastFocusedWidget === newWidget && !isEqual(previousSessionResource, currentSessionResource)) { - this.recordLastUsedSessionType(newWidget); this._onDidChangeFocusedSession.fire(); } 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 b4ab99adc7a6de..e78a6dfc6e7255 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -266,6 +266,10 @@ export interface IChatWidgetLocationInfo { readonly isMaximized: boolean; } +export interface IChatModeChangeEvent { + readonly isUserInitiated: boolean; +} + const LEGACY_SHARED_INPUT_STATE_TAGS = new Set(['view', 'editor', 'quick']); function getInputStateStorageKey(widgetViewKindTag: string): string { @@ -542,8 +546,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentLanguageModel; } - private _onDidChangeCurrentChatMode: Emitter = this._register(new Emitter()); - readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; + private _onDidChangeCurrentChatMode: Emitter = this._register(new Emitter()); + readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; private readonly _currentModeObservable: ISettableObservable; private readonly _currentChatModesObservable: ISettableObservable; @@ -1202,8 +1206,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showManageModelsAction: () => { + // Agent-host session types (local and remote) also surface the + // "Manage Models" action so users can add/configure BYOK models + // directly from the picker, matching the agents window experience. const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType; + return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showUnavailableFeatured: () => { // Agent-host session types also surface unavailable featured @@ -1779,7 +1786,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * By ID- prefer this method */ - setChatMode(mode: ChatModeKind | string, storeSelection = true): void { + setChatMode(mode: ChatModeKind | string, storeSelection = true, isUserInitiated = false): void { if (!this.options.supportsChangingModes) { return; } @@ -1789,22 +1796,22 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge modes.findModeByName(mode) ?? modes.findModeById(ChatModeKind.Agent) ?? ChatMode.Ask; - this.setChatMode2(mode2, storeSelection); + this.setChatMode2(mode2, storeSelection, isUserInitiated); } - private setChatMode2(mode: IChatMode, storeSelection = true): void { + private setChatMode2(mode: IChatMode, storeSelection = true, isUserInitiated = false): void { if (!this.options.supportsChangingModes) { return; } this._currentModeObservable.set(mode, undefined); - this._onDidChangeCurrentChatMode.fire(); + this._onDidChangeCurrentChatMode.fire({ isUserInitiated }); if (storeSelection) { // Sync to model (mode is now persisted in the model's input state) // Log first so the upcoming _syncInputStateToModel write can be attributed // to a mode change. - logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); + logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, isUserInitiated=${isUserInitiated}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); this._syncInputStateToModel(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 7ee3dd19467535..69163e8b8c2b6c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -16,8 +16,8 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; @@ -35,8 +35,6 @@ import { IGitService } from '../../../../git/common/gitService.js'; */ export class DelegationSessionPickerActionItem extends SessionTypePickerActionItem { - private readonly _isSessionsWindow: boolean; - constructor( action: MenuItemAction, chatSessionPosition: 'sidebar' | 'editor', @@ -52,10 +50,10 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @ILanguageModelsService languageModelsService: ILanguageModelsService, @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, @IGitService private readonly gitService: IGitService, ) { - super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService); - this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; + super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService, storageService); } protected override _run(sessionTypeItem: ISessionTypeItem): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 3d02a6b1f207ae..9e28f66b2c61c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -19,13 +19,15 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { getSessionTypeAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../agentSessions/sessionTypeAvailability.js'; -import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType } from '../../../common/constants.js'; +import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../../common/constants.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; @@ -47,6 +49,7 @@ const otherCategory = { label: localize('chat.sessionTarget.category.other', "Ot */ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { private _sessionTypeItems: ISessionTypeItem[] = []; + protected readonly _isSessionsWindow: boolean; constructor( action: MenuItemAction, @@ -63,6 +66,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { @IChatEntitlementService protected readonly chatEntitlementService: IChatEntitlementService, @ILanguageModelsService protected readonly languageModelsService: ILanguageModelsService, @IConfigurationService protected readonly configurationService: IConfigurationService, + @IStorageService protected readonly storageService: IStorageService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { @@ -109,6 +113,8 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { super(action, sessionTargetPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); + this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; + this._register(this.chatSessionsService.onDidChangeAvailability(() => { this._updateAgentSessionItems(); })); @@ -128,6 +134,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected _run(sessionTypeItem: ISessionTypeItem): void { + if (!this._isSessionsWindow) { + recordUserSelectedSessionType(this.storageService, this.configurationService, this.chatSessionsService, sessionTypeItem.type); + } + if (this.delegate.setActiveSessionProvider) { // Use provided setter (for welcome view) this.delegate.setActiveSessionProvider(sessionTypeItem.type); @@ -224,7 +234,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { * when the selected provider is registered. */ protected _getDefaultSessionType(): AgentSessionTarget { - return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService) as AgentSessionTarget; + return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService) as AgentSessionTarget; } protected _isVisible(type: AgentSessionTarget): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index f685c223aaa47f..34639ebf3cebf9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -16,16 +16,17 @@ import * as nls from '../../../../../../nls.js'; import { ConfirmResult, IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../../common/editor.js'; import { EditorInput, IEditorCloseHandler } from '../../../../../common/editor/editorInput.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatEditorTitleMaxLength, ChatLastUsedEditorSessionTypeStorageKey, getDefaultNewChatSessionResource, getDefaultNewChatSessionType, getNewChatEditorSessionResource } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatEditorTitleMaxLength, getComputedDefaultSessionResource, getComputedDefaultSessionType, getDefaultNewChatSessionResource } from '../../../common/constants.js'; import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; -import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; +import { LocalChatSessionUri, getChatSessionType, isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IClearEditingSessionConfirmationOptions } from '../../actions/chatActions.js'; import type { IChatEditorOptions } from './chatEditor.js'; @@ -67,6 +68,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -218,13 +220,32 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler const inputType = chatSessionType ?? this.resource.authority; if (this._sessionResource) { - this.modelRef.value = await this.chatService.acquireOrLoadSession(this._sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolve'); + try { + this.modelRef.value = await this.chatService.acquireOrLoadSession(this._sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolve'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire session ${this._sessionResource.toString()}`, error); + } + + if (!this.model && isUntitledChatSession(this._sessionResource) && getChatSessionType(this._sessionResource) !== localChatSessionType) { + this.logService.warn(`[ChatEditorInput] Falling back to a local chat session because ${this._sessionResource.toString()} could not be acquired`); + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitledFallback' }); + } if (this.shouldReplaceEmptyLocalSession(this._sessionResource)) { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService); + const defaultResource = getComputedDefaultSessionResource(this.configurationService, this.chatSessionsService); if (getChatSessionType(defaultResource) !== localChatSessionType) { - this._sessionResource = defaultResource; - this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultSession'); + let modelRef: IChatModelReference | undefined; + try { + modelRef = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultSession'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire default session ${defaultResource.toString()}`, error); + } + if (modelRef) { + this._sessionResource = defaultResource; + this.modelRef.value = modelRef; + } else { + this.logService.warn(`[ChatEditorInput] Keeping local chat session because default session ${defaultResource.toString()} could not be acquired`); + } } } @@ -236,13 +257,21 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler if (this.options.explicitSessionType === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveExplicitLocal' }); } else { - const lastUsedSessionType = this.storageService.get(ChatLastUsedEditorSessionTypeStorageKey, StorageScope.PROFILE); - const defaultResource = getNewChatEditorSessionResource(this.configurationService, this.chatSessionsService, lastUsedSessionType); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService); if (getChatSessionType(defaultResource) === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); } else { - this._sessionResource = defaultResource; - this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultUntitled'); + try { + this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultUntitled'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire default session ${defaultResource.toString()}`, error); + } + if (this.model) { + this._sessionResource = defaultResource; + } else { + this.logService.warn(`[ChatEditorInput] Falling back to a local chat session because ${defaultResource.toString()} could not be acquired`); + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitledFallback' }); + } } } } else if (this.options.target.data) { @@ -273,7 +302,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler && this.options.explicitSessionType !== localChatSessionType && !!this.model && !this.model.hasRequests - && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService) !== localChatSessionType; + && getComputedDefaultSessionType(this.configurationService, this.chatSessionsService) !== localChatSessionType; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 09b1e6b6c028d7..31b77117dcde26 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -50,7 +50,7 @@ import { CHAT_PROVIDER_ID } from '../../../common/participants/chatParticipantCo import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getComputedDefaultSessionType, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { ChatWidget } from '../../widget/chatWidget.js'; @@ -1028,11 +1028,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { * Returns `undefined` to fall back to `startNewLocalSession`. */ private async acquireDefaultNewSession(token: CancellationToken): Promise { - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService); if (defaultType === localChatSessionType) { return undefined; } - const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService); + const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService); try { return await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatViewPane#acquireDefaultNewSession'); } catch (error) { @@ -1061,7 +1061,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private shouldSkipRestoredLocalSession(sessionResource: URI, model: IChatModel): boolean { - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService); + const defaultType = getComputedDefaultSessionType(this.configurationService, this.chatSessionsService); const prefersAgentHostCopilot = this.configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) === false && this.configurationService.getValue(ChatConfiguration.EditorDefaultProvider) === 'copilotAh'; return (defaultType !== localChatSessionType || prefersAgentHostCopilot) diff --git a/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts b/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts new file mode 100644 index 00000000000000..59d8e2fdb84056 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY = 'chat.userSelectedSessionType'; + +export function getRememberedSessionType(storageService: IStorageService): string | undefined { + return storageService.get(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, StorageScope.PROFILE); +} + +export function storeUserSelectedSessionType(storageService: IStorageService, sessionType: string): void { + storageService.store(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, sessionType, StorageScope.PROFILE, StorageTarget.MACHINE); +} + +export function clearUserSelectedSessionType(storageService: IStorageService): void { + storageService.remove(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, StorageScope.PROFILE); +} diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 5454efa63f7e0f..df9ad98246cad7 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; -import { IChatSessionsService, localChatSessionType, SessionType } from './chatSessionsService.js'; +import { IChatSessionsService, isAgentHostTarget, localChatSessionType, SessionType } from './chatSessionsService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; @@ -13,6 +14,7 @@ import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../comm import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { LocalChatSessionUri } from './model/chatUri.js'; +import { clearUserSelectedSessionType, getRememberedSessionType, storeUserSelectedSessionType } from './chatSessionTypePreference.js'; export const enum BYOKUtilityModelDefault { None = 'none', @@ -262,7 +264,7 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st * Falls back to {@link localChatSessionType} when local is enabled, or when no * visible non-local provider is available. */ -export function getDefaultNewChatSessionType( +export function getComputedDefaultSessionType( configurationService: IConfigurationService, chatSessionsService: Pick ): string { @@ -283,62 +285,80 @@ export function getDefaultNewChatSessionType( return getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService)[0] ?? localChatSessionType; } -export function getDefaultNewChatSessionResource( +export function getComputedDefaultSessionResource( configurationService: IConfigurationService, chatSessionsService: Pick ): URI { - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService); + const defaultType = getComputedDefaultSessionType(configurationService, chatSessionsService); return defaultType === localChatSessionType ? LocalChatSessionUri.getNewSessionUri() : URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); } -/** - * Storage key for the last-used non-local editor chat session type (agent), persisted at profile scope. - */ -export const ChatLastUsedEditorSessionTypeStorageKey = 'chat.lastUsedEditorSessionType'; +export function isRememberedSessionTypeUsable( + sessionType: string, + configurationService: IConfigurationService, + chatSessionsService: Pick +): boolean { + if (sessionType === localChatSessionType) { + return isEditorLocalAgentEnabled(configurationService); + } + if (isAgentHostTarget(sessionType)) { + return true; + } + return !!chatSessionsService.getChatSessionContribution(sessionType); +} -/** - * Resolves the session type (agent) for a new chat editor, preferring the last-used visible non-local agent when `chat.editor.defaultProvider` isn't explicitly configured. - */ -export function getNewChatEditorSessionType( +export interface IDefaultNewChatSessionTypeOptions { + readonly explicitOverride?: string; + readonly currentSessionType?: string; +} + +export function getDefaultNewChatSessionType( configurationService: IConfigurationService, chatSessionsService: Pick, - lastUsedSessionType: string | undefined, + storageService: IStorageService, + options?: IDefaultNewChatSessionTypeOptions ): string { - const inspected = configurationService.inspect(ChatConfiguration.EditorDefaultProvider); - const explicitlyConfigured = inspected.applicationValue !== undefined - || inspected.userValue !== undefined - || inspected.userLocalValue !== undefined - || inspected.userRemoteValue !== undefined - || inspected.workspaceValue !== undefined - || inspected.workspaceFolderValue !== undefined - || inspected.memoryValue !== undefined - || inspected.policyValue !== undefined; - - if (!explicitlyConfigured - && lastUsedSessionType - && lastUsedSessionType !== localChatSessionType - && isVisibleEditorChatSessionType(lastUsedSessionType, configurationService, chatSessionsService)) { - return lastUsedSessionType; + if (options?.explicitOverride) { + return options.explicitOverride; + } + + const remembered = getRememberedSessionType(storageService); + if (remembered && isRememberedSessionTypeUsable(remembered, configurationService, chatSessionsService)) { + return remembered; } - return getDefaultNewChatSessionType(configurationService, chatSessionsService); + if (options?.currentSessionType) { + return options.currentSessionType; + } + + return getComputedDefaultSessionType(configurationService, chatSessionsService); } -/** - * Like {@link getDefaultNewChatSessionResource}, but prefers the user's - * last-used session type via {@link getNewChatEditorSessionType}. - */ -export function getNewChatEditorSessionResource( +export function getDefaultNewChatSessionResource( configurationService: IConfigurationService, chatSessionsService: Pick, - lastUsedSessionType: string | undefined, + storageService: IStorageService, + options?: IDefaultNewChatSessionTypeOptions ): URI { - const sessionType = getNewChatEditorSessionType(configurationService, chatSessionsService, lastUsedSessionType); - return sessionType === localChatSessionType + const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, options); + return defaultType === localChatSessionType ? LocalChatSessionUri.getNewSessionUri() - : URI.from({ scheme: sessionType, path: `/untitled-${generateUuid()}` }); + : URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); +} + +export function recordUserSelectedSessionType( + storageService: IStorageService, + configurationService: IConfigurationService, + chatSessionsService: Pick, + sessionType: string +): void { + if (sessionType === getComputedDefaultSessionType(configurationService, chatSessionsService)) { + clearUserSelectedSessionType(storageService); + } else { + storeUserSelectedSessionType(storageService, sessionType); + } } export function isEditorLocalAgentEnabled(configurationService: IConfigurationService): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts new file mode 100644 index 00000000000000..52bbc15dabae61 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { agentHostAgentPickerStorageKey } from '../../../../../../platform/agentHost/common/customAgents.js'; +import { StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { AgentHostModeSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostModeSynchronizer.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { ChatMode, IChatMode, IChatModes } from '../../../common/chatModes.js'; +import { ChatModeKind } from '../../../common/constants.js'; +import type { IChatModeChangeEvent } from '../../../browser/widget/input/chatInputPart.js'; + +suite('AgentHostModeSynchronizer', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionResource = URI.parse('agent-host-claude:/session-1'); + const agentUri = 'file:///agent.md'; + + function createCustomMode(): IChatMode { + return { + id: agentUri, + kind: ChatModeKind.Agent, + isBuiltin: false, + } as IChatMode; + } + + function createModes(customModes: () => readonly IChatMode[], onDidChange: Event): IChatModes { + return { + onDidChange, + builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], + get custom() { + return customModes(); + }, + findModeById: id => [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit, ...customModes()].find(mode => mode.id === id), + findModeByName: name => [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit, ...customModes()].find(mode => mode.name?.get() === name), + waitForPendingUpdates: async () => { }, + }; + } + + function createSynchronizer(initialMode: IChatMode, initialCustomModes: readonly IChatMode[] = []) { + let customModes = [...initialCustomModes]; + const modeChanges = store.add(new Emitter()); + const modesChanges = store.add(new Emitter()); + const mode = observableValue('mode', initialMode); + const modes = createModes(() => customModes, modesChanges.event); + const modesObservable = observableValue('modes', modes); + const setChatModeCalls: string[] = []; + + const widget = { + viewModel: { sessionResource }, + input: { + onDidChangeCurrentChatMode: modeChanges.event, + currentModeObs: mode, + currentChatModesObs: modesObservable, + setChatMode: (modeId: string) => { + setChatModeCalls.push(modeId); + const next = modes.findModeById(modeId); + if (next) { + mode.set(next, undefined); + } + }, + }, + onDidChangeViewModel: Event.None, + } as unknown as IChatWidget; + + const widgetService = { + getAllWidgets: () => [widget], + onDidAddWidget: Event.None, + onDidChangeFocusedSession: Event.None, + getWidgetBySessionResource: (resource: URI) => resource.toString() === sessionResource.toString() ? widget : undefined, + lastFocusedWidget: widget, + } as unknown as IChatWidgetService; + + const provisionalSessionService = { + onDidChange: Event.None, + get: () => undefined, + } as unknown as IAgentHostUntitledProvisionalSessionService; + + const storageService = store.add(new TestStorageService()); + const environmentService = { isSessionsWindow: false } as IWorkbenchEnvironmentService; + const synchronizer = store.add(new AgentHostModeSynchronizer(widgetService, provisionalSessionService, storageService, environmentService)); + + return { + modeChanges, + modesChanges, + setCustomModes: (next: readonly IChatMode[]) => { + customModes = [...next]; + }, + setChatModeCalls, + storageService, + synchronizer, + }; + } + + test('persists only user initiated custom agent changes', () => { + const { modeChanges, storageService } = createSynchronizer(createCustomMode(), [createCustomMode()]); + const key = agentHostAgentPickerStorageKey(sessionResource.scheme); + + modeChanges.fire({ isUserInitiated: false }); + assert.strictEqual(storageService.get(key, StorageScope.PROFILE), undefined); + + modeChanges.fire({ isUserInitiated: true }); + assert.strictEqual(storageService.get(key, StorageScope.PROFILE), agentUri); + }); + + test('does not force default Agent when storage is empty', async () => { + const { setChatModeCalls } = createSynchronizer(ChatMode.Ask); + + await timeout(0); + + assert.deepStrictEqual(setChatModeCalls, []); + }); + + test('retries restore when custom modes load late', async () => { + const { modesChanges, setChatModeCalls, setCustomModes, storageService } = createSynchronizer(ChatMode.Agent); + storageService.store(agentHostAgentPickerStorageKey(sessionResource.scheme), agentUri, StorageScope.PROFILE, StorageTarget.MACHINE); + + await timeout(0); + assert.deepStrictEqual(setChatModeCalls, []); + + setCustomModes([createCustomMode()]); + modesChanges.fire(); + await timeout(0); + + assert.deepStrictEqual(setChatModeCalls, [agentUri]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index 9997e690576c8f..91b62338203588 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -10,6 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; import { ChatEditorInput } from '../../../../browser/widgetHosts/editor/chatEditorInput.js'; import { IChatService, IChatSessionStartOptions } from '../../../../common/chatService/chatService.js'; @@ -52,6 +53,7 @@ suite('ChatEditorInput', () => { {} as IChatSessionsService, {} as IInstantiationService, {} as IStorageService, + new NullLogService(), ); try { @@ -104,6 +106,7 @@ suite('ChatEditorInput', () => { {} as IChatSessionsService, {} as IInstantiationService, {} as IStorageService, + new NullLogService(), ); try { diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index 2bc66b545212e2..5984b2c014c056 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -6,13 +6,15 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType } from '../../common/constants.js'; +import { ChatConfiguration, getComputedDefaultSessionType, getDefaultNewChatSessionType, isRememberedSessionTypeUsable, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../common/constants.js'; import { localChatSessionType, SessionType, IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { MockChatSessionsService } from './mockChatSessionsService.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { getRememberedSessionType } from '../../common/chatSessionTypePreference.js'; suite('ChatConfiguration defaults', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); function createChatSessionsService(...types: string[]): MockChatSessionsService { const service = new MockChatSessionsService(); @@ -28,9 +30,17 @@ suite('ChatConfiguration defaults', () => { test('editor default returns local when local is enabled', () => { const configurationService = new TestConfigurationService(); const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const storageService = disposables.add(new TestStorageService()); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), localChatSessionType); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), true); + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: localChatSessionType, + rememberedAware: localChatSessionType, + localVisible: true, + }); }); test('editor default returns agent host Copilot when local is disabled and copilotAh is configured', () => { @@ -39,9 +49,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', }); const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const storageService = disposables.add(new TestStorageService()); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), false); + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + localVisible: false, + }); }); test('editor default keeps configured agent host Copilot before contribution registers', () => { @@ -50,9 +68,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', }); const chatSessionsService = createChatSessionsService(SessionType.CopilotCLI); + const storageService = disposables.add(new TestStorageService()); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), false); + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + localVisible: false, + }); }); test('editor default skips hidden extension host Copilot CLI', () => { @@ -62,9 +88,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.CopilotCliHideExtensionHostEditor]: true, }); const chatSessionsService = createChatSessionsService(SessionType.CopilotCLI, SessionType.AgentHostCopilot); + const storageService = disposables.add(new TestStorageService()); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(SessionType.CopilotCLI, configurationService, chatSessionsService), false); + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + extensionHostVisible: isVisibleEditorChatSessionType(SessionType.CopilotCLI, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + extensionHostVisible: false, + }); }); test('editor default keeps local as last resort when local is disabled without configured provider', () => { @@ -72,8 +106,105 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorLocalAgentEnabled]: false, }); const chatSessionsService = createChatSessionsService(); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: localChatSessionType, + rememberedAware: localChatSessionType, + localVisible: true, + }); + }); + + test('remembered explicit selection wins for new sessions', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + }, { + computed: localChatSessionType, + remembered: SessionType.AgentHostClaude, + rememberedAware: SessionType.AgentHostClaude, + }); + }); + + test('explicit override wins over remembered selection', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + + assert.deepStrictEqual({ + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { explicitOverride: SessionType.AgentHostCopilot }), + }, { + remembered: SessionType.AgentHostClaude, + rememberedAware: SessionType.AgentHostCopilot, + }); + }); + + test('current session type is fallback after remembered selection', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + withoutRemembered: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { currentSessionType: SessionType.AgentHostCopilot }), + }, { + withoutRemembered: SessionType.AgentHostCopilot, + }); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + + assert.deepStrictEqual({ + withRemembered: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { currentSessionType: SessionType.AgentHostCopilot }), + }, { + withRemembered: SessionType.AgentHostClaude, + }); + }); + + test('selecting computed default clears remembered selection', () => { + const configurationService = new TestConfigurationService({ + [ChatConfiguration.EditorLocalAgentEnabled]: false, + [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', + }); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostCopilot); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + }, { + computed: SessionType.AgentHostCopilot, + remembered: undefined, + rememberedAware: SessionType.AgentHostCopilot, + }); + }); + + test('remembered agent host is usable before contribution registers', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), localChatSessionType); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), true); + assert.deepStrictEqual({ + agentHost: isRememberedSessionTypeUsable(SessionType.AgentHostClaude, configurationService, chatSessionsService), + extensionContributed: isRememberedSessionTypeUsable('my-extension-agent', configurationService, chatSessionsService), + }, { + agentHost: true, + extensionContributed: false, + }); }); }); diff --git a/src/vs/workbench/contrib/extensions/browser/media/extension.css b/src/vs/workbench/contrib/extensions/browser/media/extension.css index 8454447913129d..3993f3b68b9749 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extension.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extension.css @@ -15,7 +15,7 @@ box-sizing: border-box; width: 100%; height: 100%; - padding: 0 0 0 16px; + padding: 0 0 0 20px; overflow: hidden; display: flex; } @@ -56,10 +56,10 @@ } .extension-list-item > .details > .header-container { - height: 19px; + height: 20px; display: flex; overflow: hidden; - padding-right: 11px; + padding-right: 10px; } .extension-list-item > .details > .header-container > .header { @@ -72,7 +72,7 @@ } .extension-list-item > .details > .header-container > .header > .name { - font-weight: bold; + font-weight: var(--vscode-agents-fontWeight-semiBold); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -127,11 +127,6 @@ margin-left: 2px; } -.extension-list-item > .details > .header-container > .header > .activation-status .activationTime, -.extension-list-item > .details > .header-container > .header > .activation-status:not(:empty) .codicon { - margin-right: 2px; -} - .extension-list-item > .details > .header-container > .header .codicon { margin-right: 2px; -webkit-mask: inherit; @@ -170,7 +165,7 @@ } .extension-list-item > .details > .description { - padding-right: 11px; + padding-right: 8px; line-height: normal; color: var(--vscode-descriptionForeground); } @@ -187,7 +182,8 @@ .extension-list-item > .details > .footer { display: flex; justify-content: flex-end; - padding-right: 7px; + padding-right: 2px; + padding-top: 2px; height: 24px; overflow: hidden; align-items: center; @@ -204,9 +200,9 @@ } .extension-list-item > .details > .footer .publisher > .publisher-name { - font-size: 90%; + font-size: 11px; color: var(--vscode-descriptionForeground); - font-weight: 600; + font-weight: var(--vscode-agents-fontWeight-semiBold); } .monaco-list-row.selected .extension-list-item > .details > .footer .publisher > .publisher-name{ @@ -257,10 +253,6 @@ color: unset; } -.extension-list-item .monaco-action-bar .action-label.icon { - padding: 1px 2px; -} - .hc-black .extension-list-item .monaco-action-bar .action-label.icon, .hc-light .extension-list-item .monaco-action-bar .action-label.icon { padding: 0px 2px; diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css b/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css index 0f66f5fcc4df13..a8b7fb5012d991 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css @@ -4,16 +4,17 @@ *--------------------------------------------------------------------------------------------*/ .extension-icon .icon { - width: 42px; - height: 42px; - padding-right: 14px; + width: 36px; + height: 36px; + margin-right: 16px; + border-radius: var(--vscode-cornerRadius-xSmall, 2px); flex-shrink: 0; object-fit: contain; } .extension-icon .codicon { - padding-right: 14px; - font-size: 42px !important; + padding-right: 16px; + font-size: 36px !important; } .extension-sync-ignored.hide { @@ -29,7 +30,7 @@ } .extension-ratings > .codicon[class*='codicon-extensions-rating']:not(:first-child) { - margin-left: 3px; + margin-left: 4px; } .extension-ratings > .count { @@ -56,49 +57,43 @@ } .extension-bookmark { - display: inline-block; - height: 20px; - width: 20px; + display: flex; } .extension-bookmark > .recommendation, .extension-bookmark > .pre-release { - border-right: 20px solid transparent; - border-top: 20px solid; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-top-left-radius: var(--vscode-cornerRadius-small, 4px); + border-bottom-right-radius: var(--vscode-cornerRadius-small, 4px); box-sizing: border-box; - position: relative; } .extension-bookmark > .pre-release { - border-top-color: var(--vscode-extensionIcon-preReleaseForeground); + background-color: var(--vscode-extensionIcon-preReleaseForeground); color: #ffffff; } -.extension-bookmark > .recommendation > .codicon, -.extension-bookmark > .pre-release > .codicon { - position: absolute; - bottom: 9px; - left: 1px; - color: inherit; - font-size: 80% !important; -} - -.extension-bookmark .recommendation { - border-top-color: var(--vscode-extensionButton-prominentBackground); +.extension-bookmark > .recommendation { + background-color: var(--vscode-extensionButton-prominentBackground); color: var(--vscode-extensionButton-prominentForeground); } -.hc-black .extension-bookmark .recommendation, -.hc-light .extension-bookmark .recommendation, -.hc-black .extension-bookmark .pre-release, -.hc-light .extension-bookmark .pre-release { - border-top-color: var(--vscode-contrastBorder); - color: var(--vscode-editor-background); +.extension-bookmark > .recommendation > .codicon, +.extension-bookmark > .pre-release > .codicon { + color: inherit; + font-size: 12px !important; + line-height: 12px; } -.hc-black .extension-bookmark .recommendation .codicon, -.hc-light .extension-bookmark .recommendation .codicon, -.hc-black .extension-bookmark .pre-release .codicon, -.hc-light .extension-bookmark .pre-release .codicon { - color: var(--vscode-editor-background); +.hc-black .extension-bookmark > .recommendation, +.hc-light .extension-bookmark > .recommendation, +.hc-black .extension-bookmark > .pre-release, +.hc-light .extension-bookmark > .pre-release { + background-color: var(--vscode-editor-background); + color: var(--vscode-contrastBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder); } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index 71733ede6473da..d2310e2c2e722d 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -187,8 +187,8 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .category-title { margin: 4px 0 4px; - font-size: 14px; - font-weight: 500; + font-size: var(--vscode-bodyFontSize); + font-weight: var(--vscode-agents-fontWeight-semiBold); text-align: left; display: inline-block; overflow: hidden; @@ -204,7 +204,7 @@ } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category.no-progress { - padding: 3px 6px; + padding: 4px 4px 4px 6px; } .monaco-workbench .part.editor > .content .gettingStartedContainer .getting-started-category.no-progress .category-progress { @@ -247,8 +247,8 @@ right: 0; top: 50%; transform: translateY(-50%); - padding: 3px; - border-radius: 5px; + padding: 4px; + border-radius: var(--vscode-cornerRadius-small); } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .recently-opened li { @@ -312,11 +312,11 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category { width: calc(100% - 16px); - font-size: 13px; + font-size: var(--vscode-bodyFontSize); box-sizing: border-box; line-height: normal; - margin: 8px 8px 8px 1px; - padding: 3px 6px 6px; + margin: 8px 8px 8px 0; + padding: 4px 6px; text-align: left; } @@ -340,7 +340,7 @@ } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .description-content > .codicon { - padding-right: 1px; + padding-right: 2px; font-size: 16px; } @@ -351,32 +351,41 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .new-badge { justify-self: flex-end; align-self: flex-start; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); padding: 2px 4px; margin: 4px; - font-size: 11px; + font-size: var(--vscode-bodyFontSize-xSmall); white-space: nowrap; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured-badge { - position: relative; - top: -4px; - left: -8px; + position: absolute; + top: 0; + left: 0; + z-index: 1; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured { - border-top: 30px solid var(--vscode-activityBarBadge-background); - width: 30px; - box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 20px; height: 20px; - border-right: 40px solid transparent; - position: absolute; + border-top-left-radius: var(--vscode-cornerRadius-medium, 6px); + border-bottom-right-radius: var(--vscode-cornerRadius-medium, 6px); + background-color: var(--vscode-activityBarBadge-background); + box-sizing: border-box; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured .featured-icon { - top: -30px; - left: 4px; - font-size: 14px; + top: 0; + left: 0; + padding-right: 0; + font-size: 12px; + line-height: 12px; + height: 20px; + display: flex; + align-items: center; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .codicon.hide-category-button { @@ -401,7 +410,7 @@ padding-right: 8px; max-width: 24px; max-height: 24px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); position: relative; top: auto; } @@ -1107,10 +1116,6 @@ border: inherit; } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .progress-bar-outer { - background-color: var(--vscode-welcomePage-progress-background); -} - .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .progress-bar-inner { background-color: var(--vscode-welcomePage-progress-foreground); } 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 908328ea462615..5419b30d0ea4de 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -14,7 +14,7 @@ import { SessionChatInputToolbar } from '../../../../../sessions/contrib/chat/br // 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 { ISessionChangeset, ISessionFileChange, SessionStatus, TURN_CHANGES_CHANGESET_ID } from '../../../../../sessions/services/sessions/common/session.js'; +import { ISessionFileChange, 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'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; @@ -27,37 +27,29 @@ import { IFixtureMessage, renderChatWidget } from '../chat/chatWidget.fixture.js /** A file created during the turn (no original => classified as "created"). */ function createdFile(name: string, insertions: number, deletions: number): ISessionFileChange { - return { modifiedUri: URI.file(`/repo/${name}`), insertions, deletions }; + return { uri: URI.file(`/repo/${name}`), modifiedUri: URI.file(`/repo/${name}`), insertions, deletions }; } /** A file edited during the turn (has an original => classified as "modified"). */ function editedFile(name: string, insertions: number, deletions: number): ISessionFileChange { const uri = URI.file(`/repo/${name}`); - return { modifiedUri: uri, originalUri: uri, insertions, deletions }; -} - -/** A single "Last Turn Changes" changeset carrying the given file changes. */ -function turnChangeset(changes: readonly ISessionFileChange[]): ISessionChangeset { - return new class extends mock() { - override readonly id = TURN_CHANGES_CHANGESET_ID; - override readonly changes: IObservable = constObservable(changes); - }(); + return { uri, modifiedUri: uri, originalUri: uri, insertions, deletions }; } interface ISessionSpec { readonly providerId?: string; - /** File changes in the current turn; omit for a session with no turn changeset. */ + /** File changes in the last turn; omit for a session with no last-turn changes. */ readonly turnChanges?: readonly ISessionFileChange[]; } function createMockSession(spec: ISessionSpec): IActiveSession { - const changesets = spec.turnChanges !== undefined ? [turnChangeset(spec.turnChanges)] : []; return new class extends mock() { override readonly resource = URI.parse('session:1'); override readonly providerId = spec.providerId ?? LOCAL_AGENT_HOST_PROVIDER_ID; // Pills above the input only show while a turn is actively in progress. override readonly status: IObservable = constObservable(SessionStatus.InProgress); - override readonly changesets: IObservable = constObservable(changesets); + override readonly lastTurnChanges: IObservable | undefined = + spec.turnChanges !== undefined ? constObservable(spec.turnChanges) : undefined; }(); } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index a46707f6b99963..2eb6e5762bad0c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -26,7 +26,7 @@ import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../.. import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/browser/sessionActionFeedback.js'; // eslint-disable-next-line local/code-import-patterns import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; -import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // ============================================================================ @@ -94,8 +94,6 @@ interface ITitleBarState { blockedCount?: number; /** Explicit typed blocked sessions (drives the specific requires-input message). */ blocked?: readonly IBlockedSpec[]; - /** Whether the primary side bar is visible (requires-input only shows when hidden). */ - sidebarVisible?: boolean; /** Number of recently approved sessions (drives the green "Approved N sessions"). */ approvedCount?: number; } @@ -112,7 +110,6 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo const specs: readonly IBlockedSpec[] = state.blocked ?? Array.from({ length: state.blockedCount ?? 0 }, (): IBlockedSpec => ({ reason: BlockedSessionReason.NeedsInput })); const { blocked, approvalModel } = buildBlocked(specs); - const sidebarVisible = state.sidebarVisible ?? true; const instantiationService = createEditorServices(disposableStore, { colorTheme: ctx.theme, @@ -130,9 +127,6 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo }()); reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { override readonly onDidChangePartVisibility = Event.None; - override isVisible(part: Parts): boolean { - return part === Parts.SIDEBAR_PART ? sidebarVisible : true; - } }()); // The blocked-sessions feature is only enabled outside of stable builds. reg.defineInstance(IProductService, new class extends mock() { @@ -189,7 +183,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), blockedCount: 3, - sidebarVisible: false, }), }), @@ -201,7 +194,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, ], - sidebarVisible: false, }), }), @@ -212,7 +204,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { blocked: [ { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Question }, ], - sidebarVisible: false, }), }), @@ -224,7 +215,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { { reason: BlockedSessionReason.FailingCI }, { reason: BlockedSessionReason.FailingCI }, ], - sidebarVisible: false, }), }), @@ -236,7 +226,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, { reason: BlockedSessionReason.FailingCI }, ], - sidebarVisible: false, }), }), @@ -254,7 +243,6 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), blockedCount: 3, - sidebarVisible: false, approvedCount: 3, }), }),