From 90592d5727314ad987151700d825d143a824bd81 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 18 Jul 2026 04:06:08 +0200 Subject: [PATCH 1/2] sessions: scope git availability to each session surface Publish usable Git availability on ISession and have the shared session context key plumbing bind it into root and per-session scoped context key services. This keeps repository picker menu visibility aligned with each toolbar's own session when multiple session inputs are visible. Remove the provider contribution that mirrored only the globally active session, and add regression coverage for independent scoped contexts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/sessions/SKILL.md | 1 + src/vs/sessions/SESSIONS.md | 1 + src/vs/sessions/common/contextkeys.ts | 2 +- .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 3 + .../browser/copilotChatSessionsActions.ts | 54 +--------------- .../browser/copilotChatSessionsProvider.ts | 18 ++++-- .../browser/isolationPicker.ts | 6 +- .../services/sessions/common/session.ts | 2 + .../sessions/common/sessionContextKeys.ts | 7 ++- .../test/common/sessionContextKeys.test.ts | 63 +++++++++++++++++++ 10 files changed, 96 insertions(+), 61 deletions(-) create mode 100644 src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 61fca9cb54877c..527562a37ccb19 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -177,3 +177,4 @@ You **must** run these checks before declaring work complete: - **Save the outgoing session's working set eagerly on the *raw* active session change, not on the workspace-gated `activeSessionForWorkingSet` derive**: the derive (`baseSessionLayoutController`) holds back while the incoming session's workspace folders resolve, and other autoruns driven by the *raw* `ISessionsService.activeSession` (e.g. the single-pane managed-tabs sync) **async-close the outgoing session's docked editors** (`_closeInactiveChangesEditors`) during that lag. If the working-set save is on the lagged derive it runs *after* those closes, so the outgoing session's Changes tab (or whichever editor was active) is already gone and its active state is lost — on return the working set restores the wrong active editor (symptom: switching back to a session whose Changes tab was active shows a different tab active). Fix: a dedicated `[B2] runOnChange(activeSession, …)` saves the previous session's working set **synchronously** (guarded by resource-inequality, `!Untitled`, `!_isRestoringSessionLayout`) — this runs before the managed-tab sequencer microtask closes anything — and the save is removed from the gated apply `runOnChange` (which now only applies). Save doesn't need workspace readiness; only apply does. Pairs with making the managed Changes tab open non-stealing (`inactive: true` + `EditorActivation.PRESERVE`) so the restored active editor is preserved. The unit harness can't reproduce the derive-lag directly, so assert the decoupling: switching to a session whose workspace isn't in the folders (gated apply holds back) still records a `saveWorkingSet` for the outgoing session. - **Docked side-pane width persistence must be symmetric about the detail (aux) width**: in single-pane the docked detail (auxiliary bar) lives *inside* the editor grid node, so the workbench persists the pure editor-content width (`_persistedEditorWidth` = node − detail) and the grid descriptor reconstructs node = editor-content + detail. These must use the **same condition** for including the detail: only when the detail is visible (`partVisibility.auxiliaryBar`). Subtracting the detail width *unconditionally* at save while adding it back only when the detail is visible shrank an **Editor-only** session's side pane by the detail width on *every* reload, compounding toward zero ("side pane always tiny on reload"). Fix `_persistedEditorWidth` to subtract only when the detail is visible. - **Side-pane (editor grid node) size is workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes` via `_savePartSizes`/`createDesktopGridDescriptor`), so switching sessions keeps the same width and reload restores it in one paint. Do **not** add a per-session width map in the layout controller that re-applies a width on session switch/reveal — it makes switching sessions jump the side pane around, and a post-paint restore on reload flickers. The 60% first-open default (`SIDE_PANE_WIDTH_RATIO` in `parts/editorPartSizing.ts`, applied by the single-pane `_applyEditorSplitSize` override on the first reveal that has no size to restore) is the only width the layout intentionally sets; everything else is the user's persisted grid size. +- **Per-session menu visibility must flow through scoped context keys, not widget DOM hiding or global active-session keys**: toolbars evaluate `when` against their scoped `IContextKeyService`, while their pickers act on scoped `ISessionContext`. Expose provider-specific availability as an observable on `ISession` and publish it through `setSessionContextKeys`, so every visible session surface evaluates the same scoped state declaratively. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index d5372044441dbf..d4a848bd3d4e52 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -205,6 +205,7 @@ The contract is small and provider-agnostic: - **`ISessionsManagementService.getQuickChatSessionTypes()`** — every session type advertised by quick-chat-capable providers, for the inline composer type picker. - **`ISessionsService.openQuickChat(options?)`** — view-layer entry point; opens the quick chat as a normal session. - **`ISession.isQuickChat`** (optional `IObservable`) — set only by quick-chat-capable providers (absent ⇒ `false`). Consumers read it via the `isQuickChatSession(session)` helper. The agent-host adapter derives it from the host's `workspaceless` tag, **not** from `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too. +- **`ISession.hasGitRepository`** (optional `IObservable`) — provider-refined usable Git availability. `setSessionContextKeys` publishes it as `SessionHasGitRepositoryContext` in both root and per-session scoped context-key services, falling back to workspace repository metadata when absent. This keeps declarative menu visibility aligned with each toolbar's scoped `ISessionContext` when multiple sessions are visible. Presentation: a quick chat is a **single-chat** session that uses the normal session header (no peer-chat tab strip); only the Done/archive affordance is hidden. Its untitled-title fallback is **"New Chat"** (not "New Session") — every fallback site (titlebar, session header, list hover, sessions picker) routes through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`). **Cmd+N always creates a new session** (`NewChatInSessionsWindowAction` → `openNewSession`); a quick chat is created **only** via the "Chats"-section **"+"** (`NewQuickChatAction`, also bound to **Cmd+K Cmd+N**), which opens the composer with the inline session-type picker feeding `openQuickChat({ sessionTypeId })` on send. Peer chats within a session are a third gesture (chat **"+"** / Cmd+T). Keep these three creation actions distinct. diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index fb1e09adf13a5c..e2592e7307b7f6 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -13,7 +13,7 @@ export const SessionIdContext = new RawContextKey('sessionId', '', local export const SessionProviderIdContext = new RawContextKey('sessionProviderId', '', localize('sessionProviderId', "The provider ID of the session in scope (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionTypeContext = new RawContextKey('sessionType', '', localize('sessionType', "The session type of the session in scope (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionWorkspaceIsVirtualContext = new RawContextKey('sessionWorkspaceIsVirtual', true, localize('sessionWorkspaceIsVirtual', "Whether the session's workspace is virtual")); -export const SessionHasGitRepositoryContext = new RawContextKey('sessionHasGitRepository', false, localize('sessionHasGitRepository', "Whether the session has an associated git repository")); +export const SessionHasGitRepositoryContext = new RawContextKey('sessionHasGitRepository', false, localize('sessionHasGitRepository', "Whether the session has a usable git repository")); export const SessionHasGitSyncActionRunningContext = new RawContextKey('sessionHasGitSyncActionRunning', false, localize('sessionHasGitSyncActionRunning', "Whether the session has a git sync action currently running")); export const SessionUsesCombinedConfigPickerContext = new RawContextKey('sessionUsesCombinedConfigPicker', false, localize('sessionUsesCombinedConfigPicker', "Whether the session's provider offers a combined mode and model configuration picker (used on phone layouts in place of the standalone pickers)")); export const SessionSupportsRenameContext = new RawContextKey('sessionSupportsRename', false, localize('sessionSupportsRename', "Whether the session can be renamed")); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index baeee74397cfe2..1078cb405408b9 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -42,6 +42,7 @@ When `createNewSession(workspace)` is called, the provider creates one of two co **`CopilotCLISession`** — For local `file://` workspaces: - Implements `ISession` plus provider-specific observable fields (`permissionLevel`, `branchObservable`, `isolationModeObservable`) - Performs async git repository resolution during construction (sets `loading` to true until resolved) +- Exposes `hasGitRepository` as an observable that becomes true only when the repository has a HEAD commit, so scoped session context keys hide worktree/branch controls for non-Git and empty repositories - Configuration methods: `setIsolationMode()`, `setBranch()`, `setModelId()`, `setMode()`, `setPermissionLevel()`, `setModeById()` - Tracks selected options via `Map` and syncs to `IChatSessionsService` - Uses `IGitService` to open the repository and resolve branch information @@ -133,6 +134,8 @@ Each picker action uses a `when` clause to show only for the correct session typ | `IsActiveSessionCopilotChatCloud` | Copilot Cloud sessions | | `IsActiveSessionCopilotChatClaudeCode` | Claude sessions | +Repository controls additionally require `SessionHasGitRepositoryContext`. The provider publishes usable Git availability through `ISession.hasGitRepository`, and `setSessionContextKeys` binds that observable into each session view's scoped context-key service. Menu visibility therefore follows the toolbar's own `ISessionContext`, not the window's globally active session. + These are composed from `SessionTypeContext` (the session type ID) and `SessionProviderIdContext` (the provider ID). ### Adding a New Picker diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index b3f129fc9f7543..7c1ea49dd9a349 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -5,12 +5,12 @@ import { BaseActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { IReader, autorun } from '../../../../../base/common/observable.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { isWeb } from '../../../../../base/common/platform.js'; import { localize2 } from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../../workbench/common/contributions.js'; import { Menus } from '../../../../browser/menus.js'; @@ -24,9 +24,8 @@ import { LocalSessionType } from '../../localChatSessions/browser/localChatSessi import { IsolationPicker } from './isolationPicker.js'; import { ModePicker, ModePickerModel } from './modePicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; -import { BaseAgentHostSessionsProvider, CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; -import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(SessionTypeContext.key, CopilotCLISessionType.id); const IsActiveSessionLocal = ContextKeyExpr.equals(SessionTypeContext.key, LocalSessionType.id); @@ -248,51 +247,4 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor } -// -- Context Key Contribution -- - -class CopilotActiveSessionContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.copilotActiveSession'; - - constructor( - @ISessionsService sessionsService: ISessionsService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @IContextKeyService contextKeyService: IContextKeyService, - ) { - super(); - - const hasRepositoryKey = SessionHasGitRepositoryContext.bindTo(contextKeyService); - - this._register(autorun((reader: IReader) => { - const session = sessionsService.activeSession.read(reader); - if (session?.providerId === COPILOT_PROVIDER_ID) { - const provider = sessionsProvidersService.getProvider(session.providerId); - const providerSession = provider instanceof CopilotChatSessionsProvider ? provider.getSession(session.sessionId) : undefined; - const isLoading = providerSession?.loading.read(reader); - const gitRepository = providerSession?.gitRepository; - // An empty repository (no HEAD commit) cannot run worktree - // isolation, so treat it as having no usable git repository — - // mirrors CopilotCLISession's own worktree gating. - const hasHeadCommit = !!gitRepository?.state.read(reader).HEAD?.commit; - hasRepositoryKey.set(!isLoading && !!gitRepository && hasHeadCommit); - } else if (session?.providerId === LOCAL_AGENT_HOST_PROVIDER_ID) { - const provider = sessionsProvidersService.getProvider(session.providerId); - const providerSession = provider instanceof BaseAgentHostSessionsProvider - ? provider.getSessionByResource(session.resource) - : undefined; - - const isLoading = providerSession?.loading.read(reader); - const workspace = providerSession?.workspace.read(reader); - const hasGitRepository = workspace?.folders - .some(folder => folder.gitRepository !== undefined) ?? false; - - hasRepositoryKey.set(!isLoading && hasGitRepository); - } else { - hasRepositoryKey.set(false); - } - })); - } -} - registerWorkbenchContribution2(CopilotPickerActionViewItemContribution.ID, CopilotPickerActionViewItemContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(CopilotActiveSessionContribution.ID, CopilotActiveSessionContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 45eb3bba5b6a33..482f126c137447 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -104,6 +104,8 @@ export interface ICopilotChatSession { readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; /** Whether the session is still initializing (e.g., resolving git repository). */ readonly loading: IObservable; + /** Whether the session's repository supports worktree-backed operations. */ + readonly hasGitRepository?: IObservable; /** Whether the session is archived. */ readonly isArchived: IObservable; /** Whether the session has been read. */ @@ -255,6 +257,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { private readonly _loading = observableValue(this, true); readonly loading: IObservable = this._loading; + private readonly _hasGitRepository = observableValue(this, false); + readonly hasGitRepository: IObservable = this._hasGitRepository; private readonly _changes: ReturnType>; readonly changes: IObservable; @@ -372,13 +376,17 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this.setIsolationMode('workspace'); } } - if (this._gitRepository) { - this._loadBranches(this._gitRepository); + const gitRepository = this._gitRepository; + if (gitRepository) { + this._register(autorun(reader => { + this._hasGitRepository.set(!!gitRepository.state.read(reader).HEAD?.commit, undefined); + })); + this._loadBranches(gitRepository); // Automatically update the selected branch when the repository // state changes. This is done only for the Folder sessions. const currentBranchName = derived(reader => { - const state = this._gitRepository?.state.read(reader); + const state = gitRepository.state.read(reader); return state?.HEAD?.commit ? state.HEAD.name : undefined; }); @@ -3093,7 +3101,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Resolve the main (first) chat in the group — session-level properties come from it const mainChatIds = this._getChatIdsInGroup(sessionId); const firstChatId = mainChatIds[0]; - const primaryChat = firstChatId + const primaryChat: ICopilotChatSession = firstChatId ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat : chat; @@ -3146,6 +3154,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions icon: primaryChat.icon, createdAt: primaryChat.createdAt, workspace: primaryChat.workspace, + hasGitRepository: primaryChat.hasGitRepository, title: primaryChat.title, updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!), status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)), @@ -3187,6 +3196,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions icon: chat.icon, createdAt: chat.createdAt, workspace: chat.workspace, + hasGitRepository: chat.hasGitRepository, title: chat.title, updatedAt: chat.updatedAt, status: chat.status, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts index 8969434556fec7..1f6d89ff1df654 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/isolationPicker.ts @@ -80,10 +80,8 @@ export class IsolationPicker extends Disposable { const providerSession = provider instanceof CopilotChatSessionsProvider ? provider.getSession(session!.sessionId) : undefined; if (providerSession) { const gitRepo = providerSession.gitRepository; - const repoState = gitRepo?.state?.read?.(reader); - const hasHeadCommit = repoState ? !!repoState.HEAD?.commit : true; - // Enable only when git repo exists and HEAD has a valid commit (not an empty repo) - this._hasGitRepo = !isLoading && !!gitRepo && hasHeadCommit; + const repoState = gitRepo?.state.read(reader); + this._hasGitRepo = !isLoading && !!repoState?.HEAD?.commit; // Read isolation mode from session — session is the source of truth providerSession.isolationMode.read(reader); } else { diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index a03f0964e4097a..2b9b69231fc4db 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -495,6 +495,8 @@ export interface ISession { readonly createdAt: Date; /** Workspace this session operates on. */ readonly workspace: IObservable; + /** Whether the session has a usable Git repository. Providers may refine this beyond workspace metadata. */ + readonly hasGitRepository?: IObservable; /** Whether this is a workspace-less "quick chat". Only quick-chat-capable providers set this; absent means `false`. */ readonly isQuickChat?: IObservable; diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 4738c7dbf12bfb..5ca878b71b8558 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -29,6 +29,7 @@ import { SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionActiveChatHasSubagentsContext, + SessionHasGitRepositoryContext, } from '../../../common/contextkeys.js'; import { ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; @@ -47,6 +48,7 @@ interface ISessionContextKeys { readonly supportsRename: IContextKey; readonly supportsDelete: IContextKey; readonly workspaceIsVirtual: IContextKey; + readonly hasGitRepository: IContextKey; readonly hasChanges: IContextKey; readonly hasPullRequest: IContextKey; readonly hasWorkspace: IContextKey; @@ -85,6 +87,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey supportsRename: SessionSupportsRenameContext.bindTo(contextKeyService), supportsDelete: SessionSupportsDeleteContext.bindTo(contextKeyService), workspaceIsVirtual: SessionWorkspaceIsVirtualContext.bindTo(contextKeyService), + hasGitRepository: SessionHasGitRepositoryContext.bindTo(contextKeyService), hasChanges: SessionHasChangesContext.bindTo(contextKeyService), hasPullRequest: SessionHasPullRequestContext.bindTo(contextKeyService), hasWorkspace: SessionHasWorkspaceContext.bindTo(contextKeyService), @@ -129,7 +132,9 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS keys.supportsFork.set(capabilities?.supportsFork ?? false); keys.supportsRename.set(capabilities?.supportsRename ?? false); keys.supportsDelete.set(capabilities?.supportsDelete ?? false); - keys.workspaceIsVirtual.set(session?.workspace.read(reader)?.isVirtualWorkspace ?? true); + const workspace = session?.workspace.read(reader); + keys.workspaceIsVirtual.set(workspace?.isVirtualWorkspace ?? true); + keys.hasGitRepository.set(session?.hasGitRepository?.read(reader) ?? workspace?.folders.some(folder => folder.gitRepository !== undefined) ?? false); // Mirror the changes pill: the default changeset, falling back to the session's changes. const defaultChangeset = session?.changesets.read(reader)?.find(c => c.isDefault.read(reader)); diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts new file mode 100644 index 00000000000000..af6ce5d8647739 --- /dev/null +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { constObservable, observableValue, autorun, ISettableObservable } from '../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { SessionHasGitRepositoryContext } from '../../../../common/contextkeys.js'; +import { ISession } from '../../common/session.js'; +import { setSessionContextKeys } from '../../common/sessionContextKeys.js'; + +function createSession(hasGitRepository: ISettableObservable): ISession { + return upcastPartial({ + sessionId: 'session', + providerId: 'provider', + sessionType: 'type', + workspace: constObservable(undefined), + hasGitRepository, + isArchived: constObservable(false), + isRead: constObservable(true), + capabilities: constObservable({ supportsMultipleChats: false }), + changesets: constObservable(undefined), + changes: constObservable([]), + }); +} + +suite('Session Context Keys', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('publishes Git availability independently to scoped context key services', () => { + const firstHasGit = observableValue('firstHasGit', false); + const secondHasGit = observableValue('secondHasGit', true); + const firstContext = new MockContextKeyService(); + const secondContext = new MockContextKeyService(); + const firstSession = createSession(firstHasGit); + const secondSession = createSession(secondHasGit); + + store.add(autorun(reader => setSessionContextKeys(firstSession, firstContext, reader))); + store.add(autorun(reader => setSessionContextKeys(secondSession, secondContext, reader))); + firstHasGit.set(true, undefined); + + assert.deepStrictEqual({ + first: firstContext.getContextKeyValue(SessionHasGitRepositoryContext.key), + second: secondContext.getContextKeyValue(SessionHasGitRepositoryContext.key), + }, { + first: true, + second: true, + }); + + firstHasGit.set(false, undefined); + + assert.deepStrictEqual({ + first: firstContext.getContextKeyValue(SessionHasGitRepositoryContext.key), + second: secondContext.getContextKeyValue(SessionHasGitRepositoryContext.key), + }, { + first: false, + second: true, + }); + }); +}); From 20b00b0952508e4f7969af52c9ea14356dd926c1 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 20 Jul 2026 13:51:21 -0700 Subject: [PATCH 2/2] sessions: forward git availability through session wrappers Delegate the optional hasGitRepository observable from both VisibleSession and ResourceOverrideSession so scoped context keys preserve provider-refined Git availability through the visibility model. Add regression coverage for both wrapper paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> EOF && git push --- .../sessions/browser/visibleSessions.ts | 2 ++ .../test/browser/visibleSessions.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index fd05f1177f6744..c8f9f4488134c6 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -255,6 +255,7 @@ export class VisibleSession extends Disposable implements IActiveSession { get icon() { return this._session.icon; } get createdAt() { return this._session.createdAt; } get workspace() { return this._session.workspace; } + get hasGitRepository() { return this._session.hasGitRepository; } get isQuickChat() { return this._session.isQuickChat; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } @@ -296,6 +297,7 @@ class ResourceOverrideSession implements ISession { get icon() { return this._session.icon; } get createdAt() { return this._session.createdAt; } get workspace() { return this._session.workspace; } + get hasGitRepository() { return this._session.hasGitRepository; } get isQuickChat() { return this._session.isQuickChat; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 7e055a99c0abde..1d32f48e2156a1 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -83,6 +83,23 @@ suite('VisibleSessions', () => { }; } + test('forwards Git availability through visible and resource-override wrappers', () => { + const hasGitRepository = observableValue('hasGitRepository', false); + const session = { ...stubSession('A'), hasGitRepository }; + const model = createModel(); + model.setActive(session); + const visible = model.activeSession.get(); + const resourceOverride = model.updateResourceOfSession(session, URI.parse('test:///override')); + + assert.deepStrictEqual({ + visible: visible?.hasGitRepository === hasGitRepository, + resourceOverride: resourceOverride.hasGitRepository === hasGitRepository, + }, { + visible: true, + resourceOverride: true, + }); + }); + suite('setActive', () => { test('opening B after non-sticky A replaces A in place', () => {