diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 60931a3381f9e..ed9d1c8b5d266 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -230,7 +230,7 @@ Within a session view, chats default to a single **chat group** rendered as a ta - **Persistence.** When a created session holds more than one group, its partition (each group's ordered chat resources + active chat, the grid tree, sizes, and active group) is persisted to **workspace storage** keyed by `session.sessionId` (a single `sessions.chatGroupsLayout` map). New-session drafts always use one fresh group and clear stale state for their ID. The layout is captured on mutations (split / move / active-group change / reconcile) and re-captured on session switch-away and dispose (to snapshot the latest sash sizes). On reopen, `_tryRestoreLayout` deserializes the grid (each leaf's `index` maps a node back to groups) instead of building a single group. Because a session's chat catalog loads asynchronously after reload, restore keeps a saved `resource → group` assignment alive and routes each chat (including late-loading ones) back to its saved group via the reconcile autorun. Restoration completes when all saved chats are present, the catalog changes from its initial snapshot, or `session.loading` reports that initialization has settled. Missing chats are then treated as deleted and empty groups collapse. Restore is fully observable-driven (no timeouts). A single-group session stores nothing and clears any prior entry. - `ChatGroupView` ([browser/parts/chatGroupView.ts](src/vs/sessions/browser/parts/chatGroupView.ts)) is a single grid leaf hosting a `ChatCompositeBar` (its group's tab strip) above a kind-switched chat view (see the table below). Each group independently renders its own active chat, so multiple chats can be visible side-by-side. When the group's active chat is **read-only** (non-interactive — e.g. a subagent transcript or an archived session), a `SessionReadOnlyBanner` ([browser/parts/sessionReadOnlyBanner.ts](src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts)) is shown flush below the tab strip in place of the composer, with an inline **Restore** action for archived sessions. The banner is aligned with the tab strip: with a lone group it is capped to the centered content band and centered (via a `.single-group` CSS rule in [chatGroupsView.css](src/vs/sessions/browser/parts/media/chatGroupsView.css), mirroring the tab strip's rule); with more than one group it spans the full leaf width. Its `toJSON` carries the group's serialization `index` so the grid deserializer can map restored nodes back to groups. - `ChatCompositeBar` ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) is a tab-strip renderer driven by an `IChatCompositeBarDelegate` supplied by the owning group. Its tabs are draggable and render this group's `visibleChatTabs` in the group's order; a read-only chat's tab shows a **lock** icon. The tab strip is shown (via the group's `tabsVisible` observable) when more than one group exists, or — for a lone group — when `IActiveSession.shouldShowChatTabs` is set (the session has more than one visible chat tab). At the end of the strip a trailing **New Chat** button (gated on `ISessionCapabilities.supportsMultipleChats`, disabled for archived sessions) is pinned; New Chat routes back through the delegate so the new chat opens into the clicked group. The **Conversations** menu is not on the tab strip — it lives in the session header meta row (see §4.1). Each non-main tab renders its close button from the contributed per-tab `Menus.SessionChatTab` (context = `{ session, chat }`), whose `sessions.chatCompositeBar.closeChat` command hides the chat session-wide (reopenable from Conversations); the tab context menu offers **Rename** / **Delete Chat** gated on `getChatCapabilities`. A tab is also a drag source for a `#chat` reference (via `fillChatReferenceDragData`, resolving the chat's backend resource through `ISessionsProvidersService`) so it can be dropped into an agent-host chat input. Because the Agents workbench is always modern, the tab DOM consumes reusable editor-tab hooks from [workbench/contrib/styleOverrides/browser/media/tabs.css](src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css), while [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css) retains only chat-specific layout and adornments. `SessionView` exposes the shared hook's focused/unfocused group state so side-by-side sessions follow the same color branches as editor groups, and `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) supplies the shared visual tokens. -- Drag-and-drop is handled by `ChatGroupDropTarget` ([browser/parts/chatGroupDropTarget.ts](src/vs/sessions/browser/parts/chatGroupDropTarget.ts)), which displays a 5-zone overlay (left / right / top / bottom / center) on the hovered group. Dropping a chat onto a group's **center** moves it into that group; dropping it onto an **edge** splits it into a new group in that direction. The dragged chat's `{ sessionId, resource }` is carried on the drag event's **`dataTransfer`** (mime `SessionsDataTransfers.CHAT`, via `fillSessionChatDragData`/`isSessionChatDrag`/`getSessionChatDragData` in [browser/dnd.ts](src/vs/sessions/browser/dnd.ts)); drops from a different session are ignored. **Pitfall:** the group-move payload must **not** use the shared `LocalSelectionTransfer` singleton, because a chat-tab drag also offers a chat-*reference* payload (`DraggedChatReferenceIdentifier`, dropped into a chat input) that uses that same singleton — and `LocalSelectionTransfer` is a single global slot, so whichever payload is set last wins. Reference-carrying tabs (agent-host chats) would otherwise clobber the group-move identifier, so the drop target's `dragenter` saw no chat drag and never showed the split zones. The `dataTransfer` keeps the two payloads independent: its `types` are readable during `dragover` (to gate the overlay) and its value on `drop`. +- Drag-and-drop is handled by `ChatGroupDropTarget` ([browser/parts/chatGroupDropTarget.ts](src/vs/sessions/browser/parts/chatGroupDropTarget.ts)), which displays a 5-zone overlay (left / right / top / bottom / center) on the hovered group. Dropping a chat onto a group's **center** moves it into that group; dropping it onto an **edge** splits it into a new group in that direction. Subagent pills in the transcript use the same payload and drop zones; because subagents are hidden from the tab strip until opened, the drop first surfaces the subagent and then places it in the selected group or split. Alt+Enter on a focused subagent pill provides the keyboard-equivalent open-to-side action. The dragged chat's `{ sessionId, resource }` is carried on the drag event's **`dataTransfer`** (mime `SessionsDataTransfers.CHAT`, via `fillSessionChatDragData`/`isSessionChatDrag`/`getSessionChatDragData` in [browser/dnd.ts](src/vs/sessions/browser/dnd.ts)); drops from a different session are ignored. **Pitfall:** the group-move payload must **not** use the shared `LocalSelectionTransfer` singleton, because a chat-tab drag also offers a chat-*reference* payload (`DraggedChatReferenceIdentifier`, dropped into a chat input) that uses that same singleton — and `LocalSelectionTransfer` is a single global slot, so whichever payload is set last wins. Reference-carrying tabs (agent-host chats) would otherwise clobber the group-move identifier, so the drop target's `dragenter` saw no chat drag and never showed the split zones. The `dataTransfer` keeps the two payloads independent: its `types` are readable during `dragover` (to gate the overlay) and its value on `drop`. - Keyboard users can focus the previous/next chat group, split the active chat right/down, and move it to the previous/next group through the corresponding Sessions commands. In multi-group layouts, each group and tab list announces its one-based position and total count. - A newly opened, unassigned chat whose `origin.parentChat` is visible uses an existing group adjacent to that parent when one is available, regardless of whether it was opened from the transcript, Chats menu, or another surface. Existing and manually moved assignments remain authoritative. Without an adjacent group it opens normally; explicit open-to-side creates a new group. - **Width.** With a lone group the session reads like the classic centered chat: the header, the group's tab strip, and the inner chat content (message/input cards) all align to the centered 950px band. Once the session holds **more than one group**, everything spans full width — `ChatGroupsView` drops the `.single-group` class so the centered cap on the chat content and tab strips is removed (`max-width: none` in [chatGroupsView.css](src/vs/sessions/browser/parts/media/chatGroupsView.css)), and `SessionView` adds a `.grid-layout` class and lays the header band out at full width too (driven by an autorun on `ChatGroupsView.groupCount`). @@ -319,7 +319,7 @@ The entire third-pane redesign is gated behind the experimental setting `session - Applying the Existing Session visibility profile restores both Editor and Details visibility. - During reload there is a window after the workbench reaches `Restored` but before `restoreVisibleSessions()` supplies an active session. The New/Existing Session strategies (via `SinglePaneDetailPanelCoordinator`) return `Preserve` in that state; treating the missing session as `Hidden` would close persisted Aux, whose layout invariant reveals Editor, and paint Editor-only until the session profile arrives. - Widening a detail-only editor node does not automatically reveal editor content. The editor area remains hidden until the user explicitly opens an editor workflow or toggles the editor area. This preserves the user's detail-only choice across sash drags and grid relayouts. -- When the outer editor sash makes a visible editor and its docked details too narrow to coexist, single-pane automatically hides details and leaves editor content visible. If the user widens the node past the detail width plus the editor minimum and a 100px hysteresis margin, it restores the details. This responsive detail behavior is exclusive to the single-pane layout. +- When the outer editor sash makes a visible editor and its docked details too narrow to coexist, single-pane automatically hides details and leaves editor content visible. It captures the editor width after that hide and restores details only when the node can fit both the captured editor width and the detail width, so restoring details does not shrink the editor. This responsive detail behavior is exclusive to the single-pane layout. - Revealing the side pane from *closed* (`setEditorHidden(false)`, e.g. the session-header Changes button opening the Changes editor) passes `Sizing.Distribute` to `SerializableGrid.setViewVisible`. The grid already knows the revealed view's location, so it distributes that containing split and Sessions and the side pane receive equal space without either part computing pixels, percentages, or a split reference. The side pane sash's double-click reset uses the same native grid distribution because the visible editor part has no fixed `preferredWidth`. In docked mode this runs on every reveal that has no saved user width to restore; a genuinely user-chosen width still takes precedence. - Side-pane sizes are **workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes`), so switching between sessions keeps the same side-pane width the user last set — the layout controller does not track or restore a per-session width. The workbench persists the docked side-pane geometry across reloads via `_savePartSizes` on `onWillSaveState`, restored by `createDesktopGridDescriptor`. Because the docked detail (auxiliary bar) lives **inside** the editor grid node, the persisted editor value is the pure editor-content width: `_persistedEditorWidth` subtracts the docked detail width **only when the detail is visible**, mirroring the descriptor, which adds it back only when the detail is visible. Subtracting it unconditionally (the earlier bug) shrank an **Editor-only** session's side pane by the detail width on every reload, compounding toward zero. - `_dockedEditorSizeBeforeHide` is captured on hide **only for "Hide Editor"** (detail/auxiliary bar still visible, so the editor node stays visible at a real user-chosen width). When the **whole** side pane closes, the editor grid node collapses to `0px`; that is not captured as a user width, so reopening falls through to the last persisted width or the equal Sessions/side-pane split. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index a3e0c55e646b8..b48a38092f887 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -84,7 +84,7 @@ width) captures a width to restore later. | **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (Browser `⇧⌘K B`; Search `⌘K S` for workspace-backed sessions; a **Changes** entry when the Changes editor tab is absent, and a **Files** entry `⌘K B` when the Files tab is absent — both for any workspace session). Restored managed Changes/Files tabs are inserted at the **end** of the tab strip. Search opens a new Search editor and is unavailable for Quick Chats. **Hidden when the editor area is closed.** | | **Toggle Side Panel** | Command / keybinding | Closes/opens the **whole** side pane (editor + detail together) → chat-only and back. The mechanics live on the workbench layout service (`toggleSidePane`); while the editor area is maximized, the shared `Workbench.toggleSidePane()` remembers maximization, un-maximizes, then performs the collapse so the restored detail is also hidden. Reopening restores the complete side-pane composition before re-maximizing the editor. Hiding a focused side pane moves focus to the sessions list. | | **Toggle Sessions List** | Title bar / command | Collapses/opens the left sessions list. Collapsing it gives the freed width to the editor/detail side pane (not the chat); reopening restores the previous editor/detail width so the chat gets that space back. No single-pane editor or detail action changes this visibility. | -| **Grid sash** | Between the chat and the third pane | Dragging a detail-only side pane wider keeps the editor content closed. When editor content and details are visible but no longer fit, the detail panel hides; widening past the hysteresis threshold restores it. | +| **Grid sash** | Between the chat and the third pane | Dragging a detail-only side pane wider keeps the editor content closed. When editor content and details are visible but no longer fit, the detail panel hides; widening until the pane can restore details without shrinking the expanded editor restores it. | | **Changes pill** | Session header meta row | Opens the managed Changes multi-diff editor and explicitly reveals the editor area when the side pane was closed or in detail-only mode. The managed Changes tab still remains excluded from automatic reveal-on-open, so merely activating its tab does not reveal the editor. | **Editor action visibility.** Maximize/Restore, Toggle Details, and Open in Modal are hidden while the **editor area is closed** (`MainEditorAreaVisibleContext`). Hide Editor and Show Editor are the mutually-exclusive pair that controls that very state: both render in the tab strip's editor-title layout cluster (`MenuId.EditorTitleLayout`), immediately after Maximize/Restore, gated only on `MainEditorAreaVisibleContext` being true/false respectively — unlike Toggle Details, they always show and are always enabled regardless of whether the active tab has a docked detail panel or the detail panel is currently visible (no `HasDockedDetailsContext` gate and no `AuxiliaryBarVisibleContext` precondition), consistent with Maximize/Restore's own always-shown behavior in that same cluster. Hide Editor unconditionally reveals the auxiliary bar as part of its `run()`, so it always has somewhere to fall back to even if the detail panel was hidden beforehand — the New/Existing Session strategy's detail-panel mapping (via the shared `SinglePaneDetailPanelCoordinator`) decides what that panel actually shows (the active tab's own detail, or the Changes/Files fallback for a Browser tab with none of its own; see §5). Show Editor reveals the editor via the same explicit-reveal API (`revealEditorPartExplicitly()`) used by the session-header Changes pill, then focuses the editor group. Toggle Details remains alone in its own trailing editor-header cluster and keeps its **has a docked detail panel** (`HasDockedDetailsContext`) gating — a managed Changes/Files tab or a text file editor — since toggling a nonexistent detail panel is never meaningful. diff --git a/src/vs/sessions/browser/dnd.ts b/src/vs/sessions/browser/dnd.ts index cc85c920cb1b9..fc7c74ecbdd40 100644 --- a/src/vs/sessions/browser/dnd.ts +++ b/src/vs/sessions/browser/dnd.ts @@ -13,7 +13,7 @@ import { DraggedChatReferenceIdentifier, fillInChatReferenceDragData, LocalSelec export const SessionsDataTransfers = { /** Mime type used to identify a session being dragged within the application. */ SESSION: 'application/vnd.code.session', - /** Mime type used to identify a chat being dragged between groups within a session. */ + /** Mime type used to identify a chat being dragged into or between groups within a session. */ CHAT: 'application/vnd.code.session.chat', }; @@ -31,13 +31,13 @@ export class DraggedSessionIdentifier { } /** - * The group-move payload carried on a chat-tab drag via the + * The group-placement payload carried on a chat drag via the * {@link SessionsDataTransfers.CHAT} `dataTransfer` mime. Used to move/split a - * chat between chat groups within a session. + * visible chat between groups or open a hidden chat in a group within a session. * * This is deliberately carried on the drag event's `dataTransfer` (not on the - * shared {@link LocalSelectionTransfer} singleton) because a chat-tab drag also - * offers a chat *reference* payload, and that reference uses the singleton. The + * shared {@link LocalSelectionTransfer} singleton) because a chat-tab drag can + * also offer a chat *reference* payload, and that reference uses the singleton. The * singleton holds only one payload at a time, so relying on it here would let * the reference payload clobber the group-move payload (and vice versa). The * `dataTransfer` mime keeps the two independent: its `types` are readable during @@ -49,7 +49,7 @@ export interface IDraggedSessionChat { } /** - * Attaches the {@link IDraggedSessionChat} group-move payload to a chat-tab drag. + * Attaches the {@link IDraggedSessionChat} group-placement payload to a chat drag. */ export function fillSessionChatDragData(e: DragEvent, sessionId: string, resource: URI): void { const data: IDraggedSessionChat = { sessionId, resource: resource.toString() }; diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index ba50957a00e15..bb7ff054f77d6 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -156,7 +156,9 @@ export class ChatGroupsView extends Themable { const dropDelegate: IChatGroupDropTargetDelegate = { isChatDrag: event => isSessionChatDrag(event, session.sessionId), findTargetGroup: child => this._findTargetGroup(child), - onChatDrop: (groupId, zone, data) => this._onChatDrop(groupId, zone, data), + onChatDrop: (groupId, zone, data) => { + this._onChatDrop(groupId, zone, data).catch(onUnexpectedError); + }, }; store.add(this._instantiationService.createInstance(ChatGroupDropTarget, this.element, dropDelegate)); @@ -413,7 +415,7 @@ export class ChatGroupsView extends Themable { return undefined; } - private _onChatDrop(targetGroupId: number, zone: ChatDropZone, data: IDraggedSessionChat | undefined): void { + private async _onChatDrop(targetGroupId: number, zone: ChatDropZone, data: IDraggedSessionChat | undefined): Promise { if (!data || !this._session) { return; } @@ -424,8 +426,20 @@ export class ChatGroupsView extends Themable { const id = data.resource; const resource = URI.parse(id); - const target = this._groups.find(g => g.id === targetGroupId); - const source = this._groups.find(g => g.resourceIds.get().includes(id)); + let target = this._groups.find(g => g.id === targetGroupId); + let source = this._groups.find(g => g.resourceIds.get().includes(id)); + if (!source) { + const session = this._session; + if (!session.chats.get().some(chat => chat.resource.toString() === id)) { + return; + } + await this._sessionsService.openChat(session, resource); + if (this._session !== session) { + return; + } + target = this._groups.find(g => g.id === targetGroupId); + source = this._groups.find(g => g.resourceIds.get().includes(id)); + } if (!target || !source) { return; } diff --git a/src/vs/sessions/browser/singlePaneWorkbench.ts b/src/vs/sessions/browser/singlePaneWorkbench.ts index 60dd82a8be615..e13d76e4e92a3 100644 --- a/src/vs/sessions/browser/singlePaneWorkbench.ts +++ b/src/vs/sessions/browser/singlePaneWorkbench.ts @@ -33,11 +33,10 @@ export class SinglePaneWorkbench extends Workbench { /** Node width past the detail width at which editor content counts as visible. */ private static readonly _EDITOR_CONTENT_VISIBLE_THRESHOLD = 4; - private static readonly _DETAIL_AUTO_SHOW_MARGIN = 100; private _dockedAuxiliaryBarWidth = DockedAuxiliaryBarController.DEFAULT_WIDTH; private _syncingEditorVisibility = false; - private _detailHiddenForEditorResize = false; + private _editorWidthAfterDetailAutoHide: number | undefined; private readonly _memento = new DockedEditorSizeMemento(); override get isSinglePaneLayoutEnabled(): boolean { @@ -249,7 +248,7 @@ export class SinglePaneWorkbench extends Workbench { protected override _fireDidChangePartVisibility(partId: Parts, visible: boolean, source?: 'resize'): void { if (partId === Parts.AUXILIARYBAR_PART && source !== 'resize') { - this._detailHiddenForEditorResize = false; + this._editorWidthAfterDetailAutoHide = undefined; } super._fireDidChangePartVisibility(partId, visible, source); } @@ -272,15 +271,17 @@ export class SinglePaneWorkbench extends Workbench { try { const detailFitsBesideEditor = nodeWidth >= this._dockedAuxiliaryBarWidth + EDITOR_PART_MINIMUM_WIDTH; if (this.partVisibility.editor && this.partVisibility.auxiliaryBar && !detailFitsBesideEditor) { - this._detailHiddenForEditorResize = true; + this._editorWidthAfterDetailAutoHide = nodeWidth; this.setAuxiliaryBarHiddenForResize(true); return; } - const detailShowThreshold = this._dockedAuxiliaryBarWidth + EDITOR_PART_MINIMUM_WIDTH + SinglePaneWorkbench._DETAIL_AUTO_SHOW_MARGIN; - if (this.partVisibility.editor && !this.partVisibility.auxiliaryBar && this._detailHiddenForEditorResize && nodeWidth >= detailShowThreshold) { + const detailShowThreshold = this._editorWidthAfterDetailAutoHide === undefined + ? undefined + : this._editorWidthAfterDetailAutoHide + this._dockedAuxiliaryBarWidth; + if (this.partVisibility.editor && !this.partVisibility.auxiliaryBar && detailShowThreshold !== undefined && nodeWidth >= detailShowThreshold) { this.setAuxiliaryBarHiddenForResize(false); - this._detailHiddenForEditorResize = false; + this._editorWidthAfterDetailAutoHide = undefined; return; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 15fdd30f2bc87..fa6066a52a736 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -51,6 +51,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single visible chat tab, and as a compact button at the end of the chat tab strip once the session has more than one visible chat tab. Activate it to start a new chat. A Chats dropdown is also shown in the session header meta row, at the end of the pills, once the session has more than one committed chat or the active chat has subagents. Side chats appear as first-level chats. A Subagents group lists work delegated by the active chat, and every item announces its state. When there is one first-level chat, only its Subagents are listed. The active chat or subagent is selected when the dropdown opens. Select an item to open or focus it.")); + content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat.")); content.push(localize('sessionsChat.chatGroups', "Chats can be arranged in groups. Focus the previous group{0} or next group{1}. Split the active chat into a group to the right{2} or below{3}, or move it to the previous group{4} or next group{5}.", ``, ``, ``, ``, ``, ``)); content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); content.push(localize('sessionsChat.deleteChat', "To permanently delete a chat, open the chat tab's context menu and choose Delete Chat. This is destructive and cannot be undone.")); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts index 7773e2dab4b0d..8bc5fd6fb4333 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts @@ -14,6 +14,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID } from '../../../../../workbench/contrib/chat/common/constants.js'; import { OpenSubagentChatActionViewItem, shouldShowSubagentModel, subagentChatOpenerRegistry } from '../../../../../workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.js'; +import { fillSessionChatDragData } from '../../../../browser/dnd.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -96,8 +97,17 @@ class OpenSubagentChatActionViewItemContribution extends Disposable implements I if (!(action instanceof MenuItemAction)) { return undefined; } - const viewItem = instantiationService.createInstance(OpenSubagentChatActionViewItem, undefined, action, options, false); + const viewItem = instantiationService.createInstance(OpenSubagentChatActionViewItem, undefined, action, { ...options, draggable: true }, false); viewItem.trackEnabled((context, update) => autorun(reader => update(!!findSubagentChat(sessionsService, context.chatResource, reader)))); + viewItem.setDragDataProvider((context, event) => { + const match = findSubagentChat(sessionsService, context.chatResource); + if (!match || !event.dataTransfer) { + return false; + } + fillSessionChatDragData(event, match.session.sessionId, match.chat.resource); + event.dataTransfer.effectAllowed = 'move'; + return true; + }); return viewItem; }, onDidRegister.event)); onDidRegister.fire(); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts index 8862df2b4f542..3e0ad8691dd92 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { EventType } from '../../../../../../base/browser/dom.js'; import { Action } from '../../../../../../base/common/actions.js'; import { Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -76,6 +78,58 @@ suite('OpenSubagentChatActionViewItem', () => { }); }); + test('provides drag data and opens to the side from the keyboard', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(ISessionsService, { + activeSession: observableValue('activeSession', undefined), + visibleSessions: observableValue('visibleSessions', []), + }); + instantiationService.stub(ILanguageModelsService, { + onDidChangeLanguageModels: Event.None, + lookupLanguageModel: () => undefined, + }); + let openContext: unknown; + const action = store.add(new Action('openSubagent', 'Open Subagent', undefined, true, context => { + openContext = context; + })); + const viewItem = store.add(instantiationService.createInstance( + OpenSubagentChatActionViewItem, + { chatResource: 'ahp-chat://subagent/session/tool-call' }, + action, + { draggable: true }, + false, + )); + let dragResource: string | undefined; + viewItem.setDragDataProvider(context => { + dragResource = context.chatResource; + return true; + }); + viewItem.trackEnabled((_context, update) => { + update(true); + return Disposable.None; + }); + const container = document.createElement('div'); + viewItem.render(container); + + const dragStart = new DragEvent(EventType.DRAG_START, { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); + container.dispatchEvent(dragStart); + const keyDown = new KeyboardEvent(EventType.KEY_DOWN, { key: 'Enter', altKey: true, bubbles: true, cancelable: true }); + Object.defineProperty(keyDown, 'keyCode', { value: 13 }); + container.dispatchEvent(keyDown); + + assert.deepStrictEqual({ + draggable: container.draggable, + dragPrevented: dragStart.defaultPrevented, + dragResource, + openContext, + }, { + draggable: true, + dragPrevented: false, + dragResource: 'ahp-chat://subagent/session/tool-call', + openContext: { chatResource: 'ahp-chat://subagent/session/tool-call', toSide: true }, + }); + }); + test('refreshes accessible metadata when the active tool clears', () => { const instantiationService = workbenchInstantiationService(undefined, store); instantiationService.stub(ISessionsService, { diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 80903ddf73c69..5fe62d0aebf9f 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -248,6 +248,27 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('dropping a hidden subagent on an edge opens it in a new group', async () => { + const { view } = createHarness(disposables); + const main = createChat('main'); + const subagent = createChat('subagent', SessionStatus.Completed, main.resource); + const session = new TestActiveSession([main, subagent], [main]); + view.setSession(session, options); + + await view['_onChatDrop'](view['_groups'][0].id, 'right', { sessionId: session.sessionId, resource: subagent.resource.toString() }); + + const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); + assert.deepStrictEqual({ + groupCount: view.groupCount.get(), + groupTabs: groups.map(group => Array.from(group.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource)), + activeChat: session.activeChat.get().resource.toString(), + }, { + groupCount: 2, + groupTabs: [[main.resource.toString()], [subagent.resource.toString()]], + activeChat: subagent.resource.toString(), + }); + }); + test('opening a subagent through the sessions service uses the group adjacent to its parent', async () => { const { sessionsService, view } = createHarness(disposables); const main = createChat('main'); @@ -292,14 +313,14 @@ suite('Sessions - ChatGroupsView', () => { ]); }); - test('left split updates logical and accessible group order', () => { + test('left split updates logical and accessible group order', async () => { const { view } = createHarness(disposables); const main = createChat('main'); const secondary = createChat('secondary'); const session = new TestActiveSession([main, secondary]); view.setSession(session, options); - view['_onChatDrop'](view['_groups'][0].id, 'left', { sessionId: session.sessionId, resource: secondary.resource.toString() }); + await view['_onChatDrop'](view['_groups'][0].id, 'left', { sessionId: session.sessionId, resource: secondary.resource.toString() }); const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); const labelByChat = Object.fromEntries(groups.map(group => [ diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 9d6c632daa4f0..1f22d25f58224 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -39,6 +39,10 @@ suite('Sessions - Workbench', () => { const onEditorNodeResized = Reflect.get(SinglePaneWorkbench.prototype, '_onEditorNodeResized') as (this: ITestWorkbench, nodeWidth: number) => void; const onGridDidChange = Reflect.get(SinglePaneWorkbench.prototype, '_onGridDidChange') as (this: ITestWorkbench) => void; const onEditorPartGridVisibilityChange = Reflect.get(SinglePaneWorkbench.prototype, '_onEditorPartGridVisibilityChange') as (this: ITestWorkbench, visible: boolean) => void; + const fireDidChangePartVisibilitySinglePane = Reflect.get(SinglePaneWorkbench.prototype, '_fireDidChangePartVisibility') as (this: { + _editorWidthAfterDetailAutoHide: number | undefined; + _onDidChangePartVisibility: { fire(event: IPartVisibilityChangeEvent): void }; + }, partId: Parts, visible: boolean, source?: 'resize') => void; const persistedEditorWidth = Reflect.get(SinglePaneWorkbench.prototype, '_persistedEditorWidth') as (this: ITestWorkbench, editorGridWidth: number | undefined) => number | undefined; const rememberAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'rememberAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; @@ -78,7 +82,7 @@ suite('Sessions - Workbench', () => { _restoreSidePaneEditorMaximizedOnShow: boolean; _hasAppliedInitialEditorSplit: boolean; _dockedAuxiliaryBarWidth: number; - _detailHiddenForEditorResize: boolean; + _editorWidthAfterDetailAutoHide: number | undefined; _memento: DockedEditorSizeMemento; readonly resizes: IViewSize[]; readonly distributions: object[]; @@ -279,7 +283,7 @@ suite('Sessions - Workbench', () => { // docked bookkeeping _dockedAuxiliaryBarWidth: options.dockedWidth ?? DockedAuxiliaryBarController.DEFAULT_WIDTH, _syncingEditorVisibility: false, - _detailHiddenForEditorResize: false, + _editorWidthAfterDetailAutoHide: undefined, _memento: new DockedEditorSizeMemento(), // stubs for the heavy base helpers the hooks call _savePartVisibility: () => { counts.save++; }, @@ -1566,37 +1570,43 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual({ editorVisible: host.partVisibility.editor, detailVisible: host.partVisibility.auxiliaryBar, - detailHiddenForEditorResize: host._detailHiddenForEditorResize, + editorWidthAfterDetailAutoHide: host._editorWidthAfterDetailAutoHide, events: host.events, layoutCount: host.counts.layout, saveCount: host.counts.save, }, { editorVisible: true, detailVisible: false, - detailHiddenForEditorResize: true, + editorWidthAfterDetailAutoHide: 599, events: [{ partId: Parts.AUXILIARYBAR_PART, visible: false, source: 'resize' }], layoutCount: 1, saveCount: 0, }); }); - test('shows details when the editor sash restores room after an automatic hide', () => { + test('shows details when the editor sash can preserve the width captured after an automatic hide', () => { const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 600, partVisibility: { editor: true, auxiliaryBar: true } }); onEditorNodeResized.call(host, 599); - onEditorNodeResized.call(host, 700); + onEditorNodeResized.call(host, 898); + const detailVisibleBelowTarget = host.partVisibility.auxiliaryBar; + onEditorNodeResized.call(host, 899); assert.deepStrictEqual({ editorVisible: host.partVisibility.editor, + detailVisibleBelowTarget, detailVisible: host.partVisibility.auxiliaryBar, - detailHiddenForEditorResize: host._detailHiddenForEditorResize, + editorWidthAfterDetailAutoHide: host._editorWidthAfterDetailAutoHide, + editorWidthAfterDetailAutoShow: 899 - host._dockedAuxiliaryBarWidth, events: host.events, layoutCount: host.counts.layout, saveCount: host.counts.save, }, { editorVisible: true, + detailVisibleBelowTarget: false, detailVisible: true, - detailHiddenForEditorResize: false, + editorWidthAfterDetailAutoHide: undefined, + editorWidthAfterDetailAutoShow: 599, events: [ { partId: Parts.AUXILIARYBAR_PART, visible: false, source: 'resize' }, { partId: Parts.AUXILIARYBAR_PART, visible: true, source: 'resize' }, @@ -1606,6 +1616,31 @@ suite('Sessions - Workbench', () => { }); }); + test('clears the captured editor width only for explicit detail visibility changes', () => { + const events: IPartVisibilityChangeEvent[] = []; + const host = { + _editorWidthAfterDetailAutoHide: 599, + _onDidChangePartVisibility: { fire: (event: IPartVisibilityChangeEvent) => events.push(event) }, + }; + + fireDidChangePartVisibilitySinglePane.call(host, Parts.AUXILIARYBAR_PART, false, 'resize'); + const widthAfterResize = host._editorWidthAfterDetailAutoHide; + fireDidChangePartVisibilitySinglePane.call(host, Parts.AUXILIARYBAR_PART, true); + + assert.deepStrictEqual({ + widthAfterResize, + widthAfterExplicitChange: host._editorWidthAfterDetailAutoHide, + events, + }, { + widthAfterResize: 599, + widthAfterExplicitChange: undefined, + events: [ + { partId: Parts.AUXILIARYBAR_PART, visible: false, source: 'resize' }, + { partId: Parts.AUXILIARYBAR_PART, visible: true, source: undefined }, + ], + }); + }); + test('does not hide docked editor when node is squeezed but detail is also hidden', () => { const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 600, partVisibility: { editor: true, auxiliaryBar: false } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts index 431a573a8b9b0..579dd9602d10f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts @@ -5,12 +5,14 @@ import './media/chatSubagentOpenChat.css'; import { $, addDisposableListener, EventHelper, EventLike, EventType, isHTMLElement, WindowIntervalTimer } from '../../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { createPixelSpinner } from '../../../../../../base/browser/ui/pixelSpinner/pixelSpinner.js'; import { Action, IAction } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -202,6 +204,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { private readonly _pillHover = this._register(new MutableDisposable()); private readonly _enabledTracker = this._register(new MutableDisposable()); private _enabledTrackerFactory: ((context: IOpenSubagentChatContext, update: (enabled: boolean) => void) => IDisposable) | undefined; + private _dragDataProvider: ((context: IOpenSubagentChatContext, event: DragEvent) => boolean) | undefined; private _labelElement: HTMLElement | undefined; private _pillContentElement: HTMLElement | undefined; private _modelElement: HTMLElement | undefined; @@ -275,6 +278,21 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { pillHeader.append(pillContent, this._durationElement); container.append(pillHeader, this._activeToolElement); this._pillHover.value = this.hoverService.setupDelayedHover(pillContent, () => ({ content: this.getTooltip() ?? '' })); + if (this.options.draggable) { + this._register(addDisposableListener(container, EventType.DRAG_START, (event: DragEvent) => { + const context = asOpenSubagentChatContext(this._context); + if (!this.action.enabled || !context || !this._dragDataProvider?.(context, event)) { + event.preventDefault(); + } + })); + this._register(addDisposableListener(container, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.altKey && keyboardEvent.keyCode === KeyCode.Enter) { + EventHelper.stop(event, true); + this._openToSide(); + } + })); + } this._update(); } @@ -287,16 +305,23 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { // Alt-click opens the subagent chat to the side (in a new group) rather // than in place. Thread the intent through the action context. if ((event as MouseEvent).altKey) { - const context = asOpenSubagentChatContext(this._context); - if (context) { + if (this._openToSide()) { EventHelper.stop(event, true); - this.actionRunner.run(this.action, { ...context, toSide: true }); return; } } super.onClick(event, preserveFocus); } + private _openToSide(): boolean { + const context = asOpenSubagentChatContext(this._context); + if (!this.action.enabled || !context) { + return false; + } + this.actionRunner.run(this.action, { ...context, toSide: true }); + return true; + } + override setActionContext(newContext: unknown): void { const previousResource = asOpenSubagentChatContext(this._context)?.chatResource; super.setActionContext(newContext); @@ -344,6 +369,10 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { this._restartEnabledTracker(); } + setDragDataProvider(provider: (context: IOpenSubagentChatContext, event: DragEvent) => boolean): void { + this._dragDataProvider = provider; + } + private _restartEnabledTracker(): void { const context = asOpenSubagentChatContext(this._context); if (!context || !this._enabledTrackerFactory) {