From 14f1238bef383efa6d05007d9943f33e6dc44845 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Fri, 3 Apr 2026 15:05:05 -0400 Subject: [PATCH 1/2] sessions: disable branch picker in folder mode Keep the new chat branch picker visible when isolation switches to Folder, but disable it until Worktree is selected again. Also add regression coverage for the folder/worktree toggle and keep the disabled picker cursor consistent on hover. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/media/chatWelcomePart.css | 1 + .../browser/branchPicker.ts | 10 +- .../test/browser/branchPicker.test.ts | 190 ++++++++++++++++++ 3 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 64a59c02e353b..809dadee37a29 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -236,6 +236,7 @@ .sessions-chat-picker-slot.disabled .action-label:hover { background-color: transparent; color: var(--vscode-icon-foreground); + cursor: default; } .chat-input-picker-item .action-label.disabled { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index d3a69f34fd246..fed92499d7e8a 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -137,7 +137,7 @@ export class BranchPicker extends Disposable { const session = this._getSession(); const branches = session?.branches.get() ?? []; const isLoading = session?.loading.get() ?? false; - const isDisabled = session?.isolationMode.get() === 'workspace' || branches.length === 0; + const isDisabled = session?.isolationMode.get() === 'workspace'; const label = session?.branch.get() ?? localize('branchPicker.select', "Branch"); dom.append(this._triggerElement, renderIcon(Codicon.gitBranch)); @@ -145,11 +145,11 @@ export class BranchPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - const visible = !(isLoading || isDisabled); + const visible = !(isLoading || branches.length === 0); dom.setVisibility(visible, this._slotElement); - this._slotElement.classList.toggle('disabled', false); + this._slotElement.classList.toggle('disabled', isDisabled); this._triggerElement.setAttribute('aria-hidden', String(!visible)); - this._triggerElement.setAttribute('aria-disabled', String(!visible)); - this._triggerElement.tabIndex = visible ? 0 : -1; + this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); + this._triggerElement.tabIndex = visible && !isDisabled ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts new file mode 100644 index 0000000000000..f4d9b48a366df --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvider } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionsProvidersService } from '../../../sessions/browser/sessionsProvidersService.js'; +import { COPILOT_PROVIDER_ID, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; +import { BranchPicker } from '../../browser/branchPicker.js'; +import { IsolationMode } from '../../browser/isolationPicker.js'; + +function createActiveSession(providerId: string, sessionId: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionId}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId, + resource: URI.parse(`test:///session/${sessionId}`), + providerId, + sessionType: 'copilot-cli', + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +class TestCopilotSession extends mock() { + override readonly loading = observableValue('loading', false); + override readonly branches = observableValue('branches', ['main', 'feature/test']); + override readonly branch = observableValue('branch', 'main'); + override readonly isolationMode = observableValue('isolationMode', 'worktree'); + + override setBranch(branch: string | undefined): void { + this.branch.set(branch, undefined); + } +} + +class TestCopilotProvider extends mock() { + constructor(private readonly sessionId: string, private readonly session: ICopilotChatSession) { + super(); + } + + override readonly id = COPILOT_PROVIDER_ID; + override readonly label = 'Copilot'; + override readonly icon = Codicon.copilot; + override readonly sessionTypes = []; + override readonly browseActions = []; + override readonly onDidChangeSessions = Event.None; + override readonly capabilities = { multipleChatsPerSession: false }; + + getSession(sessionId: string): ICopilotChatSession | undefined { + return sessionId === this.sessionId ? this.session : undefined; + } +} + +class TestSessionsProvidersService extends mock() { + constructor(private readonly provider: TestCopilotProvider) { + super(); + } + + override readonly onDidChangeProviders = Event.None; + override readonly onDidChangeSessions = Event.None; + override readonly onDidReplaceSession = Event.None; + + override getProviders(): ISessionsProvider[] { + return [this.provider]; + } + + override getProvider(providerId: string): T | undefined { + return providerId === this.provider.id ? this.provider as unknown as T : undefined; + } +} + +suite('BranchPicker', () => { + + const disposables = new DisposableStore(); + let activeSession: ReturnType>; + let providerSession: TestCopilotSession; + let showCalls: number; + let instantiationService: TestInstantiationService; + + setup(() => { + const sessionId = `${COPILOT_PROVIDER_ID}:session`; + showCalls = 0; + activeSession = observableValue('activeSession', createActiveSession(COPILOT_PROVIDER_ID, sessionId)); + providerSession = new TestCopilotSession(); + + const provider = new TestCopilotProvider(sessionId, providerSession); + const sessionsProvidersService = new TestSessionsProvidersService(provider); + + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: () => { showCalls++; }, + }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + }); + instantiationService.stub(ISessionsProvidersService, sessionsProvidersService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disables the picker instead of hiding it in folder mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), true); + assert.strictEqual(trigger.getAttribute('aria-hidden'), 'false'); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'true'); + assert.strictEqual(trigger.tabIndex, -1); + + picker.showPicker(); + assert.strictEqual(showCalls, 0); + }); + + test('re-enables the picker when switching back to worktree mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + + providerSession.isolationMode.set('worktree', undefined); + + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), false); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'false'); + assert.strictEqual(trigger.tabIndex, 0); + + picker.showPicker(); + assert.strictEqual(showCalls, 1); + }); +}); From 477bf2fc157f97b6d1b064acd3b035b39ec98a14 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Fri, 3 Apr 2026 15:40:36 -0400 Subject: [PATCH 2/2] sessions: remove branch picker hover cursor tweak Keep the branch picker behavior change, but drop the extra disabled hover cursor styling so the PR only contains the intended picker visibility/disabled-state update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 809dadee37a29..64a59c02e353b 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -236,7 +236,6 @@ .sessions-chat-picker-slot.disabled .action-label:hover { background-color: transparent; color: var(--vscode-icon-foreground); - cursor: default; } .chat-input-picker-item .action-label.disabled {