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
2 changes: 2 additions & 0 deletions src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ Chat input history in the Agents Window is scoped by `ISession.sessionId`. Press

Each session operates on an **`ISessionWorkspace`** containing one or more **`ISessionFolder`** instances. Folders encapsulate a working directory and optional git repository information (`ISessionGitRepository`), including branch state, upstream tracking, and GitHub PR info.

`ISession.multiRootWorkspace` separately records the originating multi-root workspace file and optional name when a provider can supply that provenance. It does not replace the operational `ISessionWorkspace`: file actions, row badges, repository state, and configuration continue to use the runtime workspace and folders.

Workspaces carry a `group` label (e.g., `"Local"`, `"Remote"`) used by the workspace picker to organize entries into tabs via the `SESSION_WORKSPACE_GROUP_LOCAL` / `SESSION_WORKSPACE_GROUP_REMOTE` constants. The picker supplements its own history with VS Code's recently opened folders, excluding folders below a path segment ending in `.worktrees`; explicitly picked session workspace history remains available.

Tasks with `runOptions.runOn === "worktreeCreated"` are dispatched client-side only for sessions that this window has just started. `SessionsManagementService` emits `onDidStartSession` from `sendNewChatRequest` after `provider.sendRequest(...)` commits, and `WorktreeCreatedTaskDispatcher` tracks only those sessions until they report a concrete `gitRepository.workTreeUri`. Restored/synced catalog sessions and runtimes that declare `capabilities.runsWorktreeCreatedTasks` are skipped so setup tasks are not re-run on window open or double-run with server-side provisioning.
Expand Down
4 changes: 2 additions & 2 deletions src/vs/sessions/SESSIONS_LIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Each quick chat is its **own single-chat session** (New Quick Chat = a new sessi

Two grouping modes (user-switchable):

- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them.
- **By Workspace** (default) — user groups and workspace sections share a single, freely-reorderable user-managed order below Pinned. Ordinary sessions group by operational workspace label. Sessions with multi-root provenance group by workspace-file URI identity and display the localized label **`<name> (Workspace)`**, where `<name>` is the recorded workspace name or the `.code-workspace` filename without its suffix. Multi-root sections omit the `+` action because the Agents Window cannot create multi-root sessions; ordinary workspace sections retain it. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them.
- **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Recent, Older), where Recent holds up to 10 sessions from the last 7 days and Older holds the rest. Groups never mix into the date sections.

User groups are **fully user-managed**: their order is owned by `ISessionSectionOrderService`, defaults to newest-first, and is shared across both grouping modes (it no longer derives from the recency of a group's member sessions). Groups remain visible and persisted until explicitly deleted. A group with no currently-visible member rows renders a muted **"No session" placeholder row** like the empty Chats section; its hover briefly explains that sessions can be added through the session context menu or drag and drop. This includes genuinely empty groups and groups whose members currently render in Pinned or are hidden by a filter. Archiving a session removes its group membership, so a group whose last member is marked done becomes empty and can be deleted.
Expand Down Expand Up @@ -130,7 +130,7 @@ Regular sessions can be reordered by dragging them up or down within the list. P

Dragging a **group header** or a **workspace section header** over another reorderable header shows an insertion line above/below it and moves the dragged header to that slot on drop.

- **Storage** — the top-level order is owned by `ISessionSectionOrderService` (persisted locally, not synced) as a flat list of identities (`group:<id>` and `workspace:<label>`). Identities the user has never moved fall back to the list's default order, so new groups/workspaces appear in their natural place until dragged. Stale identities (deleted groups, vanished workspaces) are garbage-collected.
- **Storage** — the top-level order is owned by `ISessionSectionOrderService` (persisted locally, not synced) as a flat list of identities (`group:<id>`, `workspace:<label>` for ordinary workspaces, and `workspace:multiRoot:<workspace-file-comparison-key>` for multi-root workspaces). Identities the user has never moved fall back to the list's default order, so new groups/workspaces appear in their natural place until dragged. Stale identities (deleted groups, vanished workspaces) are garbage-collected.
- **By Workspace** — groups and workspace sections share one order and can be freely intermixed. Dragging a workspace also **promotes** it so it stays visible (escapes the "+N more workspaces" capping).
- **By Date** — only group headers are draggable; they reorder within the groups block. Workspace order changes made in the other mode are preserved. The date sections are fixed and are not drop targets.
- **Invariants** — the Pinned section is always first and the Done section always last; neither (nor the date sections, nor "show more" rows) is draggable or a valid header drop target.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ To avoid an empty list on window startup — before the agent host has started,

- A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list.
- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`.
- Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile, name? }`. `workspaceFile` is the complete workspace configuration URI string and `name` is `IWorkspace.name`; the Agent Host persists the validated object as JSON under the `multiRoot` session-database key, reconstructs it during listing/restoration, and the startup cache preserves it before the first live listing. The Editor session list matches this URI directly against `IWorkspace.configuration`; metadata-less sessions use current-folder containment without a separate workspace membership memento.
- Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile, name? }`. `workspaceFile` is the complete workspace configuration URI string and `name` is `IWorkspace.name`; the Agent Host persists the validated object as JSON under the `multiRoot` session-database key, reconstructs it during listing/restoration, and the startup cache preserves it before the first live listing. The adapter exposes this independently through `ISession.multiRootWorkspace`: the Editor session list matches the URI against `IWorkspace.configuration`, while the Agents Window uses it as the stable workspace-section identity and display-name source without changing the operational workspace or row badge.
- Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned.
- `_shouldTrackSessionCacheChanges()` is a hook (default `true`) the remote provider overrides to suspend dirty-tracking while its sessions are unpublished (offline), so the on-disk snapshot survives an unreachable host.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti
import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js';
import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js';
import { isSessionConfigComplete } from '../../../../common/sessionConfig.js';
import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js';
import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionMultiRootWorkspace, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot } from '../../../../services/sessions/common/sessionsProvider.js';
import { IGitHubService } from '../../../github/browser/githubService.js';
Expand Down Expand Up @@ -453,6 +453,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
readonly icon: ThemeIcon;
readonly createdAt: Date;
readonly workspace: ISettableObservable<ISessionWorkspace | undefined>;
readonly multiRootWorkspace: ISettableObservable<ISessionMultiRootWorkspace | undefined>;
readonly isQuickChat: IObservable<boolean>;
/** See {@link ISession.worktreePending}. */
readonly worktreePending: IObservable<boolean>;
Expand Down Expand Up @@ -647,6 +648,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {

this._meta = metadata._meta;
this._metaObs = observableValue<SessionMeta | undefined>('agentHostSessionMeta', this._meta);
this.multiRootWorkspace = observableValue('multiRootWorkspace', this._computeMultiRootWorkspace());

const baseGitHubInfoObs = derivedOpts<IGitHubInfo | undefined>({
equalsFn: isGitHubInfoEqual
Expand Down Expand Up @@ -1258,6 +1260,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
subtransaction(tx, tx => {
this._metaObs.set(this._meta, tx);
didChange = this._promoteToQuickChatIfWorkspaceless(tx);
const multiRootWorkspace = this._computeMultiRootWorkspace();
const currentMultiRootWorkspace = this.multiRootWorkspace.get();
if (multiRootWorkspace?.name !== currentMultiRootWorkspace?.name
|| !isEqual(multiRootWorkspace?.workspaceFile, currentMultiRootWorkspace?.workspaceFile)) {
this.multiRootWorkspace.set(multiRootWorkspace, tx);
didChange = true;
}
const workspace = this._computeWorkspace();
if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) {
this.workspace.set(workspace, tx);
Expand Down Expand Up @@ -1295,6 +1304,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession {
return this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectories, this.gitHubInfo, readSessionGitState(this._meta)));
}

private _computeMultiRootWorkspace(): ISessionMultiRootWorkspace | undefined {
const multiRoot = readSessionMultiRootMetadata(this._meta);
return multiRoot ? { workspaceFile: URI.parse(multiRoot.workspaceFile), name: multiRoot.name } : undefined;
}

updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) {
if (!changesetsMetadata) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,42 @@ suite('LocalAgentHostSessionsProvider', () => {
});
}));

test('session metadata changes publish multi-root workspace provenance', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
agentHost.addSession(createSession('multi-root-meta', {
summary: 'Multi-root Session',
project: { uri: URI.parse('file:///Users/me/project'), displayName: 'project' },
}));
const provider = createProvider(disposables, agentHost);
provider.getSessions();
await timeout(0);
const session = provider.getSessions()[0]!;
const changes: ISessionChangeEvent[] = [];
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
const multiRoot = {
workspaceFile: 'file:///Users/me/project.code-workspace',
name: 'Project Workspace',
};

fireSessionMetaChanged(agentHost, 'multi-root-meta', withSessionMultiRootMetadata(undefined, multiRoot));
fireSessionMetaChanged(agentHost, 'multi-root-meta', withSessionMultiRootMetadata(undefined, multiRoot));

assert.deepStrictEqual({
multiRootWorkspace: session.multiRootWorkspace?.get() ? {
workspaceFile: session.multiRootWorkspace.get()!.workspaceFile.toString(),
name: session.multiRootWorkspace.get()!.name,
} : undefined,
operationalWorkspaceLabel: session.workspace.get()?.label,
changedEvents: changes.map(change => change.changed.map(changed => changed === session)),
}, {
multiRootWorkspace: {
workspaceFile: multiRoot.workspaceFile,
name: multiRoot.name,
},
operationalWorkspaceLabel: 'project',
changedEvents: [[true]],
});
}));

test('getSessions populates from listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
agentHost.addSession(createSession('list-1', { summary: 'First' }));
agentHost.addSession(createSession('list-2', { summary: 'Second' }));
Expand Down
Loading
Loading