diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 5a6ba7cb7abcd2..6da5e310993d52 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -205,6 +205,8 @@ Agent-host chat input completions preserve the host's result order through Monac 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. +`ISessionWorkspace.canCreateSession` optionally indicates whether the Agents Window can create another session for that workspace; omission means supported. Agent Host sessions with `_meta.multiRoot` keep their operational workspace URI and folders but use the recorded workspace name (or workspace-file fallback) as the workspace label and set this capability to `false`. + 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. Folders below a path segment ending in `.worktrees` or named `copilot-worktrees` appear only when the user previously selected them in an Agents picker; they are excluded from VS Code's general recents and never automatically preselected. For other folders, the picker restores the last explicitly selected workspace first, then other Agents-owned recents, and finally the most recent resolvable workspace from VS Code's general history. 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. diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 933e8552146dc5..e71644e8f05914 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -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. Sessions group by their final workspace label. Agent Host sessions with multi-root provenance expose the localized label **` (Workspace)`**, where `` is the recorded workspace name or the `.code-workspace` filename without its suffix. Equal labels intentionally merge into one section. A section omits the `+` action when any member workspace has `canCreateSession === false`; 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. diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index d26e4d197d8ed7..50e86d185fdb51 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -92,7 +92,7 @@ export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | unde String(repo?.uncommittedChanges ?? ''), ].join('\u0001'); }); - return [workspace.label, ...folderKeys].join('\n'); + return [workspace.label, String(workspace.canCreateSession ?? true), ...folderKeys].join('\n'); } export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectories: readonly URI[] | undefined, options: IAgentHostSessionWorkspaceOptions, gitHubInfo: IObservable, gitState?: ISessionGitState): ISessionWorkspace | undefined { diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 42bf067bdb4c0f..2f3cf65a3c5ecf 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -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 keeps the operational `ISessionWorkspace.uri` and `folders`, but derives its label as ` (Workspace)` (using the workspace filename when the name is blank) and sets `canCreateSession: false`. The Agents Window groups that final label and suppresses unsupported section creation. Editor-window filtering remains independent and matches `_meta.multiRoot.workspaceFile` directly against `IWorkspace.configuration`. - 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. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 2e1b909a899c6b..d91d5947d4cfaa 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -12,7 +12,7 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; -import { isEqual, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js'; +import { basename, isEqual, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -1309,7 +1309,21 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * assigned; workspace sessions build from project/git metadata. */ private _computeWorkspace(): ISessionWorkspace | undefined { - return this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectories, this.gitHubInfo, readSessionGitState(this._meta))); + const workspace = this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectories, this.gitHubInfo, readSessionGitState(this._meta))); + const multiRoot = readSessionMultiRootMetadata(this._meta); + if (!workspace || !multiRoot) { + return workspace; + } + + const name = multiRoot.name?.trim(); + const fileName = basename(URI.parse(multiRoot.workspaceFile)); + const workspaceName = name || fileName.replace(/\.code-workspace$/i, ''); + const workspaceLabel = localize('multiRootWorkspaceLabel', "Workspace"); + return { + ...workspace, + label: `${workspaceName} (${workspaceLabel})`, + canCreateSession: false, + }; } updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 35d76724ce7144..4e9d546d77ae37 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -351,7 +351,7 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string; name?: string } }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; workingDirectories?: readonly URI[]; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string; name?: string } }): IAgentSessionMetadata { let _meta = opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined; _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); return { @@ -360,7 +360,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, project: opts?.project, - workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, + workingDirectories: opts?.workingDirectories ?? (opts?.workingDirectory ? [opts.workingDirectory] : undefined), _meta, }; } @@ -1077,6 +1077,76 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('session metadata changes update the operational workspace for a one-folder multi-root session', () => runWithFakedTimers({ 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)); + + const workspace = session.workspace.get()!; + assert.deepStrictEqual({ + label: workspace.label, + canCreateSession: workspace.canCreateSession, + uri: workspace.uri.toString(), + folders: workspace.folders.map(folder => folder.root.toString()), + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + label: 'Project Workspace (Workspace)', + canCreateSession: false, + uri: 'file:///Users/me/project', + folders: ['file:///Users/me/project'], + changedEvents: [[true]], + }); + })); + + test('multi-root workspace metadata falls back to the file name and preserves all working directories', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('multi-root-folders', { + workingDirectories: [URI.file('/work/primary'), URI.file('/work/secondary')], + multiRoot: { + workspaceFile: 'file:///work/Fallback.CODE-WORKSPACE', + name: ' ', + }, + })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const workspace = provider.getSessions()[0].workspace.get()!; + assert.deepStrictEqual({ + label: workspace.label, + canCreateSession: workspace.canCreateSession, + uri: workspace.uri.toString(), + folders: workspace.folders.map(folder => ({ + root: folder.root.toString(), + workingDirectory: folder.workingDirectory.toString(), + })), + }, { + label: 'Fallback (Workspace)', + canCreateSession: false, + uri: 'file:///work/primary', + folders: [{ + root: 'file:///work/primary', + workingDirectory: 'file:///work/primary', + }, { + root: 'file:///work/secondary', + workingDirectory: 'file:///work/secondary', + }], + }); + })); + test('getSessions populates from listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('list-1', { summary: 'First' })); agentHost.addSession(createSession('list-2', { summary: 'Second' })); @@ -1419,7 +1489,7 @@ suite('LocalAgentHostSessionsProvider', () => { name: 'Demo Workspace', }; await persistCachedSessions(disposables, storageService, [ - createSession('multi-root-cached', { summary: 'Multi Root', multiRoot }), + createSession('multi-root-cached', { summary: 'Multi Root', workingDirectory: URI.parse('vscode-remote://ssh-remote+host/work/demo'), multiRoot }), ]); const nextHost = new MockAgentHostService(); disposables.add(toDisposable(() => nextHost.dispose())); @@ -1438,9 +1508,13 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ repersisted: repersisted[0].multiRoot, hydratedTitle: session.title.get(), + workspaceLabel: session.workspace.get()?.label, + canCreateSession: session.workspace.get()?.canCreateSession, }, { repersisted: multiRoot, hydratedTitle: 'Updated after hydration', + workspaceLabel: 'Demo Workspace (Workspace)', + canCreateSession: false, }); })); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 54366f28e18a27..400a8eec201f6e 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -107,6 +107,7 @@ export const SessionItemStatusContext = new RawContextKey('sessio /** Whether the focused session item currently belongs to a user group. */ export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); +export const SessionSectionCanCreateContext = new RawContextKey('sessionSection.canCreate', true); export const SessionGroupHasVisibleSessionsContext = new RawContextKey('sessionGroup.hasVisibleSessions', false); export const SessionGroupIsEmptyContext = new RawContextKey('sessionGroup.isEmpty', false); @@ -133,6 +134,11 @@ export interface ISessionSection { readonly id: string; readonly label: string; readonly sessions: ISession[]; + readonly canCreateSession?: boolean; +} + +export function canCreateSessionForSection(section: ISessionSection | undefined): boolean { + return !!section && section.canCreateSession !== false && section.sessions.length > 0; } /** @@ -1027,6 +1033,7 @@ class SessionSectionRenderer implements ITreeRenderer sessionWorkspaceLabel(s) === targetLabel); + const targetSectionId = this.sessionWorkspaceSectionId(target); + return dragged.every(s => this.sessionWorkspaceSectionId(s) === targetSectionId); } return true; } @@ -2728,8 +2735,8 @@ export class SessionsList extends Disposable implements ISessionsList { const targetGroup = this._sessionGroupsService.getGroupOfSession(target.sessionId); scope = scope.filter(s => this._sessionGroupsService.getGroupOfSession(s.sessionId) === targetGroup); if (targetGroup === undefined && grouping === SessionsGrouping.Workspace) { - const targetLabel = sessionWorkspaceLabel(target); - scope = scope.filter(s => sessionWorkspaceLabel(s) === targetLabel); + const targetSectionId = this.sessionWorkspaceSectionId(target); + scope = scope.filter(s => this.sessionWorkspaceSectionId(s) === targetSectionId); } } @@ -2868,11 +2875,15 @@ export class SessionsList extends Disposable implements ISessionsList { ids.add(`group:${group.id}`); } for (const session of this._sessionsManagementService.getSessions()) { - ids.add(`workspace:${sessionWorkspaceLabel(session)}`); + ids.add(this.sessionWorkspaceSectionId(session)); } return ids; } + private sessionWorkspaceSectionId(session: ISession): string { + return getSessionWorkspaceSectionDescriptor(session).id; + } + private setDropTargetHeader(header: ISessionDropTargetHeader | undefined): void { const current = this._dropTargetHeader; if (current?.kind === header?.kind && current?.id === header?.id) { @@ -3482,38 +3493,48 @@ export function groupSessionsForList( return sections; } -/** The workspace group label a session belongs to (matches {@link groupByWorkspace}). */ -function sessionWorkspaceLabel(session: ISession): string { - return session.workspace.get()?.label || localize('unknown', "Unknown"); +export interface ISessionWorkspaceSectionDescriptor { + readonly id: string; + readonly label: string; + readonly canCreateSession: boolean; +} + +/** Returns the stable workspace-section identity and presentation for a session. */ +export function getSessionWorkspaceSectionDescriptor(session: ISession): ISessionWorkspaceSectionDescriptor { + const workspace = session.workspace.get(); + const label = workspace?.label || localize('unknown', "Unknown"); + return { + id: `workspace:${label}`, + label, + canCreateSession: workspace?.canCreateSession !== false, + }; } export function groupByWorkspace(sessions: ISession[]): ISessionSection[] { - const groups = new Map(); + const groups = new Map(); for (const session of sessions) { - const label = sessionWorkspaceLabel(session); - let group = groups.get(label); + const descriptor = getSessionWorkspaceSectionDescriptor(session); + let group = groups.get(descriptor.id); if (!group) { - group = []; - groups.set(label, group); + group = { ...descriptor, sessions: [] }; + groups.set(descriptor.id, group); + } else if (!descriptor.canCreateSession && group.canCreateSession !== false) { + group = { ...group, canCreateSession: false }; + groups.set(descriptor.id, group); } - group.push(session); + group.sessions.push(session); } const unknownWorkspaceLabel = localize('unknown', "Unknown"); - const order = [...groups.keys()] - .filter(k => k !== unknownWorkspaceLabel) - .sort((a, b) => a.localeCompare(b)); - - const result: ISessionSection[] = order.map(label => ({ - id: `workspace:${label}`, - label, - sessions: groups.get(label)!, - })); + const unknownWorkspaceId = `workspace:${unknownWorkspaceLabel}`; + const result = [...groups.values()] + .filter(section => section.id !== unknownWorkspaceId) + .sort((a, b) => a.label.localeCompare(b.label)); // "Unknown Workspace" always at the bottom - const unknownWorkspace = groups.get(unknownWorkspaceLabel); + const unknownWorkspace = groups.get(unknownWorkspaceId); if (unknownWorkspace) { - result.push({ id: `workspace:${unknownWorkspaceLabel}`, label: unknownWorkspaceLabel, sessions: unknownWorkspace }); + result.push(unknownWorkspace); } return result; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 67153d1c4ce164..19abb570897b8a 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -24,7 +24,7 @@ import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext import { SessionsCategories } from '../../../../common/categories.js'; import { RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; -import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem } from './sessionsList.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionCanCreateContext, SessionSectionTypeContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem, canCreateSessionForSection } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js'; @@ -444,12 +444,15 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { id: SessionSectionToolbarMenuId, group: 'navigation', order: 1, - when: ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), + when: ContextKeyExpr.and( + ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), + SessionSectionCanCreateContext, + ), }] }); } async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { - if (!context || !context.sessions || context.sessions.length === 0) { + if (!context || !canCreateSessionForSection(context)) { return; } const sessionsService = accessor.get(ISessionsService); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index c9026504dc90c4..191b040f76f502 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -9,13 +9,14 @@ import { constObservable, observableValue } from '../../../../../base/common/obs import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, limitSessionsForList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { canCreateSessionForSection, computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, limitSessionsForList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; function createSession(id: string, opts: { workspaceLabel?: string; createdAt?: Date; updatedAt?: Date; isArchived?: boolean; + canCreateSession?: boolean; }): ISession { const createdAt = opts.createdAt ?? new Date(); const updatedAt = opts.updatedAt ?? createdAt; @@ -31,6 +32,7 @@ function createSession(id: string, opts: { label: opts.workspaceLabel, icon: Codicon.folder, folders: [], + canCreateSession: opts.canCreateSession, requiresWorkspaceTrust: false, isVirtualWorkspace: false, } : undefined), @@ -130,6 +132,51 @@ suite('Sessions - SessionsList Helpers', () => { assert.strictEqual(groups[0].id, 'workspace:MyProject'); }); + + test('workspaces with the same final label merge into one section', () => { + const groups = groupByWorkspace([ + createSession('1', { workspaceLabel: 'Shared Name (Workspace)', canCreateSession: false }), + createSession('2', { workspaceLabel: 'Shared Name (Workspace)', canCreateSession: false }), + ]); + + assert.deepStrictEqual(groups.map(group => ({ + id: group.id, + label: group.label, + sessions: group.sessions.map(session => session.sessionId), + canCreateSession: group.canCreateSession, + })), [{ + id: 'workspace:Shared Name (Workspace)', + label: 'Shared Name (Workspace)', + sessions: ['1', '2'], + canCreateSession: false, + }]); + }); + + test('workspace creation capability defaults to supported', () => { + const regular = groupByWorkspace([createSession('regular', { workspaceLabel: 'Repo' })])[0]; + const nonCreatable = groupByWorkspace([createSession('multi', { workspaceLabel: 'Demo (Workspace)', canCreateSession: false })])[0]; + + assert.deepStrictEqual({ + regular: canCreateSessionForSection(regular), + nonCreatable: canCreateSessionForSection(nonCreatable), + }, { + regular: true, + nonCreatable: false, + }); + }); + + test('merged workspace creation capability is conservative regardless of order', () => { + const creatable = createSession('creatable', { workspaceLabel: 'Shared' }); + const nonCreatable = createSession('non-creatable', { workspaceLabel: 'Shared', canCreateSession: false }); + + assert.deepStrictEqual({ + creatableFirst: groupByWorkspace([creatable, nonCreatable])[0].canCreateSession, + nonCreatableFirst: groupByWorkspace([nonCreatable, creatable])[0].canCreateSession, + }, { + creatableFirst: false, + nonCreatableFirst: false, + }); + }); }); suite('groupByDate', () => { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsViewActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewActions.test.ts new file mode 100644 index 00000000000000..ea65e3f60edcc7 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsViewActions.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyValue, IContext } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionSectionCanCreateContext, SessionSectionToolbarMenuId, SessionSectionTypeContext } from '../../browser/views/sessionsList.js'; +import '../../browser/views/sessionsViewActions.js'; + +suite('Sessions - Sessions View Actions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('workspace create action is hidden for multi-root sections', () => { + const action = MenuRegistry.getMenuItems(SessionSectionToolbarMenuId) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessionsView.sectionNewSession'); + assert.ok(action?.when); + const createContext = (canCreate: boolean): IContext => ({ + getValue: (key: string): T | undefined => ({ + [SessionSectionTypeContext.key]: 'workspace', + [SessionSectionCanCreateContext.key]: canCreate, + })[key] as T | undefined, + }); + + assert.deepStrictEqual({ + regularWorkspace: action.when.evaluate(createContext(true)), + nonCreatableWorkspace: action.when.evaluate(createContext(false)), + }, { + regularWorkspace: true, + nonCreatableWorkspace: false, + }); + }); +}); diff --git a/src/vs/sessions/services/sessions/browser/sessionSectionOrderService.ts b/src/vs/sessions/services/sessions/browser/sessionSectionOrderService.ts index ab3e8957610947..3444dea1762f83 100644 --- a/src/vs/sessions/services/sessions/browser/sessionSectionOrderService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionSectionOrderService.ts @@ -14,7 +14,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo * order of user-created groups and workspace sections relative to each other. * * The order is stored as a flat list of opaque identities (the sessions list - * uses `group:` for groups and `workspace: