diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 0a6f018cf7b538..c8d2ff95d9b36c 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -215,6 +215,11 @@ jobs: timeout-minutes: 40 run: bash test/smoke/scripts/run-agents-window-network-proxy.sh + - name: ๐Ÿงช Run Agents Window smoke tests through Kerberos-authenticated macOS PAC proxy + if: ${{ inputs.electron_tests && inputs.smoke_tests }} + timeout-minutes: 40 + run: bash test/smoke/scripts/run-agents-window-network-proxy.sh --kerberos + - name: ๐Ÿงช Run smoke tests (Browser, Chromium) if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 83beb72dae28ee..f5debbc4b3883b 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -123,6 +123,14 @@ steps: timeoutInMinutes: 40 displayName: ๐Ÿงช Run Agents Window smoke tests through macOS PAC proxy + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) + APP_NAME="`ls $APP_ROOT | head -n 1`" + bash test/smoke/scripts/run-agents-window-network-proxy.sh --kerberos --build "$APP_ROOT/$APP_NAME" + timeoutInMinutes: 40 + displayName: ๐Ÿงช Run Agents Window smoke tests through Kerberos-authenticated macOS PAC proxy + - ${{ if eq(parameters.VSCODE_RUN_BROWSER_TESTS, true) }}: - script: npm run smoketest-no-compile -- --web --tracing --headless env: diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index 6e6bba31582bcc..16b432582426ab 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -13,9 +13,7 @@ import { IAccessibilityService } from '../../../../../../../platform/accessibili import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; -import { IEditorMouseEvent } from '../../../../../../browser/editorBrowser.js'; import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js'; -import { Point } from '../../../../../../common/core/2d/point.js'; import { Rect } from '../../../../../../common/core/2d/rect.js'; import { HoverService } from '../../../../../../../platform/hover/browser/hoverService.js'; import { HoverWidget } from '../../../../../../../platform/hover/browser/hoverWidget.js'; @@ -138,17 +136,6 @@ export class InlineEditsGutterIndicator extends Disposable { minContentWidthInPx: constObservable(0), })); - this._register(this._editorObs.editor.onMouseMove((e: IEditorMouseEvent) => { - const state = this._state.get(); - if (state === undefined) { return; } - - const el = this._iconRef.element; - const rect = el.getBoundingClientRect(); - const rectangularArea = Rect.fromLeftTopWidthHeight(rect.left, rect.top, rect.width, rect.height); - const point = new Point(e.event.posx, e.event.posy); - this._isHoveredOverIcon.set(rectangularArea.containsPoint(point), undefined); - })); - this._register(this._editorObs.editor.onDidScrollChange(() => { this._isHoveredOverIcon.set(false, undefined); })); @@ -548,9 +535,11 @@ export class InlineEditsGutterIndicator extends Disposable { }, onmouseenter: () => { + this._isHoveredOverIcon.set(true, undefined); // TODO show hover when hovering ghost text etc. this._showHover(); }, + onmouseleave: () => this._isHoveredOverIcon.set(false, undefined), style: { cursor: 'pointer', zIndex: '20', diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index acfa3007018d05..115f6498c9df8a 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -969,10 +969,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * response. */ async subscribe(resource: URI): Promise { + this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${resource.toString()}`); const result = await this._sendRequest('subscribe', { channel: resource.toString() }); if (!result.snapshot) { throw new Error(`subscribe to ${resource.toString()} returned no snapshot`); } + this._logService.trace(`[RemoteAgentHostProtocol] subscribe done: ${resource.toString()}`); return result.snapshot; } diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index be5cfadaa082b6..4b865ed6fc6541 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -203,6 +203,15 @@ export interface IWorktreeFileProgress { readonly filesTotal: number; } +export interface IAddWorktreeOptions { + readonly path: URI; + readonly commitish: string; + readonly newBranchName?: string; + readonly track: boolean; + readonly preferRemoteBranch?: boolean; + readonly onProgress?: (progress: IWorktreeFileProgress) => void; +} + export interface IAgentHostGitService { readonly _serviceBrand: undefined; getCurrentBranch(workingDirectory: URI): Promise; @@ -215,13 +224,13 @@ export interface IAgentHostGitService { /** Returns worktree roots in Git's porcelain order, with the primary worktree first. */ getWorktreeRoots(workingDirectory: URI): Promise; /** - * Creates a worktree for a new branch. `onProgress` receives every checkout + * Creates a worktree, optionally on a new branch. `onProgress` receives every checkout * sample git reports, which can be several per second, so consumers are * expected to round and rate limit for their own presentation. It may also * never be called (fast checkouts and git versions that stay silent), so it * MUST be treated as best-effort. */ - addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string, track: boolean, onProgress?: (progress: IWorktreeFileProgress) => void): Promise; + addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise; /** * Copies the git-ignored files matching `globs` into the worktree. * `onProgress` counts the individual files covered, but only fires as whole diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 1772836f753aa2..f7576e8d416e33 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; -import { EMPTY_TREE_OBJECT, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { firstParallel, Limiter, SequencerByKey, timeout } from '../../../base/common/async.js'; @@ -151,26 +151,32 @@ export class AgentHostGitService implements IAgentHostGitService { .map(line => URI.file(line.substring('worktree '.length))); } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string, track = false, onProgress?: (progress: IWorktreeFileProgress) => void): Promise { - const resolvedStartPoint = await this._resolveRemoteTrackingBranch(repositoryRoot, startPoint, track) ?? startPoint; + async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { + const resolvedCommitish = options.preferRemoteBranch + ? await this._resolveRemoteTrackingBranch(repositoryRoot, options.commitish, options.track) ?? options.commitish + : options.commitish; const args = ['-c', 'checkout.workers=0', 'worktree', 'add']; - if (!track) { - // Pass --no-track so the new agent branch never picks up upstream - // tracking from the start point (e.g. when starting from - // 'origin/main', without --no-track git would set the new branch's - // upstream to origin/main, which would mis-attribute pushes/pulls). - args.push('--no-track'); + if (options.newBranchName) { + if (!options.track) { + // Pass --no-track so the new agent branch never picks up upstream + // tracking from the start point (e.g. when starting from + // 'origin/main', without --no-track git would set the new branch's + // upstream to origin/main, which would mis-attribute pushes/pulls). + args.push('--no-track'); + } + + args.push('-b', options.newBranchName); } - args.push('-b', branchName, worktree.fsPath, resolvedStartPoint); + args.push(options.path.fsPath, resolvedCommitish); // `git worktree add` forces progress reporting on its internal checkout // even when stderr is a pipe, so `Updating files: N% (x/y)` can be // parsed for live feedback. GIT_PROGRESS_DELAY=0 lifts git's default // two-second suppression so the first sample arrives immediately. - const progressParser = onProgress ? new GitCheckoutProgressParser(onProgress) : undefined; + const progressParser = options.onProgress ? new GitCheckoutProgressParser(options.onProgress) : undefined; await this._runGit(repositoryRoot, args, { timeout: 180_000, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6a7cee7d46f4a1..241ae294e7ba0b 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3066,7 +3066,10 @@ export class AgentService extends Disposable implements IAgentService { */ private async _getChatMessages(provider: IAgent, chat: URI, session: URI, origin?: ChatOrigin): Promise { const context = { ...this._chatContext(session, chat), ...(origin ? { origin } : {}) }; - const turns = await this._applyPersistedTurnUsage(chat, await provider.chats.getMessages(chat, context)); + this._logService.trace(`[AgentService] getChatMessages start: chat=${chat.toString()}`); + const providerTurns = await provider.chats.getMessages(chat, context); + this._logService.trace(`[AgentService] getChatMessages: provider returned ${providerTurns.length} turn(s) for chat=${chat.toString()}`); + const turns = await this._applyPersistedTurnUsage(chat, providerTurns); // Host-owned worktree restore announcement: re-inject the "Created isolated // worktree" message at the top of the default chat's first turn from // persisted metadata. No-op for folder sessions and non-default chats (peer @@ -3111,6 +3114,7 @@ export class AgentService extends Disposable implements IAgentService { } try { usages = await ref.object.getTurnUsages(); + this._logService.trace(`[AgentService] getTurnUsages done: ${usages.size} row(s) for ${storage.toString()}`); } catch (err) { this._logService.warn(`[AgentService] Failed to read persisted turn usage for ${storage.toString()}`, err); return turns; @@ -3790,6 +3794,7 @@ export class AgentService extends Disposable implements IAgentService { } let snapshot = this._stateManager.getSnapshot(resourceStr); + const servedFromMemory = !!snapshot; const parsedChangeset = parseChangesetUri(resourceStr); if (snapshot && parsedChangeset && !this._stateManager.getSessionState(parsedChangeset.sessionUri)) { await this._changesetCoordinator.restoreSessionIfChangesetSubscription(resource, s => this.restoreSession(s)); @@ -3876,6 +3881,7 @@ export class AgentService extends Disposable implements IAgentService { void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); } + this._logService.trace(`[AgentService] subscribe done: ${resourceStr} (servedFromMemory=${servedFromMemory})`); return snapshot; } catch (err) { this.unsubscribe(resource, clientId); @@ -4689,6 +4695,7 @@ export class AgentService extends Disposable implements IAgentService { const inFlight = this._restoreSessionInFlight.get(sessionStr); if (inFlight) { + this._logService.trace(`[AgentService] restoreSession: joining in-flight restore for ${sessionStr}`); return inFlight; } @@ -4696,10 +4703,12 @@ export class AgentService extends Disposable implements IAgentService { return; } + this._logService.trace(`[AgentService] restoreSession start: ${sessionStr}`); const restore = this._doRestoreSession(session, sessionStr); this._restoreSessionInFlight.set(sessionStr, restore); try { await restore; + this._logService.trace(`[AgentService] restoreSession done: ${sessionStr}`); } finally { if (this._restoreSessionInFlight.get(sessionStr) === restore) { this._restoreSessionInFlight.delete(sessionStr); @@ -4762,6 +4771,7 @@ export class AgentService extends Disposable implements IAgentService { } const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); const external = registeredSession?.external ?? false; + this._logService.trace(`[AgentService] restore: catalog and registry resolved for ${sessionStr} (registered=${!!registeredSession}, external=${external})`); // Adopt-on-open for a surfaced un-adopted legacy Copilot CLI session, strictly gated on the live migrate setting (a no-op for native / already-adopted sessions). const migrateLegacyEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; @@ -4891,6 +4901,7 @@ export class AgentService extends Disposable implements IAgentService { * fails so the caller can report the outcome accurately. */ private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], catalogReadable: boolean, sessionKnownToRegistry: boolean): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`); let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { // Authoritative absence only when the catalog was readable this run and @@ -4901,6 +4912,7 @@ export class AgentService extends Disposable implements IAgentService { ? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`) : new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`); } + this._logService.trace(`[AgentService] restore: provider metadata resolved for ${sessionStr}`); // A freshly-adopted legacy session whose working directory is a // pre-existing git worktree keeps no worktree metadata (adoption seeds @@ -5079,6 +5091,7 @@ export class AgentService extends Disposable implements IAgentService { // Best-effort: fall back to agent-provided metadata } } + this._logService.trace(`[AgentService] restore: persisted session metadata read for ${sessionStr}`); // Encode isRead/isArchived as status bitmask flags let status: SessionStatus = SessionStatus.Idle; @@ -5127,6 +5140,7 @@ export class AgentService extends Disposable implements IAgentService { } this._invalidateSessionList(); this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); + this._logService.trace(`[AgentService] restore: hydrated state for ${sessionStr} with ${mergedTurns.length} turn(s)`); this._serverToolHost.advertise(sessionStr); // A freshly-adopted legacy session bridges its git checkpoints into the diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index fef93853ca12e3..775c30dc2747d3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2655,7 +2655,9 @@ export class CopilotAgentSession extends Disposable { } private async _computeMappedEvents(): Promise { + this._logService.trace(`[Copilot:${this.sessionId}] Reading persisted session events`); const events = await this._wrapper.session.getEvents(); + this._logService.trace(`[Copilot:${this.sessionId}] Read ${events.length} persisted event(s); reconstructing turns`); let db: ISessionDatabase | undefined; try { db = this._databaseRef.object; @@ -2668,6 +2670,7 @@ export class CopilotAgentSession extends Disposable { ? this._launchPlan.model : this._launchPlan.fallback.model, }); + this._logService.trace(`[Copilot:${this.sessionId}] Reconstructed ${result.turns.length} turn(s) from ${events.length} event(s)`); return result; } diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index d9426746d6b21f..21cb4023a8f193 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isGpt56Model } from './modelIdentifiers.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -19,16 +18,20 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set - this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch, worktreeBranchTrack, progress)); + this._gitService.addWorktree(repositoryRoot, { + path: worktree, + commitish: baseBranch, + newBranchName: branchName, + track: worktreeBranchTrack, + preferRemoteBranch: true, + onProgress: progress, + })); return { branchName, worktree, baseBranch }; }); const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 1a1f5c8bd568db..a74bd772cfd09f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -746,12 +746,35 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { } }); + (hasGit ? test : test.skip)('addWorktree attaches a worktree without creating a new branch', async () => { + const dir = initRepo(); + cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); + const wtPath = join(dir, '..', `wt-${Date.now()}`); + try { + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'feature', + track: false, + }); + + assert.strictEqual(cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(), 'feature'); + } finally { + try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ } + rmDirWithRetry(wtPath); + } + }); + (hasGit ? test : test.skip)('removeWorktree preserves dirty work unless forced', async () => { const dir = initRepo(); const fs = await import('fs/promises'); const wtPath = join(dir, '..', `wt-dirty-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/dirty-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/dirty-worktree', + track: false, + }); await fs.writeFile(join(wtPath, 'untracked.txt'), 'keep me'); let safeRemovalFailed = false; @@ -785,7 +808,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const suffix = `wt-prune-${Date.now()}`; const wtPath = join(dir, '..', suffix); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/prune-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/prune-worktree', + track: false, + }); // Reproduce the CI teardown race: the working tree directory is gone // but git still holds the `.git/worktrees/` admin entry, so a plain // `git worktree remove` fails โ€” removeWorktree must fall back to prune. @@ -815,7 +843,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', suffix); let worktreeLocked = false; try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/leak-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/leak-worktree', + track: false, + }); cp.execFileSync('git', ['worktree', 'lock', wtPath], { cwd: dir, env, stdio: 'pipe' }); worktreeLocked = true; // A locked missing worktree makes prune exit 0 while retaining the admin entry on every OS. @@ -846,7 +879,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const suffix = `wt-orphan-${Date.now()}`; const wtPath = join(dir, '..', suffix); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/orphan-worktree', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/orphan-worktree', + track: false, + }); // De-register the worktree (delete git's admin entries) while leaving the working-tree directory in place. const adminRoot = join(dir, '.git', 'worktrees'); for (const entry of readdirSync(adminRoot)) { @@ -896,7 +934,13 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', `wt-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/test-origin-start-point', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/test-origin-start-point', + preferRemoteBranch: true, + track: false, + }); const stat = await fs.stat(join(wtPath, 'upstream.txt')); assert.ok(stat.isFile(), 'worktree should start from origin/main, not stale local main'); assert.throws(() => cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: wtPath, env, stdio: 'pipe' }), /fatal:/); @@ -941,7 +985,12 @@ suite('AgentHostGitService - worktree helpers (real git)', () => { const wtPath = join(dir, '..', `wt-${Date.now()}`); try { - await svc!.addWorktree(URI.file(dir), URI.file(wtPath), 'agents/include-files', 'main'); + await svc!.addWorktree(URI.file(dir), { + path: URI.file(wtPath), + commitish: 'main', + newBranchName: 'agents/include-files', + track: false, + }); const progress: { filesDone: number; filesTotal: number }[] = []; await svc!.copyWorktreeIncludeFiles(URI.file(dir), URI.file(wtPath), ['.env', 'secrets/**', 'partial/*.txt', 'app/**'], sample => progress.push(sample)); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 688f6882ceb241..631d9fdd728b59 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -53,7 +53,7 @@ import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } fro import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPromptCache, IAgentHostPromptCache } from '../../node/agentHostPromptCache.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../node/agentHostSessionTitleSignal.js'; -import { IAgentHostGitService, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; +import { IAgentHostGitService, type IAddWorktreeOptions, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; @@ -263,7 +263,7 @@ class TestAgentHostGitService implements IAgentHostGitService { repositoryRoot: URI | undefined = undefined; headCommit: string | undefined = '0'.repeat(40); - addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; + addedWorktrees: { repositoryRoot: URI; options: IAddWorktreeOptions }[] = []; addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; existingBranches = new Set(); @@ -276,9 +276,11 @@ class TestAgentHostGitService implements IAgentHostGitService { async getBranches(): Promise { return []; } async getRepositoryRoot(): Promise { return this.repositoryRoot; } async getWorktreeRoots(): Promise { return []; } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); - this.existingBranches.add(branchName); + async addWorktree(repositoryRoot: URI, options: IAddWorktreeOptions): Promise { + this.addedWorktrees.push({ repositoryRoot, options }); + if (options.newBranchName) { + this.existingBranches.add(options.newBranchName); + } } async copyWorktreeIncludeFiles(): Promise { } async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index bb4f35c414038a..00835e39c023fb 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -34,6 +34,10 @@ function mockProject(provider: AgentProvider) { return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; } +function mockWorkspacePath(relativePath: string): string { + return join(process.env['VSCODE_AGENT_HOST_MOCK_WORKSPACE'] ?? process.cwd(), relativePath); +} + interface IMockSendMessageCall { readonly session: URI; readonly prompt: string; @@ -710,7 +714,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: join(process.cwd(), 'src/app.ts') })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: mockWorkspacePath('src/app.ts') })); // Auto-approved writes resolve immediately โ€” complete the tool and turn await timeout(10); this._fireSequence([ @@ -729,7 +733,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: join(process.cwd(), '.env'), confirmationTitle: 'Write .env' })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: mockWorkspacePath('.env'), confirmationTitle: 'Write .env' })); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { @@ -817,7 +821,7 @@ export class ScriptedMockAgent implements IAgent { this._onDidChatProgress.fire(s); } await timeout(5); - this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: join(process.cwd(), 'file.ts') })); + this._onDidChatProgress.fire(_pendingConfirmation(chat, 'tc-orphan', 'Read file', { permissionKind: 'read', permissionPath: mockWorkspacePath('file.ts') })); })(); this._pendingPermissions.set('tc-orphan', (approved) => { if (approved) { diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts index b82f7f03968c8b..19fdb7746a8cb7 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; @@ -24,15 +27,18 @@ suite('Protocol WebSocket โ€” Permissions & Auto-Approve', function () { let server: IServerHandle; let client: TestProtocolClient; + let workspace: string; suiteSetup(async function () { this.timeout(getAgentHostE2ETestTimeout(15_000, 60_000)); - server = await startServer(); + workspace = mkdtempSync(join(tmpdir(), 'agent-host-tool-approval-')); + server = await startServer({ env: { VSCODE_AGENT_HOST_MOCK_WORKSPACE: workspace } }); }); suiteTeardown(async function () { this.timeout(getAgentHostE2ETestTimeout(20_000, 50_000)); await stopServer(server); + rmSync(workspace, { recursive: true, force: true }); }); setup(async function () { @@ -82,7 +88,7 @@ suite('Protocol WebSocket โ€” Permissions & Auto-Approve', function () { test('auto-approves write to regular file (no pending confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', URI.file(workspace).toString()); client.clearReceived(); // Start a turn that triggers a write permission request for a regular .ts file @@ -108,7 +114,7 @@ suite('Protocol WebSocket โ€” Permissions & Auto-Approve', function () { test('blocks write to .env file (requires manual confirmation)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', URI.file(workspace).toString()); client.clearReceived(); // Start a turn that tries to write .env (blocked by default patterns) @@ -196,7 +202,7 @@ suite('Protocol WebSocket โ€” Permissions & Auto-Approve', function () { test('dispatches pending_confirmation that arrives without an active turn (does not hang)', async function () { this.timeout(10_000); - const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', URI.file(process.cwd()).toString()); + const sessionUri = await createAndSubscribeSession(client, 'test-orphan-confirmation', URI.file(workspace).toString()); client.clearReceived(); // The mock completes the turn, then simulates a hook-triggered diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 24497ef0e8cb9e..f4e59d4424915d 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -13,7 +13,7 @@ import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; -import { GitRefType, IAgentHostGitService } from '../../../common/agentHostGitService.js'; +import { GitRefType, IAgentHostGitService, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; @@ -45,7 +45,7 @@ suite('WorktreeIsolation', () => { let repoRoot: URI; let worktreesRoot: URI; let db: TestSessionDatabase; - let addWorktreeCalls: { worktree: URI; branchName: string; startPoint: string; track: boolean }[]; + let addWorktreeCalls: IAddWorktreeOptions[]; let addExistingCalls: { worktree: URI; branchName: string }[]; let removeCalls: { worktree: URI; force: boolean }[]; let copyIncludeCalls: { repositoryRoot: URI; worktree: URI; globs: readonly string[] }[]; @@ -71,9 +71,9 @@ suite('WorktreeIsolation', () => { ], branchExists: async () => branchExists, hasUncommittedChanges: async () => hasUncommittedChanges, - addWorktree: async (_root, worktree, branch, startPoint, track) => { - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktree: async (_root, options) => { + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); }, copyWorktreeIncludeFiles: async (repositoryRoot, worktree, globs) => { copyIncludeCalls.push({ repositoryRoot, worktree, globs: [...globs] }); @@ -197,7 +197,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ branchDefault: config.branchDefault, branchEnum: config.branchProperty?.protocol.enum, - startPoint: addWorktreeCalls[0]?.startPoint, + startPoint: addWorktreeCalls[0]?.commitish, }, { branchDefault: 'main', branchEnum: ['main'], @@ -218,7 +218,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ returnedWorktree: first!.toString(), addWorktreeCallCount: addWorktreeCalls.length, - addWorktreeArgs: addWorktreeCalls.map(c => ({ worktree: c.worktree.toString(), branchName: c.branchName, startPoint: c.startPoint })), + addWorktreeArgs: addWorktreeCalls.map(c => ({ worktree: c.path.toString(), branchName: c.newBranchName, startPoint: c.commitish })), metaBranch: meta?.branchName, metaWorktree: meta?.worktreePath?.toString(), metaRepo: meta?.repositoryRoot?.toString(), @@ -246,10 +246,10 @@ suite('WorktreeIsolation', () => { let addWorktreeRoot: URI | undefined; gitService.getRepositoryRoot = async () => checkoutRoot; gitService.getWorktreeRoots = async () => [repoRoot, checkoutRoot]; - gitService.addWorktree = async (repositoryRoot, worktree, branch, startPoint, track) => { + gitService.addWorktree = async (repositoryRoot, options) => { addWorktreeRoot = repositoryRoot; - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); }; const isolation = createIsolation(disposables, { gitService }); const includeFiles = ['.env']; @@ -309,14 +309,14 @@ suite('WorktreeIsolation', () => { test('resolveWorkingDirectory names each creation phase, rounding percentages down and debouncing updates', async () => { const gitService = createGitService(); - gitService.addWorktree = async (_root, worktree, branch, startPoint, track, onProgress) => { - addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); - mkdirSync(worktree.fsPath, { recursive: true }); - onProgress?.({ filesDone: 7, filesTotal: 800 }); - onProgress?.({ filesDone: 96, filesTotal: 800 }); - onProgress?.({ filesDone: 100, filesTotal: 800 }); + gitService.addWorktree = async (_root, options) => { + addWorktreeCalls.push(options); + mkdirSync(options.path.fsPath, { recursive: true }); + options.onProgress?.({ filesDone: 7, filesTotal: 800 }); + options.onProgress?.({ filesDone: 96, filesTotal: 800 }); + options.onProgress?.({ filesDone: 100, filesTotal: 800 }); await timeout(50); - onProgress?.({ filesDone: 800, filesTotal: 800 }); + options.onProgress?.({ filesDone: 800, filesTotal: 800 }); }; gitService.copyWorktreeIncludeFiles = async (_root, _worktree, _globs, onProgress) => { onProgress?.({ filesDone: 1, filesTotal: 4 }); @@ -368,7 +368,7 @@ suite('WorktreeIsolation', () => { }); assert.deepStrictEqual({ - branchName: addWorktreeCalls[0]?.branchName, + branchName: addWorktreeCalls[0]?.newBranchName, worktree: resolved?.toString(), }, { branchName: 'agents/add-feature-12345678', @@ -402,7 +402,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ branchExistsCalls, - branchName: addWorktreeCalls[0]?.branchName, + branchName: addWorktreeCalls[0]?.newBranchName, worktree: resolved?.toString(), }, { branchExistsCalls: 2, @@ -421,13 +421,15 @@ suite('WorktreeIsolation', () => { gitService.getRepositoryRoot = async workingDirectory => workingDirectory; gitService.getWorktreeRoots = async () => [repoRoot, checkoutRootA, checkoutRootB]; gitService.branchExists = async (_repositoryRoot, candidate) => existingBranches.has(candidate); - gitService.addWorktree = async (_repositoryRoot, worktree, candidate, startPoint, track) => { + gitService.addWorktree = async (_repositoryRoot, options) => { activeAddWorktrees++; maxActiveAddWorktrees = Math.max(maxActiveAddWorktrees, activeAddWorktrees); await timeout(10); - addWorktreeCalls.push({ worktree, branchName: candidate, startPoint, track }); - existingBranches.add(candidate); - mkdirSync(worktree.fsPath, { recursive: true }); + addWorktreeCalls.push(options); + if (options.newBranchName) { + existingBranches.add(options.newBranchName); + } + mkdirSync(options.path.fsPath, { recursive: true }); activeAddWorktrees--; }; const isolation = createIsolation(disposables, { @@ -443,7 +445,7 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ maxActiveAddWorktrees, - branchNames: addWorktreeCalls.map(call => call.branchName), + branchNames: addWorktreeCalls.map(call => call.newBranchName), worktrees: worktrees.map(worktree => worktree?.toString()), }, { maxActiveAddWorktrees: 1, diff --git a/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts index 7e53c98b9ac889..6ca4513c22b52f 100644 --- a/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts +++ b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts @@ -48,9 +48,9 @@ suite('toolSearchDeferral', () => { } }); - test('supports OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { + test('temporarily rejects OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 variants', () => { for (const id of ['gpt-5.4', 'gpt-5.5', 'gpt-5-4', 'gpt-5-5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { - assert.strictEqual(agentHostModelSupportsToolSearch(id), true, id); + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); } }); diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 06c5f99ba012a7..33a8a3dfaf07fa 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -446,6 +446,7 @@ export class HoverWidget extends Widget implements IHoverWidget { private computeXCordinate(target: TargetRect): void { const hoverWidth = this._hover.containerDomNode.clientWidth + Constants.HoverBorderWidth; + const documentElementClientLeft = this._targetDocumentElement.clientLeft; if (this._target.x !== undefined) { this._x = this._target.x; @@ -469,14 +470,15 @@ export class HoverWidget extends Widget implements IHoverWidget { } // Hover is going beyond window towards right end - if (this._x + hoverWidth >= this._targetDocumentElement.clientWidth) { + const documentElementClientWidth = this._targetDocumentElement.clientWidth; + if (this._x + hoverWidth >= documentElementClientWidth) { this._hover.containerDomNode.classList.add('right-aligned'); - this._x = Math.max(this._targetDocumentElement.clientWidth - hoverWidth - Constants.HoverWindowEdgeMargin, this._targetDocumentElement.clientLeft); + this._x = Math.max(documentElementClientWidth - hoverWidth - Constants.HoverWindowEdgeMargin, documentElementClientLeft); } } // Hover is going beyond window towards left end - if (this._x < this._targetDocumentElement.clientLeft) { + if (this._x < documentElementClientLeft) { this._x = target.left + Constants.HoverWindowEdgeMargin; } diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts index acd582010c4486..4810077c0abd42 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts +++ b/src/vs/sessions/browser/parts/mobile/contributions/mobileMultiDiffView.ts @@ -340,7 +340,8 @@ export class MobileMultiDiffView extends Disposable { return; } - const layout = this.computeCurrentVirtualLayout(); + const viewportHeight = this.scrollWrapper.clientHeight; + const layout = this.computeCurrentVirtualLayout(viewportHeight); this.currentLayout = layout; this.virtualContent.style.height = `${layout.totalHeight}px`; @@ -357,7 +358,7 @@ export class MobileMultiDiffView extends Disposable { for (const item of layout.items) { const state = this.fileStates[item.index]; const section = this.ensureFileSection(state); - this.applyVirtualLayout(section, state, item); + this.applyVirtualLayout(section, state, item, viewportHeight); if (!this.mountedIndexes.has(item.index)) { this.mountedIndexes.add(item.index); } @@ -375,18 +376,18 @@ export class MobileMultiDiffView extends Disposable { } } - private applyVirtualLayout(section: HTMLElement, state: IMobileMultiDiffFileState, item: IMobileMultiDiffVirtualItemLayout): void { + private applyVirtualLayout(section: HTMLElement, state: IMobileMultiDiffFileState, item: IMobileMultiDiffVirtualItemLayout, viewportHeight: number): void { section.style.top = `${item.renderTop}px`; section.style.height = `${item.renderHeight}px`; const bodyOffset = Math.max(0, item.innerOffset - VIRTUALIZER_METRICS.fileHeaderHeight); state.bodyScrollTop = bodyOffset; - state.bodyViewportHeight = Math.max(0, this.scrollWrapper.clientHeight - VIRTUALIZER_METRICS.fileHeaderHeight); + state.bodyViewportHeight = Math.max(0, viewportHeight - VIRTUALIZER_METRICS.fileHeaderHeight); const content = state.content!; content.classList.toggle('mobile-multi-diff-file-content-placeholder', state.loadState !== 'loaded'); if (state.loadState === 'loaded') { content.style.height = ''; content.style.transform = ''; - this.renderLoadedFileContent(state); + this.renderLoadedFileContent(state, viewportHeight); } else { const bodyHeight = Math.max(0, item.renderHeight - VIRTUALIZER_METRICS.fileHeaderHeight); const placeholderHeight = Math.min( @@ -452,12 +453,12 @@ export class MobileMultiDiffView extends Disposable { empty.style.height = `${visibleHeight}px`; } - private renderLoadedFileContent(state: IMobileMultiDiffFileState): void { + private renderLoadedFileContent(state: IMobileMultiDiffFileState, viewportHeight = this.scrollWrapper.clientHeight): void { if (!state.content || !state.renderData) { return; } - const bodyOverscan = Math.max(this.scrollWrapper.clientHeight, 480); + const bodyOverscan = Math.max(viewportHeight, 480); const visibleTop = Math.max(0, state.bodyScrollTop - bodyOverscan); const visibleBottom = Math.min( state.renderData.bodyHeight, @@ -488,11 +489,11 @@ export class MobileMultiDiffView extends Disposable { }; } - private computeCurrentVirtualLayout(): ReturnType { + private computeCurrentVirtualLayout(viewportHeight: number): ReturnType { return computeMobileMultiDiffVirtualLayout(this.fileStates.map(state => this.toVirtualItem(state)), { - viewportHeight: this.scrollWrapper.clientHeight, + viewportHeight, scrollTop: this.scrollWrapper.scrollTop, - overscan: Math.max(this.scrollWrapper.clientHeight, 480), + overscan: Math.max(viewportHeight, 480), metrics: VIRTUALIZER_METRICS, }); } diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index b90338250a52d9..fef3a3bc238e8e 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -382,6 +382,7 @@ export class ChatView extends AbstractChatView { this._currentChatResource = resource; this._currentChatResourceObs.set(resource, undefined); + this.logService.trace(`[ChatView] setChat start uri=${resource.toString()} session=${session?.resource.toString()}`); // Cancel any in-flight load for the previous chat and start a fresh one. this._loadCts.value?.cancel(); @@ -403,8 +404,10 @@ export class ChatView extends AbstractChatView { if (isEqual(this._currentChatResource, resource)) { this._widget.setLoading(false); } + this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); return; } + this.logService.trace(`[ChatView] setChat model loaded uri=${resource.toString()}`); this._modelRef.value = ref; this._updateWidgetLockState(getChatSessionType(ref.object.sessionResource)); setModelPreservingInputTypedWhileLoading(this._widget, inputBeforeLoad, () => this._widget.setModel(ref.object)); @@ -418,9 +421,12 @@ export class ChatView extends AbstractChatView { // Set AFTER `setModel` so observers see the attribute only once the // inner widget is fully attached to the loaded model. this.element.dataset.boundChatResource = resource.toString(); + this.logService.trace(`[ChatView] setChat done uri=${resource.toString()}`); }, err => { if (!token.isCancellationRequested) { - this.logService.error('[ChatView] Failed to load chat model for chat', err); + this.logService.error(`[ChatView] Failed to load chat model for chat uri=${resource.toString()}`, err); + } else { + this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); } if (isEqual(this._currentChatResource, resource)) { // might have changed while we were waiting, only reset if it is still the same this._currentChatResource = undefined; diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 1080cc97fe0e70..4eb65c425895fd 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -187,6 +187,31 @@ export function computePullRequestIcon(state: GitHubPullRequestState | 'draft', } } +/** Coarse pull request state, recoverable from the icon carried on session GitHub info. */ +export type PullRequestStatus = 'open' | 'closed' | 'merged' | 'draft'; + +/** + * Inverse of {@link computePullRequestIcon}: recovers the coarse pull request + * status from an icon. Returns `undefined` when the icon is missing or is not + * one of the known pull request icons (i.e. the state was never resolved). + */ +export function getPullRequestStatusFromIcon(icon: ThemeIcon | undefined): PullRequestStatus | undefined { + switch (icon?.id) { + case Codicon.gitPullRequestDone.id: + return 'merged'; + case Codicon.gitPullRequestClosed.id: + return 'closed'; + case Codicon.gitPullRequestDraft.id: + return 'draft'; + case Codicon.gitPullRequest.id: + case Codicon.gitPullRequestError.id: + case Codicon.gitPullRequestComment.id: + return 'open'; + default: + return undefined; + } +} + //#endregion //#region Issues diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts index 2bcd980c32d8c9..68d0a8defeb620 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsLifecycleTracker.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ISession } from '../../../services/sessions/common/session.js'; +import { getPullRequestStatusFromIcon, PullRequestStatus } from '../../github/common/types.js'; import { classifySessionWorkspaceTopology, getSessionsTelemetryProviderId, hashSessionIdForTelemetry } from '../../../common/sessionsTelemetry.js'; /** Storage key for the cumulative number of times this client has been launched. */ @@ -60,6 +61,9 @@ interface IStoredSessionStats { isolationKind: 'worktree' | 'folder'; hasGitRepository: boolean; isVirtualWorkspace: boolean; + // Optional so rows persisted before the field existed still load; + // `createEntry` always sets it and `buildSummary` defaults it. + isExternal?: boolean; // Topology fields are optional so rows persisted before they existed still // load; `createEntry` always sets them and `buildSummary` defaults them. isMultiRoot?: boolean; @@ -110,6 +114,10 @@ interface IStoredSessionStats { filesChanged: number; linesAdded: number; linesDeleted: number; + // Pull requests observed on the session. Optional so rows persisted before + // the fields existed still load; `buildSummary` defaults them. + pullRequestCount?: number; + pullRequestStatus?: PullRequestStatus; } /** @@ -125,6 +133,7 @@ export interface ISessionLifecycleSummary { workspaceHash: string; hasGitRepository: boolean; isVirtualWorkspace: boolean; + isExternal: boolean; isMultiRoot: boolean; folderCount: number; gitFolderCount: number; @@ -161,6 +170,8 @@ export interface ISessionLifecycleSummary { filesChanged: number; linesAdded: number; linesDeleted: number; + pullRequestCount: number; + pullRequestStatus: PullRequestStatus | undefined; userSessionsTotal: number; userSessionsInWorkspace: number; userSessionsForProvider: number; @@ -214,7 +225,7 @@ export class SessionsLifecycleTracker extends Disposable { entry.firstRequestSentAt = Date.now(); entry.firstRequestSentInThisClient = true; } - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } @@ -237,17 +248,17 @@ export class SessionsLifecycleTracker extends Disposable { bumpCounter(session: ISession, key: SessionLifecycleCounterKey): void { const entry = this._ensure(session); entry[key]++; - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } - /** Refresh observed change summary for a tracked session. No-op when not tracked. */ + /** Refresh observed session state (pull requests, changes) for a tracked session. No-op when not tracked. */ updateSessionState(session: ISession): void { const entry = this._stats.get(session.sessionId); if (!entry) { return; } - this._updateChangesSummary(entry, session); + this._updateObservedState(entry, session); this._save(); } @@ -314,7 +325,7 @@ export class SessionsLifecycleTracker extends Disposable { return undefined; } if (finalSession) { - this._updateChangesSummary(entry, finalSession); + this._updateObservedState(entry, finalSession); } this._stats.delete(sessionId); this._save(); @@ -375,6 +386,31 @@ export class SessionsLifecycleTracker extends Disposable { return entry; } + /** + * Refreshes the parts of the entry that mirror live session state, so the + * summary reports what was last observed rather than what was known when + * tracking started. + */ + private _updateObservedState(entry: IStoredSessionStats, session: ISession): void { + // Provenance is only known once the session metadata has loaded, which + // may happen after the entry was created. + entry.isExternal = session.isExternal?.get() ?? entry.isExternal ?? false; + this._updatePullRequestState(entry, session); + this._updateChangesSummary(entry, session); + } + + private _updatePullRequestState(entry: IStoredSessionStats, session: ISession): void { + const gitHubInfo = session.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); + if (!gitHubInfo) { + // Keep the last known values: GitHub info is resolved asynchronously + // and is absent for sessions without a GitHub repository. + return; + } + const pullRequests = gitHubInfo.pullRequests; + entry.pullRequestCount = pullRequests?.length ?? (gitHubInfo.pullRequest ? 1 : 0); + entry.pullRequestStatus = getPullRequestStatusFromIcon(gitHubInfo.pullRequest?.icon ?? pullRequests?.[0]?.icon); + } + private _updateChangesSummary(entry: IStoredSessionStats, session: ISession): void { const summary = session.changesSummary?.get(); if (summary) { @@ -460,6 +496,7 @@ function createEntry(session: ISession, appLaunchCount: number): IStoredSessionS isolationKind: hasWorktree ? 'worktree' : 'folder', hasGitRepository: hasGit, isVirtualWorkspace: isVirtual, + isExternal: session.isExternal?.get() ?? false, isMultiRoot: topology.isMultiRoot, folderCount: topology.folderCount, gitFolderCount: topology.gitFolderCount, @@ -495,6 +532,8 @@ function createEntry(session: ISession, appLaunchCount: number): IStoredSessionS filesChanged: 0, linesAdded: 0, linesDeleted: 0, + pullRequestCount: 0, + pullRequestStatus: undefined, }; } @@ -509,6 +548,7 @@ function buildSummary(sessionId: string, entry: IStoredSessionStats, reason: Ses hasGitRepository: entry.hasGitRepository, isVirtualWorkspace: entry.isVirtualWorkspace, // Back-compat: entries persisted before these fields existed default to 0/false. + isExternal: entry.isExternal ?? false, isMultiRoot: entry.isMultiRoot ?? false, folderCount: entry.folderCount ?? 0, gitFolderCount: entry.gitFolderCount ?? 0, @@ -545,6 +585,8 @@ function buildSummary(sessionId: string, entry: IStoredSessionStats, reason: Ses filesChanged: entry.filesChanged, linesAdded: entry.linesAdded, linesDeleted: entry.linesDeleted, + pullRequestCount: entry.pullRequestCount ?? 0, + pullRequestStatus: entry.pullRequestStatus, userSessionsTotal: requestCounters.userSessionsTotal, userSessionsInWorkspace: requestCounters.userSessionsInWorkspace, userSessionsForProvider: requestCounters.userSessionsForProvider, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index 49f66163d08d8f..b80746008ae6cd 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -536,6 +536,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe providerId: getSessionsTelemetryProviderId(session.providerId), providerType: session.sessionType, chatCount: session.chats.get().length, + isExternal: session.isExternal?.get() ?? false, }; } @@ -665,6 +666,13 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe const inAll: ISession[] = []; for (const session of allSessions) { + // The anchor session is the subject of the event, so counting it + // would inflate every bucket by one for the very session being + // reported on. Compare by id because providers hand out a fresh + // session object on update. + if (session.sessionId === anchorSession.sessionId) { + continue; + } if (session.isArchived.get()) { continue; } @@ -748,6 +756,7 @@ type SessionFields = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; }; type ChatFields = { @@ -810,6 +819,7 @@ type SessionRequestSentEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; chatModeKind: string; isolationKind: SessionIsolationKind; workspaceHash: string; @@ -852,6 +862,7 @@ type SessionActionEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -874,6 +885,7 @@ type SessionRequestSentClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; chatModeKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Built-in chat mode kind (e.g., ask, agent, edit); empty when no mode is selected.' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; @@ -889,18 +901,18 @@ type SessionRequestSentClassification = { fileAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of file attachments included with the request.' }; imageAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of image attachments included with the request.' }; attachmentKinds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Stringified JSON object mapping each attachment kind (e.g. file, image, symbol) to its count for this request.' }; - currentWorkspaceFolderInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace using folder isolation.' }; - currentWorkspaceFolderUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace using folder isolation.' }; - currentWorkspaceFolderWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace using folder isolation.' }; - currentWorkspaceFolderNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace using folder isolation.' }; - currentWorkspaceInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace across all isolation modes.' }; - currentWorkspaceUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace across all isolation modes.' }; - currentWorkspaceWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace across all isolation modes.' }; - currentWorkspaceNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace across all isolation modes.' }; - allWorkspacesInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions across all workspaces.' }; - allWorkspacesUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions across all workspaces.' }; - allWorkspacesWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input across all workspaces.' }; - allWorkspacesNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done across all workspaces.' }; + currentWorkspaceFolderInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceFolderNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace using folder isolation, excluding the session this request was sent to.' }; + currentWorkspaceInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + currentWorkspaceNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace across all isolation modes, excluding the session this request was sent to.' }; + allWorkspacesInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input across all workspaces, excluding the session this request was sent to.' }; + allWorkspacesNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done across all workspaces, excluding the session this request was sent to.' }; userSessionsTotal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started from the Agents window across all workspaces and providers. Incremented only when `isNewSession` is true.' }; userSessionsInWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started in the current workspace. Incremented only when `isNewSession` is true.' }; userSessionsForProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started for this sessions provider across all workspaces. Incremented only when `isNewSession` is true.' }; @@ -913,6 +925,7 @@ type SessionArchivedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -930,6 +943,7 @@ type SessionUnarchivedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -947,6 +961,7 @@ type SessionDeletedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -964,6 +979,7 @@ type ChatDeletedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -981,6 +997,7 @@ type ChatRenamedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -998,6 +1015,7 @@ type SessionRenamedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1015,6 +1033,7 @@ type CreatePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1032,6 +1051,7 @@ type CreateDraftPullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1049,6 +1069,7 @@ type UpdatePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1066,6 +1087,7 @@ type MergePullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1083,6 +1105,7 @@ type CheckoutPullRequestClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1100,6 +1123,7 @@ type InitializeRepositoryClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1117,6 +1141,7 @@ type CommitClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1134,6 +1159,7 @@ type CommitAndSyncClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1151,6 +1177,7 @@ type SessionRestoredClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1168,6 +1195,7 @@ type FixCIChecksClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1185,6 +1213,7 @@ type FeedbackAddedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1204,6 +1233,7 @@ type FeedbackAddedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1221,6 +1251,7 @@ type FeedbackConvertedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1241,6 +1272,7 @@ type FeedbackConvertedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1259,6 +1291,7 @@ type FeedbackReplyAddedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1278,6 +1311,7 @@ type FeedbackReplyAddedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1295,6 +1329,7 @@ type FeedbackSubmittedEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1317,6 +1352,7 @@ type FeedbackSubmittedClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1339,6 +1375,7 @@ type SessionStickinessToggledEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1357,6 +1394,7 @@ type SessionStickinessToggledClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1373,6 +1411,7 @@ type SessionMaximizeToggledEvent = { providerId: string; providerType: string; chatCount: number; + isExternal: boolean; isolationKind: SessionIsolationKind; workspaceHash: string; hasGitRepository: boolean; @@ -1391,6 +1430,7 @@ type SessionMaximizeToggledClassification = { providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded sessions provider category: default-copilot, local-agent-host, remote-agent-host, or other.' }; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; @@ -1414,6 +1454,7 @@ type SessionSummaryClassification = { workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI the session is tied to, used to correlate events across the same workspace without disclosing the path.' }; hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository, captured the first time the session was observed in this client.' }; isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote), captured the first time the session was observed in this client.' }; + isExternal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the session was discovered in an application other than the current host (an external session).' }; isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one workspace folder, captured the first time the session was observed in this client.' }; folderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of workspace folders in the session, captured the first time the session was observed in this client (browser-projected metadata).' }; gitFolderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of workspace folders backed by a git repository, captured the first time the session was observed in this client.' }; @@ -1450,6 +1491,8 @@ type SessionSummaryClassification = { filesChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of changed files in the session at the moment the summary was emitted.' }; linesAdded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total lines added across all changed files in the session at the moment the summary was emitted.' }; linesDeleted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total lines deleted across all changed files in the session at the moment the summary was emitted.' }; + pullRequestCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of pull requests associated with the session as last observed in this client; 0 when the session never had one.' }; + pullRequestStatus: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'State of the session\'s most recent pull request as last observed in this client (open, closed, merged or draft); undefined when the session has no pull request or its state was never resolved.' }; userSessionsTotal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started from the Agents window across all workspaces and providers at the moment the summary was emitted.' }; userSessionsInWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started in the current workspace at the moment the summary was emitted.' }; userSessionsForProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of new sessions the user has started for this sessions provider across all workspaces at the moment the summary was emitted.' }; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index b22a8d4c953c17..20bc543c84dcd2 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -446,7 +446,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { { id: SessionSectionToolbarMenuId, group: 'navigation', - order: 1, + order: 0, when: ContextKeyExpr.and( ChatContextKeys.enabled, SessionSectionHasNonCloudRepositoryContext, @@ -455,7 +455,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { { id: SessionSectionToolbarMenuId, group: 'navigation', - order: 1, + order: 0, when: ContextKeyExpr.and( ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), ContextKeyExpr.or( @@ -585,7 +585,7 @@ abstract class BaseArchiveSectionAction extends Action2 { menu: [{ id: SessionSectionToolbarMenuId, group: 'navigation', - order: 0, + order: 1, // Not on Done itself, and not on the "Chats" (quick chats) section. // Also not on Automations. when: ContextKeyExpr.and( diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts index ef14d05c64ce04..ad62c2b87a9fb4 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts @@ -6,11 +6,12 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { hash } from '../../../../../base/common/hash.js'; -import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { IChat, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IChat, IGitHubInfo, IGitHubPullRequestRef, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { MAX_TRACKED_SESSIONS, SESSIONS_KEY, SessionsLifecycleTracker } from '../../browser/sessionsLifecycleTracker.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -20,6 +21,7 @@ interface ICreateSessionOptions { workspace?: ISessionWorkspace; changes?: readonly ISessionFileChange[]; changesSummary?: ISessionChangesSummary; + isExternal?: IObservable; } function createSession(id: string, opts: ICreateSessionOptions = {}): ISession { @@ -49,6 +51,7 @@ function createSession(id: string, opts: ICreateSessionOptions = {}): ISession { chats: observableValue(`chats-${id}`, []), mainChat: constObservable(undefined!), capabilities: constObservable({ supportsMultipleChats: false }), + isExternal: opts.isExternal, }; } @@ -63,23 +66,33 @@ function createWorkspace(uri: URI, folders: ISessionFolder[]): ISessionWorkspace }; } -function createFolder(uri: URI, opts: { readonly workTreeUri?: URI; readonly withGitRepository?: boolean } = {}): ISessionFolder { +function createFolder(uri: URI, opts: { readonly workTreeUri?: URI; readonly withGitRepository?: boolean; readonly gitHubInfo?: IGitHubInfo } = {}): ISessionFolder { return { root: uri, workingDirectory: uri, name: 'folder', description: undefined, - gitRepository: (opts.withGitRepository || opts.workTreeUri) + gitRepository: (opts.withGitRepository || opts.workTreeUri || opts.gitHubInfo) ? { uri, workTreeUri: opts.workTreeUri, baseBranchName: undefined, - gitHubInfo: constObservable(undefined), + gitHubInfo: constObservable(opts.gitHubInfo), } : undefined, }; } +function createPullRequestRef(number: number, state: GitHubPullRequestState | 'draft'): IGitHubPullRequestRef { + return { + owner: 'microsoft', + repo: 'vscode', + number, + uri: URI.parse(`https://github.com/microsoft/vscode/pull/${number}`), + icon: computePullRequestIcon(state), + }; +} + suite('SessionsLifecycleTracker', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -352,6 +365,81 @@ suite('SessionsLifecycleTracker', () => { }); }); + test('summary reports whether the session is external, refreshed on later interactions', () => { + const isExternal = observableValue('isExternal', false); + const session = createSession('s1', { isExternal }); + + tracker.recordNewChatRequestSent(session); + isExternal.set(true, undefined); + tracker.bumpCounter(session, 'commit'); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + const plain = createSession('s2'); + tracker.recordNewChatRequestSent(plain); + const plainSummary = tracker.finalize(plain.sessionId, 'archived', plain); + + assert.deepStrictEqual({ + external: summary?.isExternal, + plain: plainSummary?.isExternal, + }, { + external: true, + plain: false, + }); + }); + + test('summary reports the pull request count and the status of the most recent pull request', () => { + const workspaceUri = URI.parse('file:///repo'); + const gitHubInfo: IGitHubInfo = { + owner: 'microsoft', + repo: 'vscode', + pullRequests: [createPullRequestRef(2, GitHubPullRequestState.Merged), createPullRequestRef(1, GitHubPullRequestState.Closed)], + pullRequest: { number: 2, uri: URI.parse('https://github.com/microsoft/vscode/pull/2'), icon: computePullRequestIcon(GitHubPullRequestState.Merged) }, + }; + const workspace = createWorkspace(workspaceUri, [createFolder(workspaceUri, { gitHubInfo })]); + const session = createSession('s1', { workspace }); + + tracker.recordNewChatRequestSent(session); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + const plain = createSession('s2'); + tracker.recordNewChatRequestSent(plain); + const plainSummary = tracker.finalize(plain.sessionId, 'archived', plain); + + assert.deepStrictEqual({ + pullRequestCount: summary?.pullRequestCount, + pullRequestStatus: summary?.pullRequestStatus, + plainPullRequestCount: plainSummary?.pullRequestCount, + plainPullRequestStatus: plainSummary?.pullRequestStatus, + }, { + pullRequestCount: 2, + pullRequestStatus: 'merged', + plainPullRequestCount: 0, + plainPullRequestStatus: undefined, + }); + }); + + test('summary reports a draft pull request status without a resolved main pull request icon', () => { + const workspaceUri = URI.parse('file:///repo'); + const gitHubInfo: IGitHubInfo = { + owner: 'microsoft', + repo: 'vscode', + pullRequests: [createPullRequestRef(7, 'draft')], + }; + const workspace = createWorkspace(workspaceUri, [createFolder(workspaceUri, { gitHubInfo })]); + const session = createSession('s1', { workspace }); + + tracker.recordNewChatRequestSent(session); + const summary = tracker.finalize(session.sessionId, 'archived', session); + + assert.deepStrictEqual({ + pullRequestCount: summary?.pullRequestCount, + pullRequestStatus: summary?.pullRequestStatus, + }, { + pullRequestCount: 1, + pullRequestStatus: 'draft', + }); + }); + test('recordFirstRequestTaskInfo is a no-op when the session is not tracked', () => { const session = createSession('s1'); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts index 4adea7f6f366e8..872772442f5d2c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTelemetry.contribution.test.ts @@ -7,6 +7,8 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -18,7 +20,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { ISearchService } from '../../../../../workbench/services/search/common/search.js'; import { IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; import { ISessionsTasksService } from '../../../chat/browser/sessionsTasksService.js'; -import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISendRequestSentEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -32,7 +34,18 @@ interface IRequestSentTelemetry { readonly attachmentKinds: string; } -function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry { +interface ISessionCountsTelemetry { + readonly currentWorkspaceInProgress: number; + readonly currentWorkspaceUnread: number; + readonly currentWorkspaceWaitingForInput: number; + readonly currentWorkspaceNotDone: number; + readonly allWorkspacesInProgress: number; + readonly allWorkspacesUnread: number; + readonly allWorkspacesWaitingForInput: number; + readonly allWorkspacesNotDone: number; +} + +function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry & ISessionCountsTelemetry { return typeof data === 'object' && data !== null && typeof Reflect.get(data, 'isNewSession') === 'boolean' @@ -43,6 +56,7 @@ function isRequestSentTelemetry(data: unknown): data is IRequestSentTelemetry { class TestTelemetryService extends NullTelemetryServiceShape { readonly requestSentEvents: IRequestSentTelemetry[] = []; + readonly sessionCounts: ISessionCountsTelemetry[] = []; override publicLog2(eventName?: string, data?: unknown): void { if (eventName === 'agents/requestSent' && isRequestSentTelemetry(data)) { @@ -52,6 +66,16 @@ class TestTelemetryService extends NullTelemetryServiceShape { totalAttachementCount: data.totalAttachementCount, attachmentKinds: data.attachmentKinds, }); + this.sessionCounts.push({ + currentWorkspaceInProgress: data.currentWorkspaceInProgress, + currentWorkspaceUnread: data.currentWorkspaceUnread, + currentWorkspaceWaitingForInput: data.currentWorkspaceWaitingForInput, + currentWorkspaceNotDone: data.currentWorkspaceNotDone, + allWorkspacesInProgress: data.allWorkspacesInProgress, + allWorkspacesUnread: data.allWorkspacesUnread, + allWorkspacesWaitingForInput: data.allWorkspacesWaitingForInput, + allWorkspacesNotDone: data.allWorkspacesNotDone, + }); } } } @@ -99,10 +123,23 @@ const session = { capabilities: constObservable({ supportsMultipleChats: true }), } satisfies ISession; +function createWorkspace(uri: URI): ISessionWorkspace { + return { + uri, + label: 'ws', + icon: ThemeIcon.fromId('folder'), + folders: [], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +const workspace = createWorkspace(URI.parse('file:///repo')); + suite('SessionsTelemetryContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('logs requestSent for new sessions, new chats, and follow-up messages', async () => { + function setup(sessions: readonly ISession[]): { telemetryService: TestTelemetryService; onDidSendRequest: Emitter } { const onDidSendRequest = disposables.add(new Emitter()); const sessionsManagementService = new class extends mock() { override readonly onWillSendRequest = Event.None; @@ -114,7 +151,7 @@ suite('SessionsTelemetryContribution', () => { override readonly onDidRenameChat = Event.None; override readonly onDidRenameSession = Event.None; override readonly onDidChangeSessions = Event.None; - override getSessions(): ISession[] { return [session]; } + override getSessions(): ISession[] { return [...sessions]; } }(); const sessionsService = new class extends mock() { override readonly visibleSessions = constObservable([]); @@ -147,7 +184,9 @@ suite('SessionsTelemetryContribution', () => { sessionsManagementService, sessionsService, telemetryService, - new class extends mock() { }(), + new class extends mock() { + override readonly extUri = extUri; + }(), storageService, new class extends mock() { }(), new class extends mock() { }(), @@ -158,6 +197,12 @@ suite('SessionsTelemetryContribution', () => { tasksService, )); + return { telemetryService, onDidSendRequest }; + } + + test('logs requestSent for new sessions, new chats, and follow-up messages', async () => { + const { telemetryService, onDidSendRequest } = setup([session]); + onDidSendRequest.fire({ session, chat, isNewSession: true, isNewChat: true, options: { query: 'new session' } }); onDidSendRequest.fire({ session, chat, isNewSession: false, isNewChat: true, options: { query: 'new chat' } }); onDidSendRequest.fire({ @@ -178,4 +223,28 @@ suite('SessionsTelemetryContribution', () => { { isNewSession: false, isNewChat: false, totalAttachementCount: 1, attachmentKinds: '{"generic":1}' }, ]); }); + + test('requestSent session counts exclude the session the request was sent to', async () => { + // The anchor is reported by a fresh session object with in-progress + // state, mirroring what a provider hands out right after a send. + const anchor = { ...session, status: constObservable(SessionStatus.InProgress), isRead: constObservable(false), workspace: constObservable(workspace) }; + const listedAnchor = { ...anchor }; + const otherInSameWorkspace = { ...anchor, sessionId: 'other', resource: URI.parse('test:///other') }; + const otherWorkspaceSession = { ...anchor, sessionId: 'elsewhere', resource: URI.parse('test:///elsewhere'), workspace: constObservable(createWorkspace(URI.parse('file:///other-repo'))) }; + const { telemetryService, onDidSendRequest } = setup([listedAnchor, otherInSameWorkspace, otherWorkspaceSession]); + + onDidSendRequest.fire({ session: anchor, chat, isNewSession: false, isNewChat: false, options: { query: 'hi' } }); + await Promise.resolve(); + + assert.deepStrictEqual(telemetryService.sessionCounts, [{ + currentWorkspaceInProgress: 1, + currentWorkspaceUnread: 1, + currentWorkspaceWaitingForInput: 0, + currentWorkspaceNotDone: 1, + allWorkspacesInProgress: 2, + allWorkspacesUnread: 2, + allWorkspacesWaitingForInput: 0, + allWorkspacesNotDone: 2, + }]); + }); }); diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 90d9bec4cc70c3..4e19d759fe9f15 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -786,6 +786,7 @@ export class SessionsService extends Disposable implements ISessionsService { } async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise { + this.logService.trace(`[SessionsView] openSession requested uri=${sessionResource.toString()}`); // Claim the open before resolving: resolution can take seconds for a legacy // Copilot CLI resource, and a newer open must win regardless of which // resolution finishes first. diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index 857ec74cde8293..2a737151e75f06 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -661,15 +661,20 @@ export class NotificationsToasts extends Themable implements INotificationsToast let singleToastHeightToGive = heightToGive; let multipleToastsHeightToGive = Math.round(heightToGive * 0.618); - let visibleToasts = 0; - for (const toast of this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE)) { - + const toasts = this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE); + for (const toast of toasts) { // In order to measure the client height, the element cannot have display: none toast.container.style.opacity = '0'; this.updateToastVisibility(toast, true); + } - singleToastHeightToGive -= toast.container.offsetHeight; - multipleToastsHeightToGive -= toast.container.offsetHeight; + const toastHeights = toasts.map(toast => toast.container.offsetHeight); + let visibleToasts = 0; + for (let i = 0; i < toasts.length; i++) { + const toast = toasts[i]; + const toastHeight = toastHeights[i]; + singleToastHeightToGive -= toastHeight; + multipleToastsHeightToGive -= toastHeight; let makeVisible = false; if (visibleToasts === NotificationsToasts.MAX_NOTIFICATIONS) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ab039beacfff6a..1ec57fa4094958 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1311,6 +1311,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // whether this session resource represents a new session that hasn't yet // been created on the backend. const isNewSession = this._isNewSessionResource(sessionResource); + this._logService.trace(`[AgentHost] provideChatSessionContent start: ${resolvedSession.toString()} (isNewSession=${isNewSession})`); const history: IChatSessionHistoryItem[] = []; let initialProgress: IChatProgress[] | undefined; let initialResponsePartCount = 0; @@ -1346,6 +1347,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (sub.value instanceof Error) { throw sub.value; } + this._logService.trace(`[AgentHost] provideChatSessionContent: session state hydrated for ${resolvedSession.toString()}`); const rawState = this._getRawSessionState(resolvedSession.toString()); if (!rawState) { throw new Error(`Session state did not hydrate for ${resolvedSession.toString()}`); @@ -1355,6 +1357,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const chatSub = this._ensureChatSubscription(resolvedSession.toString(), chatURI); chatSubscription = chatSub; await this._whenSubscriptionHydrated(chatSub, token); + this._logService.trace(`[AgentHost] provideChatSessionContent: chat state hydrated for ${chatURI}`); const sessionState = this._getSessionState(resolvedSession.toString(), chatURI); if (sessionState) { sessionTitle = sessionState.title; @@ -1374,11 +1377,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._chatErrorContext(), this._config.connection.initializeResult.get()?.terminalCommandPrefix, )); + this._logService.trace(`[AgentHost] provideChatSessionContent: converted ${sessionState.turns.length} turn(s) into ${history.length} history item(s) for ${resolvedSession.toString()}`); // Enrich history with inner tool calls from subagent // child sessions. Subscribes to each child session so // its tool calls appear grouped under the parent widget. await this._enrichHistoryWithSubagentCalls(history, resolvedSession, sessionResource, sessionState, historySubagentObservations); + this._logService.trace(`[AgentHost] provideChatSessionContent: subagent enrichment done for ${resolvedSession.toString()}`); // Store historical turns so the editing session can seed a // request-level checkpoint for each turn (with file edits @@ -1589,6 +1594,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + this._logService.trace(`[AgentHost] provideChatSessionContent done: ${resolvedSession.toString()} with ${history.length} history item(s)`); return session; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index c2de8ec1d05f3d..f4369e66c2795f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -99,6 +99,8 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes const instantiationService = accessor.get(IInstantiationService); const logService = accessor.get(ILogService); + logService.trace(`[AgentSessions] openSession start: ${session.resource.toString()}`); + // List and picker clicks arrive here with a resolved session, so the redirect // has to happen on this path too or those opens never migrate. A no-op for // anything that is not a superseded legacy resource. @@ -119,6 +121,7 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes try { const handled = await instantiationService.invokeFunction(accessor => participant.handleOpenSession(accessor, session, openOptions)); if (handled) { + logService.trace(`[AgentSessions] openSession handled by participant: ${session.resource.toString()}`); return undefined; // Participant handled the session, skip default opening } } catch (error) { @@ -134,6 +137,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); try { session.setRead(true); // mark as read when opened @@ -152,6 +156,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes }; await chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open + logService.trace(`[AgentSessions] openSession: provider '${session.providerType}' activated for ${session.resource.toString()}`); let target: typeof SIDE_GROUP | typeof ACTIVE_GROUP | typeof ChatViewPaneTarget | undefined; if (openOptions?.sideBySide) { @@ -168,6 +173,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes return await chatWidgetService.openSession(session.resource, target, options); } catch (error) { + logService.error(`[AgentSessions] openSession failed: ${session.resource.toString()}`, error); notificationService.error(localize('chat.openSessionFailed', "Failed to open chat session: {0}", toErrorMessage(error))); return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index a5645c446c07e1..1faeba2ee7c027 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1252,6 +1252,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ { const existingSessionData = this._sessions.get(sessionResource); if (existingSessionData) { + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: cache hit for ${sessionResource.toString()}`); return existingSessionData.session; } } @@ -1293,7 +1294,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ dispose: () => { } }; } else { + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: resolving content from provider '${resolvedType}' for ${sessionResource.toString()}`); session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + this._logService.trace(`[ChatSessionsService] getOrCreateChatSession: provider returned ${session.history.length} history item(s) for ${sessionResource.toString()}`); } if (session.options) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 616f1effecf586..a92f0f7580dec3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -417,6 +417,14 @@ export class ChatWidget extends Disposable implements IChatWidget { private _isRenderingWelcome = false; private _isLoading = false; + /** + * The session whose model was just bound, cleared by the first + * {@link onDidChangeItems} that renders it. Tracked by resource (rather than + * a flag) so the trace marks time-to-first-render for the model it belongs + * to, once, even when an outgoing model triggers a render while unbinding. + */ + private _pendingFirstRenderSessionResource: URI | undefined; + // Coding agent locking state private _lockedAgent?: { id: string; @@ -461,7 +469,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._viewModel = viewModel; if (viewModel) { this.viewModelDisposables.add(viewModel); - this.logService.debug('ChatWidget#setViewModel: have viewModel'); + this.logService.debug(`ChatWidget#setViewModel: have viewModel session=${viewModel.sessionResource.toString()} requests=${viewModel.model.getRequests().length}`); // If switching to a model with a request in progress, play progress sound if (viewModel.model.requestInProgress.get()) { @@ -1340,6 +1348,11 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.setVisibleChangeCount(this.visibleChangeCount); this.listWidget.refresh(); + if (this._pendingFirstRenderSessionResource && this.viewModel && isEqual(this.viewModel.sessionResource, this._pendingFirstRenderSessionResource)) { + this._pendingFirstRenderSessionResource = undefined; + this.logService.trace(`ChatWidget#firstRender: session=${this.viewModel.sessionResource.toString()} items=${items.length}`); + } + if (!skipDynamicLayout && this._dynamicMessageLayoutData) { this.layoutDynamicChatTreeItemMode(); } @@ -2596,6 +2609,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const currentInputModel = this.viewModel?.model?.inputModel?.state?.get(); if (!model) { + this._pendingFirstRenderSessionResource = undefined; logChangesToStateModel(this.viewModel?.model?.inputModel, `ChatWidget.setModel to empty, old ${this.viewModel?.sessionResource.toString()}`, undefined, currentInputModel, this.logService); // Flush any unsent draft to the outgoing input model before we drop our // reference to it, so the host's `willDisposeModel` persistence sees it. @@ -2643,6 +2657,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.listWidget.setViewModel(this.viewModel); + // Armed only once the list is bound, so a render triggered while the + // outgoing model was torn down cannot consume it. + this._pendingFirstRenderSessionResource = model.sessionResource; if (this._lockedAgent) { let placeholder = this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id)?.inputPlaceholder; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b7205ea6d3c378..d297690e19d961 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -677,11 +677,13 @@ export class ChatService extends Disposable implements IChatService { } private async loadRemoteSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken, debugOwner?: string): Promise { + this.trace('loadRemoteSession', `start ${sessionResource.toString()}`); // Check if session already exists before resolving the provider, // so we can return a cached model even if the provider was unregistered. { const existingRef = this.acquireExistingSession(sessionResource, debugOwner); if (existingRef) { + this.trace('loadRemoteSession', `reused existing model for ${sessionResource.toString()}`); return existingRef; } } @@ -691,6 +693,7 @@ export class ChatService extends Disposable implements IChatService { } const providedSession = await this.chatSessionService.getOrCreateChatSession(sessionResource, token); + this.trace('loadRemoteSession', `session content resolved for ${sessionResource.toString()} with ${providedSession.history.length} history item(s)`); // Make sure we haven't created this in the meantime { @@ -919,6 +922,7 @@ export class ChatService extends Disposable implements IChatService { } } } + this.trace('loadRemoteSession', `history applied to model for ${sessionResource.toString()}: ${model.getRequests().length} request(s)`); // Set up progress streaming and cancellation for contributed sessions. // This handles both the initial in-flight response (from session load) diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts index 7f92d3f354cc60..7d9a488066a133 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/editorGutter.ts @@ -78,6 +78,7 @@ export class EditorGutter extends D this.editorOnDidContentSizeChange.read(reader); const scrollTop = this.scrollTop.read(reader); + const domNodeHeight = this._domNode.clientHeight; const visibleRanges = this._editor.getVisibleRanges(); const unusedIds = new Set(this.views.keys()); @@ -126,7 +127,7 @@ export class EditorGutter extends D view.domNode.style.top = `${top}px`; view.domNode.style.height = `${height}px`; - view.gutterItemView.layout(top, height, 0, this._domNode.clientHeight); + view.gutterItemView.layout(top, height, 0, domNodeHeight); } } diff --git a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts index eea62f66bfd193..73e0f6db02ad64 100644 --- a/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts +++ b/src/vs/workbench/contrib/meteredConnection/browser/meteredConnectionStatus.ts @@ -49,6 +49,7 @@ export class MeteredConnectionStatusContribution extends Disposable implements I text: '$(radio-tower)', ariaLabel: localize('status.meteredConnection.ariaLabel', "Metered Connection Enabled"), tooltip: localize('status.meteredConnection.tooltip', "Metered connection enabled. Some background network activity, including updates, Settings Sync, inline completions, telemetry, and automatic Git operations, is paused to reduce data usage."), + kind: 'warning', command: { id: 'workbench.action.configureMeteredConnection', title: localize('status.meteredConnection.configure', "Configure") diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts index f0ad0a8c91b9f4..a33ef06b46eafa 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts @@ -657,7 +657,8 @@ async function webviewPreloads(ctx: PreloadContext) { } // if the node is not scrollable, we can continue. We don't check the computed style always as it's expensive - if (window.getComputedStyle(node).overflowY === 'hidden' || window.getComputedStyle(node).overflowY === 'visible') { + const overflowY = window.getComputedStyle(node).overflowY; + if (overflowY === 'hidden' || overflowY === 'visible') { continue; } diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index 9370b48b871f6d..0f9f311a5f35f8 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -74,15 +74,15 @@ .monaco-workbench .xterm { /* All terminals have at least 20px left padding for the gutter */ - padding-left: 20px; + padding-left: var(--vscode-spacing-size200); } .monaco-workbench .xterm .xterm-scrollable-element { /* Offset the scrollable element such that: * - The terminal grid will be positioned to the right of the gutter * - Elements are not hidden in the gutter */ - margin-left: -20px; - padding-left: 20px; + margin-left: calc(-1 * var(--vscode-spacing-size200)); + padding-left: var(--vscode-spacing-size200); } .monaco-workbench .terminal-editor .xterm, @@ -555,6 +555,7 @@ .terminal-scroll-highlight { left: 0; right: 0; + margin-left: calc(-1 * var(--vscode-spacing-size200)); border-left: 5px solid #ffffff; border-left-width: 5px !important; pointer-events: none; @@ -573,7 +574,7 @@ box-sizing: border-box; transform: translateX(3px); pointer-events: none; - margin-left: -20px; + margin-left: calc(-1 * var(--vscode-spacing-size200)); } .terminal-command-guide.top { border-top-left-radius: 1px; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index f3ef66bbe3e920..bbd1786b5398ff 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -11,7 +11,6 @@ import type { Terminal, IMarker, ITerminalAddon, IDecoration, IBufferRange } fro import { timeout } from '../../../../../base/common/async.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TERMINAL_OVERVIEW_RULER_CURSOR_FOREGROUND_COLOR } from '../../common/terminalColorRegistry.js'; -import { getWindow } from '../../../../../base/browser/dom.js'; import { ICurrentPartialCommand, isFullTerminalCommand } from '../../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TerminalContribSettingId } from '../../terminalContribExports.js'; @@ -411,9 +410,6 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } else { element.classList.add('terminal-scroll-highlight'); } - if (this._terminal?.element) { - element.style.marginLeft = `-${getWindow(this._terminal.element).getComputedStyle(this._terminal.element).paddingLeft}`; - } }); // TODO: This is not efficient for a large decorationCount decoration.onDispose(() => { this._navigationDecorations = this._navigationDecorations?.filter(d => d !== decoration); }); diff --git a/test/smoke/network-proxy/test-kerberos.pac b/test/smoke/network-proxy/test-kerberos.pac new file mode 100644 index 00000000000000..7cac145be35262 --- /dev/null +++ b/test/smoke/network-proxy/test-kerberos.pac @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +function FindProxyForURL(url, host) { + return 'PROXY localhost:43144'; +} diff --git a/test/smoke/scripts/run-agents-window-network-proxy.sh b/test/smoke/scripts/run-agents-window-network-proxy.sh index f73e5a3d118b52..f172c16db4c0fd 100644 --- a/test/smoke/scripts/run-agents-window-network-proxy.sh +++ b/test/smoke/scripts/run-agents-window-network-proxy.sh @@ -11,19 +11,37 @@ FIXTURE_DIR="$ROOT/test/smoke/network-proxy" LOG_DIR="$ROOT/.build/logs/agents-window-network-proxy" TEMP_ROOT="${RUNNER_TEMP:-${AGENT_TEMPDIRECTORY:-${TMPDIR:-/tmp}}}/vscode-agents-window-network-proxy-$$" TEST_REPO="$TEMP_ROOT/vscode-smoketest-express" +PROXY_AUTH="none" +if [[ "${1:-}" == "--kerberos" ]]; then + PROXY_AUTH="kerberos" + shift +fi PROXY_GROUP="vscodeproxytest" PF_ANCHOR="com.apple/vscodeproxytest" MOCK_HOST="vscode-smoke.test" PROXY_HEADER_VALUE="vscode-smoke-network-proxy-$$" PAC_URL="http://127.0.0.1:44444/test.pac" +PAC_FILE="$FIXTURE_DIR/test.pac" PAC_LOG="$LOG_DIR/pac-server.log" SQUID_ACCESS_LOG="$LOG_DIR/squid-access.log" SQUID_LOG="$LOG_DIR/squid.log" SQUID_PREFIX="$(brew --prefix squid 2>/dev/null || true)" SQUID_BIN="$SQUID_PREFIX/sbin/squid" +NODE_BIN="$(command -v node)" +KDC_BIN="/System/Library/PrivateFrameworks/Heimdal.framework/Helpers/kdc" +KDC_PORT="61088" +KDC_LOG="$LOG_DIR/kdc.log" +KERBEROS_AUTH_LOG="$LOG_DIR/kerberos-auth.log" +KERBEROS_CONFIG="$TEMP_ROOT/krb5.conf" +KERBEROS_CACHE="FILE:$TEMP_ROOT/krb5cc" +KERBEROS_KEYTAB="FILE:$TEMP_ROOT/proxy.keytab" +KERBEROS_REALM="VSCODE.PROXY.TEST" +KERBEROS_USERNAME="PlaceholderUsername" +KERBEROS_PASSWORD="Placeholder" pac_pid="" squid_pid="" +kdc_pid="" primary_service="" saved_pac_url="" saved_pac_enabled="No" @@ -59,6 +77,10 @@ cleanup() { kill "$pac_pid" wait "$pac_pid" fi + if [[ -n "$kdc_pid" ]]; then + kill "$kdc_pid" + wait "$kdc_pid" + fi if $group_created; then sudo dseditgroup -o delete "$PROXY_GROUP" @@ -67,7 +89,7 @@ cleanup() { fi if [[ $exit_code -ne 0 ]]; then - tail -n 100 "$PAC_LOG" "$SQUID_LOG" "$SQUID_ACCESS_LOG" 2>/dev/null + tail -n 100 "$PAC_LOG" "$SQUID_LOG" "$SQUID_ACCESS_LOG" "$KDC_LOG" "$KERBEROS_AUTH_LOG" 2>/dev/null fi rm -rf "$TEMP_ROOT" exit "$exit_code" @@ -94,7 +116,7 @@ saved_pac_url="$(printf '%s\n' "$saved_pac_state" | sed -n 's/^URL: //p')" saved_pac_enabled="$(printf '%s\n' "$saved_pac_state" | sed -n 's/^Enabled: //p')" mkdir -p "$LOG_DIR" "$TEMP_ROOT" -rm -f "$PAC_LOG" "$SQUID_ACCESS_LOG" "$SQUID_LOG" +rm -f "$PAC_LOG" "$SQUID_ACCESS_LOG" "$SQUID_LOG" "$KDC_LOG" "$KERBEROS_AUTH_LOG" git clone --depth 1 https://github.com/microsoft/vscode-smoketest-express "$TEST_REPO" if [[ -z "$SQUID_PREFIX" || ! -x "$SQUID_BIN" ]]; then @@ -102,6 +124,66 @@ if [[ -z "$SQUID_PREFIX" || ! -x "$SQUID_BIN" ]]; then exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + PAC_FILE="$FIXTURE_DIR/test-kerberos.pac" + if [[ ! -x "$KDC_BIN" ]]; then + echo "The macOS Heimdal KDC is required at $KDC_BIN" >&2 + exit 1 + fi + if nc -z 127.0.0.1 "$KDC_PORT"; then + echo "Port $KDC_PORT must be available for the Kerberos KDC" >&2 + exit 1 + fi + + cat > "$KERBEROS_CONFIG" < "$KDC_LOG" 2>&1 & + kdc_pid=$! + + kdc_ready=false + for _ in {1..50}; do + if nc -z 127.0.0.1 "$KDC_PORT"; then + kdc_ready=true + break + fi + sleep 0.1 + done + if ! $kdc_ready; then + echo "The local Kerberos KDC did not become ready" >&2 + exit 1 + fi + + printf '%s\n' "$KERBEROS_PASSWORD" > "$TEMP_ROOT/kerberos-password" + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5CCNAME="$KERBEROS_CACHE" \ + /usr/bin/kinit --password-file="$TEMP_ROOT/kerberos-password" "$KERBEROS_USERNAME" +fi + cat > "$TEMP_ROOT/hosts" < "$TEMP_ROOT/squid.conf" <> "$TEMP_ROOT/squid.conf" <> "$TEMP_ROOT/squid.conf" <&2 exit 1 fi -node "$ROOT/test/smoke/out/networkProxy/pacServer.js" "$FIXTURE_DIR/test.pac" > "$PAC_LOG" 2>&1 & +node "$ROOT/test/smoke/out/networkProxy/pacServer.js" "$PAC_FILE" > "$PAC_LOG" 2>&1 & pac_pid=$! -"$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5_KTNAME="$KERBEROS_KEYTAB" "$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +else + "$SQUID_BIN" -N -f "$TEMP_ROOT/squid.conf" -d 1 >> "$SQUID_LOG" 2>&1 & +fi squid_pid=$! proxy_ready=false @@ -193,6 +297,12 @@ restricted_env=( "TMPDIR=${TMPDIR:-/tmp}" "USER=$(id -un)" ) +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + restricted_env+=( + "KRB5_CONFIG=$KERBEROS_CONFIG" + "KRB5CCNAME=$KERBEROS_CACHE" + ) +fi for name in BUILD_ARTIFACTSTAGINGDIRECTORY CI GITHUB_ACTIONS GITHUB_RUN_ATTEMPT GITHUB_RUN_ID GITHUB_WORKSPACE RUNNER_TEMP TF_BUILD; do if [[ -n "${!name:-}" ]]; then restricted_env+=("$name=${!name}") @@ -219,6 +329,15 @@ if run_restricted curl --fail --silent --connect-timeout 3 --noproxy '*' http:// exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + if curl --fail --silent --connect-timeout 3 --proxy http://localhost:43144 --noproxy '' "$PAC_URL" >/dev/null 2>&1; then + echo "The Kerberos proxy accepted an unauthenticated request" >&2 + exit 1 + fi + KRB5_CONFIG="$KERBEROS_CONFIG" KRB5CCNAME="$KERBEROS_CACHE" \ + curl --fail --silent --connect-timeout 3 --proxy http://localhost:43144 --noproxy '' --proxy-negotiate --proxy-user : "$PAC_URL" >/dev/null +fi + cd "$ROOT" run_restricted env VSCODE_SMOKE_TEST_MOCK_HOST="$MOCK_HOST" VSCODE_SMOKE_TEST_PROXY_HEADER="$PROXY_HEADER_VALUE" \ npm run smoketest-no-compile -- --tracing -g 'Agents Window' --fail-zero --test-repo "$TEST_REPO" --skip-stable-build "$@" @@ -229,6 +348,11 @@ squid_pid="" kill "$pac_pid" wait "$pac_pid" || true pac_pid="" +if [[ -n "$kdc_pid" ]]; then + kill "$kdc_pid" + wait "$kdc_pid" || true + kdc_pid="" +fi if ! grep -Fq 'GET /test.pac' "$PAC_LOG"; then echo "The macOS proxy resolver did not fetch the PAC script" >&2 @@ -238,3 +362,13 @@ if ! grep -Fq "$MOCK_HOST" "$SQUID_ACCESS_LOG"; then echo "The Agents Window smoke test did not reach the mock server through Squid" >&2 exit 1 fi +if [[ "$PROXY_AUTH" == "kerberos" ]]; then + if ! grep -Fq "$KERBEROS_USERNAME@$KERBEROS_REALM" "$KERBEROS_AUTH_LOG"; then + echo "Squid did not validate a Kerberos token from the Agents Window smoke test" >&2 + exit 1 + fi + if ! awk -v host="$MOCK_HOST" -v user="$KERBEROS_USERNAME@$KERBEROS_REALM" 'index($0, "TCP_TUNNEL/200") && index($0, "CONNECT " host) && index($0, " " user " ") { found=1 } END { exit !found }' "$SQUID_ACCESS_LOG"; then + echo "The Agents Window smoke test did not establish a Kerberos-authenticated tunnel through Squid" >&2 + exit 1 + fi +fi diff --git a/test/smoke/src/networkProxy/negotiateAuthHelper.ts b/test/smoke/src/networkProxy/negotiateAuthHelper.ts new file mode 100644 index 00000000000000..acbb9746995071 --- /dev/null +++ b/test/smoke/src/networkProxy/negotiateAuthHelper.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as kerberos from 'kerberos'; + +const authLogPath = process.argv[2]; +if (!authLogPath) { + throw new Error('Authentication log path is required'); +} + +async function main(): Promise { + const input = readline.createInterface({ input: process.stdin }); + for await (const request of input) { + const match = /^YR (\S+)$/.exec(request); + if (!match) { + process.stdout.write('BH unsupported-request\n'); + continue; + } + + try { + const server = await kerberos.initializeServer('HTTP@localhost'); + const response = await server.step(match[1]); + if (!server.contextComplete) { + process.stdout.write('BH incomplete-authentication\n'); + continue; + } + + fs.appendFileSync(authLogPath, `${server.username}\n`); + process.stdout.write(`AF ${response || '='} ${server.username}\n`); + } catch (error) { + console.error(error); + process.stdout.write('BH token-validation-failed\n'); + } + } +} + +void main();