Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>`) — 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<boolean>`) — 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.

Expand Down
2 changes: 1 addition & 1 deletion src/vs/sessions/common/contextkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const SessionIdContext = new RawContextKey<string>('sessionId', '', local
export const SessionProviderIdContext = new RawContextKey<string>('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<string>('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<boolean>('sessionWorkspaceIsVirtual', true, localize('sessionWorkspaceIsVirtual', "Whether the session's workspace is virtual"));
export const SessionHasGitRepositoryContext = new RawContextKey<boolean>('sessionHasGitRepository', false, localize('sessionHasGitRepository', "Whether the session has an associated git repository"));
export const SessionHasGitRepositoryContext = new RawContextKey<boolean>('sessionHasGitRepository', false, localize('sessionHasGitRepository', "Whether the session has a usable git repository"));
export const SessionHasGitSyncActionRunningContext = new RawContextKey<boolean>('sessionHasGitSyncActionRunning', false, localize('sessionHasGitSyncActionRunning', "Whether the session has a git sync action currently running"));
export const SessionUsesCombinedConfigPickerContext = new RawContextKey<boolean>('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<boolean>('sessionSupportsRename', false, localize('sessionSupportsRename', "Whether the session can be renamed"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IChatSessionProviderOptionItem>` and syncs to `IChatSessionsService`
- Uses `IGitService` to open the repository and resolve branch information
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
/** Whether the session's repository supports worktree-backed operations. */
readonly hasGitRepository?: IObservable<boolean>;
/** Whether the session is archived. */
readonly isArchived: IObservable<boolean>;
/** Whether the session has been read. */
Expand Down Expand Up @@ -255,6 +257,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession {

private readonly _loading = observableValue(this, true);
readonly loading: IObservable<boolean> = this._loading;
private readonly _hasGitRepository = observableValue(this, false);
readonly hasGitRepository: IObservable<boolean> = this._hasGitRepository;

private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>;
readonly changes: IObservable<readonly ISessionFileChange[]>;
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/vs/sessions/services/sessions/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,8 @@ export interface ISession {
readonly createdAt: Date;
/** Workspace this session operates on. */
readonly workspace: IObservable<ISessionWorkspace | undefined>;
/** Whether the session has a usable Git repository. Providers may refine this beyond workspace metadata. */
readonly hasGitRepository?: IObservable<boolean>;
Comment on lines +498 to +499
/** Whether this is a workspace-less "quick chat". Only quick-chat-capable providers set this; absent means `false`. */
readonly isQuickChat?: IObservable<boolean>;

Expand Down
Loading
Loading