diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f883b3024d83c..781ec304d1733 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -34,7 +34,7 @@ Editors open as modal overlays via `ModalEditorPart`. The main editor part exist | Part | Position | Default Visibility | Purpose | |------|----------|-------------------|---------| -| Titlebar | Top, full width | Always visible | Session picker, toggle actions, account widget | +| Titlebar | Top, full width | Always visible | Active-session identity, navigation and layout actions, account widget | | Sidebar | Left, below titlebar | Visible | Sessions list | | Sessions Part | Center of right section | Visible | Grid of one or more session views (each rendering the active chat of its session) | | Custom View Grid | Same row as the Sessions Part | Hidden | Grid of custom views shown *instead of* the Sessions Part — see [§2.4](#24-custom-view-grid) | @@ -127,7 +127,7 @@ The titlebar is a standalone implementation (`TitlebarPart`) — not extending ` | Section | Menu ID | Content | |---------|---------|---------| | Left | `Menus.TitleBarLeftLayout` | Toggle sidebar, new session (when sidebar hidden, A/B experiment), agent host filter | -| Center | `Menus.CommandCenter` | Session picker widget | +| Center | `Menus.CommandCenter` | Active-session identity | | Right | `Menus.TitleBarUpdate`, `Menus.TitleBarSessionMenu`, `Menus.TitleBarRightLayout` | The leftmost Update indicator, active-session actions (including Create Pull Request for created sessions with changes), remote connections, run script (split button), Open in VS Code, bottom-panel and auxiliary-bar layout toggles, and the account widget | No menubar or `WindowTitle` dependency. Editor-specific actions remain in the editor header, while session-level actions are placed on the right of the title bar. @@ -138,20 +138,14 @@ The Update indicator occupies its own toolbar at the leading edge of the right-s The account widget shows overlapping provider identities only for accounts that are currently verified as signed in. Its panel keeps provider and status groups in a stable order: Copilot, ChatGPT (or its sign-in action), then contributed account status such as Codebase Semantic Index, with dividers between groups. Subscription usage uses a two-row metric layout with the plan and percentage first, followed by reset timing and the usage label. -### Session Picker (Center) +### Session Identity (Center) -The center section shows a clickable session picker widget. When a session is active it renders: +For an active session, the center section shows a passive session identity with: - **Provider icon** — the session type icon (e.g. Copilot CLI, Cloud) - **Session title** — the AI-generated or user-assigned session title - **Workspace name** — the repository or folder name -- **Branch / worktree** — the active git branch or worktree name in parentheses -- **Changes summary** — `+insertions -deletions` when the session has pending changes -When no session is active (new chat view) the widget hides its chrome so the center is empty. Clicking opens the session switcher quick pick. - -When the primary side bar is hidden and at least one session is **blocked** the widget instead switches to a **requires-input** state (see [Blocked Sessions](#blocked-sessions-center) below). - -After the user approves a pending action on a session from the sessions list (e.g. the **Allow** button on an approval row), the widget briefly shows a green "Approved N sessions" confirmation. Each approval within the rolling 3s window increments the count and restarts the countdown; while visible it takes precedence over the requires-input state. Driven by `ISessionActionFeedbackService` (`contrib/sessions`), whose `approvedCount` observable the widget reads. +Drafts use **New session** (or **New chat** for a quick chat) until they receive a title. When no session is active, the center is empty. The identity is not interactive; session-level commands remain in the in-content session header alongside the metadata pills. The title remains the active-session identity when the session needs input or an approval completes; those states do not replace it. Back and forward arrows appear beside the identity only while the primary sidebar is hidden, because the visible sessions list already provides direct navigation. In the single-pane layout, activating the session header **Changes** pill is treated as an explicit editor open: it reveals the docked editor area and opens the Changes multi-diff editor even though @@ -174,11 +168,9 @@ projection and the Files view. When multiple remote agent hosts are known, a dropdown pill in the left toolbar scopes the workbench to a specific host. When no hosts are known the pill acts as a re-discover trigger. -### Blocked Sessions (Center) - -When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to a light orange "N sessions require input" state (orange label with a subtle background and border), and blinks gently twice whenever a newly blocked occurrence appears. A session counts as blocked when it needs input, or - while not in progress - has failing CI checks. Pull request comments do not make a session blocked. Raw detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`), which reuses the shared, background-polled GitHub CI models and identifies CI occurrences by commit. The widget refines this into what the title bar surfaces via the `BlockedSessionsIndicatorModel` (`blockedSessionsIndicatorModel.ts`) it instantiates: it acknowledges the current occurrence when the user views the session or explicitly ignores it, applies optimistic approval dismissals, classifies the homogeneous requires-input reason (for the specific message), builds the pill label, and decides when the attention blink plays. Acknowledgement lasts only for that input request or CI failure; a later approval, a new failing commit, or an unblock-to-block transition surfaces the session again. Clicking the widget opens those sessions rendered exactly like the sessions list but flat - no sections, groups or workspace headers - via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. Its header toolbar offers **Show All Sessions**, **Ignore All Input Needed**, and a trailing **Close** action whose hover shows the `Escape` keybinding. Its rows use `Menus.BlockedSessionsItem` instead of the main session-item toolbar menu and contribute **Ignore Input Needed** / **Ignore CI Failure** actions with the same bell-slash icon. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven by the `BlockedSessionsIndicatorModel`'s `blockedSessions` observable. +### Blocked Sessions -Approval acknowledgement must use the pending tool call's stable id, not the approval model's load-time timestamp. Opening the new-session view can dispose and later reload the chat model; a timestamp-based id would make the same approval appear blocked again after that reload. +Blocked and approval state is surfaced by the sessions list and the active session's prompt-area status controls. It does not replace the active-session identity in the titlebar. ### Account Widget (Right) @@ -220,7 +212,7 @@ The header is centered and capped to 990px via its own CSS class (`.chat-composi **Pitfall:** combined codicon glyphs (e.g. `git-pull-request-done`) have a wider horizontal advance (~16px) than `*-compact` glyphs (e.g. `worktree-compact`, 12px), so even at `font-size: 12px` their layout box stays wide and pushes the following label away. Setting `font-size` alone does not fix it — clamp the icon box with explicit `width`/`height` set to `--vscode-codiconFontSize-compact` plus `justify-content: center` so the extra advance overflows harmlessly and the label sits tight against the glyph. -**Pitfall:** don't put `overflow: hidden` on the meta row. The meta buttons are secondary `Button`s whose focus ring is drawn with `outline-offset: 2px`, so it extends a few pixels outside the button. When the meta row's height equals the button height (22px) and the row clips its overflow, the ring is sheared flat at the top and bottom. Leave the row `overflow: visible` and rely on the header's `padding-bottom` and the title-row gap above to give the ring room. +**Pitfall:** don't put `overflow: hidden` on the metadata row. The metadata buttons are secondary `Button`s whose focus ring is drawn outside the button. When the row clips its overflow, the ring is sheared flat at the top and bottom. Leave the row `overflow: visible` and rely on the header padding to give the ring room. #### 4.1.1 Chat groups grid diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 655338e9ba042..dc55906f9e375 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -12,7 +12,7 @@ overflow: hidden; } -/* Header host: title row + meta row, with the top padding for the whole bar area */ +/* Header host: metadata and session actions, with the top padding for the whole bar area */ .chat-composite-bar.session-header-bar { padding: 6px 10px 0; box-sizing: border-box; @@ -34,36 +34,17 @@ --chat-tab-max-width: min(200px, 40cqi); } -/* Header: a status icon column next to a main column (title row + meta row). - Mirrors the sessions list so the meta row aligns under the title. */ +/* Header: status, metadata pills, and session actions share one row. */ .chat-composite-bar-header { display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; gap: 6px; padding-bottom: 6px; border-bottom: 1px solid color-mix(in srgb, var(--session-view-foreground) 12%, transparent); } -/* Main column stacks the title row and the meta row */ -.chat-composite-bar-header-main { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1 1 auto; - min-width: 0; -} - -/* Title row: title + actions */ -.chat-composite-bar-title-row { - display: flex; - align-items: center; - gap: 6px; - height: 26px; -} - -/* Status icon column — sits beside the main column, centered on the title line. - The compound selector (with .session-header-bar) raises specificity above the +/* The compound selector (with .session-header-bar) raises specificity above the base `.codicon { display: inline-block }` rule so the flex centering wins. */ .session-header-bar .chat-composite-bar-session-icon { display: flex; @@ -75,75 +56,6 @@ color: var(--session-view-foreground); } -.chat-composite-bar-session-title { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - display: flex; - align-items: center; - font-weight: var(--vscode-agents-fontWeight-semiBold, 600); - font-size: var(--vscode-agents-fontSize-heading3, 13px); - color: var(--chat-tab-active-foreground, var(--session-view-foreground)); - border-radius: var(--vscode-cornerRadius-small); - min-height: 22px; - /* Match the input's inner padding so the rendered text and the rename - input's text occupy the same horizontal position. */ - padding: 0 4px; - margin: 0 -4px; -} - -.chat-composite-bar-session-title-text { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Hover feedback: only when the title can actually be renamed and we aren't - currently editing it. */ -.chat-composite-bar-session-title.editable { - cursor: text; -} - -.chat-composite-bar-session-title.editable:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.chat-composite-bar-session-title.editing { - flex: 1 1 auto; - cursor: default; - background-color: transparent; - /* Remove the read-mode padding; the input has its own border + padding that - together match the read-mode padding so the text doesn't shift. */ - padding: 0; -} - -/* Inline rename input. Sized and styled to closely match the rendered title - so swapping in/out doesn't shift the row's layout. */ -.chat-composite-bar-session-title-input { - flex: 1 1 auto; - min-width: 0; - width: 100%; - box-sizing: border-box; - font-family: inherit; - font-weight: inherit; - font-size: inherit; - line-height: inherit; - color: var(--vscode-input-foreground, inherit); - background-color: var(--vscode-input-background, transparent); - border: 1px solid var(--vscode-focusBorder, var(--vscode-input-border, transparent)); - border-radius: var(--vscode-cornerRadius-small); - padding: 0 4px; - margin: 0; - outline: none; - min-height: 22px; -} - -.chat-composite-bar-session-title-input::selection { - background-color: var(--vscode-selection-background, var(--vscode-editor-selectionBackground)); -} - .chat-composite-bar-title-actions { display: flex; align-items: center; @@ -152,14 +64,16 @@ margin-left: auto; } -/* Meta row: workspace + contributed changes / pull request buttons. +/* Metadata: workspace + contributed changes / pull request buttons. No `overflow: hidden` here — it would clip the meta buttons' focus ring at the - row's 22px height. The workspace label self-truncates via its own containers. */ + row height. The workspace label self-truncates via its own containers. */ .chat-composite-bar-meta-row { display: flex; align-items: center; gap: 6px; - height: 22px; + height: 26px; + flex: 1 1 auto; + min-width: 0; font-size: var(--vscode-agents-fontSize-label1, 12px); font-weight: var(--vscode-agents-fontWeight-regular, 400); line-height: 18px; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index aaae2c4878187..5438f3967489b 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -6,16 +6,12 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; -import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; -import { KeyCode } from '../../../base/common/keyCodes.js'; import { autorun, IObservable, IReader, observableSignalFromEvent } from '../../../base/common/observable.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { localize } from '../../../nls.js'; -import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; -import { getUntitledSessionTitle } from '../../services/sessions/common/session.js'; import { ActionRunner, IAction } from '../../../base/common/actions.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; @@ -27,7 +23,6 @@ import { DraggedSessionIdentifier, SessionsDataTransfers } from '../dnd.js'; import { applyDragImage } from '../../../base/browser/ui/dnd/dnd.js'; import { applySessionBarThemeColors } from './sessionBarStyles.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { onUnexpectedError } from '../../../base/common/errors.js'; import { SessionStatusIcon } from '../sessionStatusIcon.js'; import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; @@ -57,9 +52,8 @@ class SessionActivatingActionRunner extends ActionRunner { /** * The session header shown at the top of a session view. It surfaces the session - * identity (status icon + title), a meta row (contributed workspace folder / - * changes / pull request pills), and the session toolbars (e.g. Run, Open in - * VS Code, New Chat). + * status, metadata (workspace folder / changes / pull request pills), and the + * session toolbar (e.g. Run, Open in VS Code, New Chat). * * It is intentionally decoupled from the {@link ChatCompositeBar} (the chat tab * strip) so the two surfaces evolve independently. The hosting view tells the @@ -69,16 +63,12 @@ export class SessionHeader extends Disposable { private readonly _container: HTMLElement; private readonly _iconEl: HTMLElement; - private readonly _titleEl: HTMLElement; - private readonly _titleTextEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; private readonly _titleActionsEl: HTMLElement; private readonly _sessionDisposables = this._register(new MutableDisposable()); - private readonly _editingDisposables = this._register(new MutableDisposable()); - private _renameInput: HTMLInputElement | undefined; private _session: IActiveSession | undefined; // dragstart's own target is always the draggable container, so this tracks the @@ -116,17 +106,14 @@ export class SessionHeader extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, ) { super(); this._container = $('.chat-composite-bar.session-header-bar'); - // Header: a status icon column alongside a main column that stacks the title - // row (title + actions) and the meta row (workspace · diff). This mirrors the - // sessions list so the meta row aligns under the title rather than under the - // status icon. + // Header: status, metadata pills, and session actions share one row. Session + // identity lives in the window titlebar. const header = $('.chat-composite-bar-header'); this._container.appendChild(header); @@ -134,29 +121,11 @@ export class SessionHeader extends Disposable { header.appendChild(this._iconEl); this._statusIcon = this._register(instantiationService.createInstance(SessionStatusIcon, this._iconEl)); - const main = $('.chat-composite-bar-header-main'); - header.appendChild(main); - - const titleRow = $('.chat-composite-bar-title-row'); - main.appendChild(titleRow); - - this._titleEl = $('.chat-composite-bar-session-title'); - titleRow.appendChild(this._titleEl); - - // Wrap the title text in a span so we can swap it for an input when - // the user clicks to rename without rebuilding the title slot itself. - this._titleTextEl = $('span.chat-composite-bar-session-title-text'); - this._titleEl.appendChild(this._titleTextEl); - - // Click the title to start an inline rename. Click is preferred over - // mousedown so that initiating a drag from the title doesn't also - // flip into edit mode. - this._register(addDisposableListener(this._titleEl, EventType.CLICK, () => { - this.startTitleEditing(); - })); + this._metaRow = $('.chat-composite-bar-meta-row'); + header.appendChild(this._metaRow); const titleActions = $('.chat-composite-bar-title-actions'); - titleRow.appendChild(titleActions); + header.appendChild(titleActions); this._titleActionsEl = titleActions; const toolbarContainer = $('.chat-composite-bar-toolbar'); @@ -170,9 +139,6 @@ export class SessionHeader extends Disposable { toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, })); - this._metaRow = $('.chat-composite-bar-meta-row'); - main.appendChild(this._metaRow); - // Session header meta toolbar. Actions are contributed into the generic // Menus.SessionHeaderMeta menu: the files view contributes the workspace // folder pill (opens the Files view), the changes view contributes the @@ -262,12 +228,6 @@ export class SessionHeader extends Disposable { return; } - // Don't initiate a drag while the title is being renamed. - if (this._renameInput) { - e.preventDefault(); - return; - } - this._sessionTransfer.setData( [new DraggedSessionIdentifier(session.sessionId, session.resource)], DraggedSessionIdentifier.prototype, @@ -292,8 +252,6 @@ export class SessionHeader extends Disposable { if (this._session === session) { return; } - // Cancel any in-flight rename when switching sessions. - this._cancelTitleEditing(); this._session = session; this._toolbar.context = session; this._metaToolbar.context = session; @@ -319,19 +277,14 @@ export class SessionHeader extends Disposable { private _updateHeader(session: IActiveSession, reader: IReader): void { // Session icon — the SessionStatusIcon widget owns the rendering (spinner vs. // codicon, cross-fade, reduced-motion); here we just feed it the latest state. - // The pull request is surfaced in the meta row, so in terminal/default states the - // title shows the read/unread dot indicator (no session type or PR icon). + // The pull request is surfaced in the metadata row, so terminal/default states + // use the read/unread dot indicator (no session type or PR icon). const status = session.status.read(reader); const isRead = session.isRead.read(reader); const isArchived = session.isArchived.read(reader); this._statusIcon.setStatus(status, isRead, isArchived); - // Session title — quick chats use "New Chat" as the untitled fallback. - const isQuickChat = session.isQuickChat?.read(reader) ?? false; - this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); - this._titleEl.classList.toggle('editable', this._isTitleEditable()); - - // Meta row: contributed action pills (workspace folder · diff stats · pull request). + // Metadata: contributed action pills (workspace folder · diff stats · pull request). // Reading the signal re-runs this on menu changes. this._metaActionsSignal.read(reader); const hasMetaActions = !this._metaToolbar.isEmpty(); @@ -353,116 +306,6 @@ export class SessionHeader extends Disposable { applySessionBarThemeColors(this._container, this._themeService.getColorTheme()); } - /** - * The title is editable when the backing provider declares it supports - * renaming the session (`capabilities.supportsRename`). This is the same - * signal that gates the `Rename...` context menu action in the sessions list. - */ - private _isTitleEditable(): boolean { - return !!this._session && (this._session.capabilities.get().supportsRename ?? false); - } - - startTitleEditing(): void { - if (!this._isTitleEditable() || this._renameInput) { - return; - } - this._startTitleEditing(); - } - - /** - * Replace the rendered title text with an `` containing the current - * title (pre-selected). Enter commits via {@link ISessionsManagementService.renameChat}, - * Escape or blur cancels. - */ - private _startTitleEditing(): void { - const session = this._session; - if (!session || this._renameInput) { - return; - } - - const initialTitle = session.title.get(); - // When the stored title is empty the header shows a localized fallback. - // Reflect that as a placeholder rather than seeding the input with it, so - // the user neither sees a blank field nor accidentally commits the fallback. - const fallbackTitle = getUntitledSessionTitle(session.isQuickChat?.get() ?? false); - - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'chat-composite-bar-session-title-input'; - input.value = initialTitle; - input.placeholder = fallbackTitle; - input.setAttribute('aria-label', localize('renameSession.aria', "Rename session")); - input.spellcheck = false; - - this._titleTextEl.style.display = 'none'; - this._titleEl.appendChild(input); - this._titleEl.classList.add('editing'); - this._renameInput = input; - - input.focus(); - input.select(); - - const store = new DisposableStore(); - this._editingDisposables.value = store; - - let finished = false; - const finish = (commit: boolean) => { - if (finished) { - return; - } - finished = true; - const newTitle = input.value.trim(); - this._endTitleEditing(); - if (commit && newTitle && newTitle !== initialTitle) { - this._sessionsManagementService - .renameSession(session, newTitle) - .catch(onUnexpectedError); - } - }; - - store.add(addStandardDisposableListener(input, EventType.KEY_DOWN, (e: IKeyboardEvent) => { - if (e.equals(KeyCode.Enter)) { - e.preventDefault(); - e.stopPropagation(); - finish(true); - } else if (e.equals(KeyCode.Escape)) { - e.preventDefault(); - e.stopPropagation(); - finish(false); - } else { - // Don't let typing leak out to workbench shortcuts (e.g. Space). - e.stopPropagation(); - } - })); - - store.add(addDisposableListener(input, EventType.BLUR, () => { - finish(false); - })); - - // Swallow click/pointerdown on the input so the title's click handler - // doesn't try to re-enter editing mode. Use the generic mousedown - // helper which routes through `pointerdown` on iOS where mouse events - // don't fire. - store.add(addDisposableGenericMouseDownListener(input, e => e.stopPropagation())); - store.add(addDisposableListener(input, EventType.CLICK, e => e.stopPropagation())); - } - - private _cancelTitleEditing(): void { - if (!this._renameInput) { - return; - } - this._endTitleEditing(); - } - - private _endTitleEditing(): void { - if (this._renameInput) { - this._renameInput.remove(); - this._renameInput = undefined; - } - this._titleTextEl.style.display = ''; - this._titleEl.classList.remove('editing'); - this._editingDisposables.clear(); - } } /** diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 38a35ed78a374..3ada3b755f16c 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -240,10 +240,6 @@ export class SessionView extends Disposable implements ISerializableView { standaloneView ? standaloneView.focus() : this._groupsView.focus(); } - startTitleEditing(): void { - this._header.startTitleEditing(); - } - selectWorkspace(folderUri: URI, providerId?: string): void { const standaloneView = this._standaloneView.value; standaloneView ? standaloneView.selectWorkspace(folderUri, providerId) : this._groupsView.selectWorkspace(folderUri, providerId); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 557d711fc050a..a1b3dd781ee2c 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Container - button style hover */ .agent-sessions-workbench .command-center .monaco-action-bar .actions-container { display: flex; align-items: center; @@ -21,47 +20,29 @@ align-items: center; justify-content: flex-start; min-width: 0; - width: 31vw; + width: auto; max-width: 600px; - height: 22px; - border: 1px solid var(--vscode-commandCenter-border); + height: 24px; background-color: transparent; - border-radius: var(--vscode-cornerRadius-medium); + border-radius: var(--vscode-cornerRadius-small); -webkit-app-region: no-drag; overflow: hidden; - color: var(--vscode-commandCenter-foreground); + color: inherit; font-size: var(--vscode-agents-fontSize-label1); gap: 6px; cursor: default; - opacity: 1; - transition: opacity 120ms ease-out; -} - -.command-center .agent-sessions-titlebar-container:hover { - color: var(--vscode-commandCenter-activeForeground); - background-color: var(--vscode-commandCenter-activeBackground); - border-color: var(--vscode-commandCenter-activeBorder); } -/* Session pill - clickable area for session picker, fills the command center box */ +/* Session identity fills the titlebar slot without introducing an interaction. */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill { display: flex; justify-content: space-around; align-items: center; flex: 1 1 auto; - padding: 0 8px; + padding: 0 6px; min-width: 0; max-width: 100%; height: 100%; - cursor: pointer; - touch-action: manipulation; - /* Match the VS Code window command center title which is dimmed. */ - opacity: 0.6; -} - -.command-center .agent-sessions-titlebar-container:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; } /* Center group: icon + label + folder */ @@ -71,7 +52,6 @@ gap: 6px; min-width: 0; justify-content: flex-start; - cursor: pointer; overflow: hidden; } /* Codicons use the base 16px token rather than an unsupported intermediate size. */ @@ -82,7 +62,7 @@ font-size: var(--vscode-codiconFontSize); } -/* Session title - primary label in the command center box. */ +/* Session title - primary label in the command center. */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-title { flex: 0 1 auto; min-width: 0; @@ -135,38 +115,3 @@ color: var(--vscode-agentsUnreadBadge-foreground); pointer-events: none; } - -@media (prefers-reduced-motion: reduce) { - .command-center .agent-sessions-titlebar-container { - transition: none; - } -} - -/* Approved state: a transient green confirmation shown after the user approves - one or more sessions' pending actions from the sessions list. It stays - clickable (activating whatever the underlying state would do). */ -.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved { - color: var(--vscode-charts-green); - border-color: var(--vscode-charts-green); - background-color: color-mix(in srgb, var(--vscode-charts-green) 14%, transparent); -} - -.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved:hover { - color: var(--vscode-charts-green); - border-color: var(--vscode-charts-green); - background-color: color-mix(in srgb, var(--vscode-charts-green) 22%, transparent); -} - -.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved .agent-sessions-titlebar-pill { - opacity: 1; - justify-content: center; -} - -.command-center .agent-sessions-titlebar-approved-label { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: var(--vscode-agents-fontWeight-semiBold); -} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index adb943ce088ca..3030241971501 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -17,18 +17,19 @@ import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../pla import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; -import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { CLOSE_CHAT_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { CLOSE_CHAT_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; @@ -268,7 +269,7 @@ registerAction2(class GoBackAction extends Action2 { id: Menus.TitleBarCenterLeft, group: 'navigation', order: 1, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), SideBarVisibleContext.toNegated()), }, { id: Menus.GoMenu, group: '1_history_nav', @@ -310,7 +311,7 @@ registerAction2(class GoForwardAction extends Action2 { id: Menus.TitleBarCenterLeft, group: 'navigation', order: 2, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), SideBarVisibleContext.toNegated()), }, { id: Menus.GoMenu, group: '1_history_nav', @@ -1502,11 +1503,11 @@ registerAction2(class RenameSessionHeaderAction extends Action2 { }); } - override run(accessor: ServicesAccessor, session: IActiveSession | undefined): void { + override run(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise | undefined { if (!session) { return; } - accessor.get(ISessionsPartService).getSessionView(session.sessionId)?.startTitleEditing(); + return accessor.get(ICommandService).executeCommand(RENAME_SESSION_COMMAND_ID, session); } }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index af7da5966f8b9..8bc214cf4bee6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,62 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, getDomNodePagePosition, getWindow, isAncestor, reset } from '../../../../base/browser/dom.js'; -import { combinedDisposable, Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { $, reset } from '../../../../base/browser/dom.js'; +import { combinedDisposable, Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { localize } from '../../../../nls.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { autorun } from '../../../../base/common/observable.js'; -import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { URI } from '../../../../base/common/uri.js'; -import { AnchorAlignment, AnchorPosition, IAnchor } from '../../../../base/common/layout.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IContextViewService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { SessionsBlockedSessionsVisibleContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; -import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; -import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; -import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; -import { SessionActionFeedback } from './sessionActionFeedback.js'; -import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; -import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; -import { openSessionToTheSide } from './views/sessionsView.js'; +import { IBlockedSessionsHeaderActionContext } from './blockedSessionsList.js'; -/** - * Internal command behind the blocked-sessions dropdown header's "Show All - * Sessions" action: it dismisses the dropdown (a transient context view) before - * opening the full sessions picker so the popup doesn't linger behind it. - */ const SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID = 'sessions.blockedSessions.showAllSessions'; - -/** Internal command behind the blocked-sessions dropdown header's bulk-ignore action. */ const IGNORE_ALL_INPUT_NEEDED_COMMAND_ID = 'sessions.blockedSessions.ignoreAllInputNeeded'; - -/** - * Internal command that dismisses the blocked-sessions dropdown. Bound to Escape - * (scoped to {@link SessionsBlockedSessionsVisibleContext}) so the dropdown can - * be closed from anywhere in the sessions window while it is open, not only when - * focus happens to be inside it. - */ const HIDE_BLOCKED_SESSIONS_COMMAND_ID = 'sessions.blockedSessions.hide'; -/** Register the actions shown in the blocked-sessions dropdown header toolbar. */ export function registerBlockedSessionsHeaderActions(): IDisposable { return combinedDisposable( MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { @@ -92,165 +61,36 @@ export function registerBlockedSessionsHeaderActions(): IDisposable { ); } -/** Register the commands invoked by the blocked-sessions header toolbar. */ export function registerBlockedSessionsHeaderCommands(): IDisposable { return combinedDisposable( - CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => { - context.showAllSessions(); - }), - CommandsRegistry.registerCommand(IGNORE_ALL_INPUT_NEEDED_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => { - context.ignoreAllSessions(); - }), + CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => context.showAllSessions()), + CommandsRegistry.registerCommand(IGNORE_ALL_INPUT_NEEDED_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => context.ignoreAllSessions()), + CommandsRegistry.registerCommand(HIDE_BLOCKED_SESSIONS_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => context.close()), ); } /** - * The currently-open blocked-sessions dropdown, shared with the Escape command so - * it closes this specific context view. - */ -let openBlockedSessionsView: IOpenContextView | undefined; - -/** - * Minimum width of the blocked-sessions dropdown, in pixels. The dropdown is at - * least as wide as the command center box it hangs off, but never narrower than - * this so its rows have room to breathe. - */ -const BLOCKED_DROPDOWN_MIN_WIDTH = 550; - -/** - * Maximum width of the blocked-sessions dropdown as a fraction of the window - * width, so it never spans (nearly) the entire window on narrow layouts. - */ -const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; - -/** - * Sessions Title Bar Widget - renders the active chat session - * in the command center of the agent sessions workbench. - * - * Shows the current chat session as a clickable pill with: - * - Kind icon at the beginning (provider type icon) - * - Repository folder name and active branch/worktree name when available - * - * When at least one session is blocked (needs input or has failing CI checks), - * the widget instead adopts an orange "N sessions require input" state and reveals those sessions as a - * flat list in a dropdown anchored below the command center box. A short blink - * animation plays whenever a new session becomes blocked. In every other case it - * behaves as the active-session pill and opens the sessions picker on click. - * - * The requires-input logic (which blocked sessions to surface, the homogeneous - * reason, labels and when to blink) is owned by {@link BlockedSessionsIndicatorModel}; - * this widget only renders it. - * - * Session actions (changes, terminal, etc.) are rendered via the - * SessionTitleActions menu toolbar next to this widget. + * Renders the active session identity in the Agents window title bar. */ export class SessionsTitleBarWidget extends BaseActionViewItem { private _container: HTMLElement | undefined; - private readonly _dynamicDisposables = this._register(new DisposableStore()); - - /** Owns the blink animation's `animationend` listener, kept across re-renders. */ - private readonly _blinkListener = this._register(new MutableDisposable()); - - /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; - /** Guard to prevent re-entrant rendering */ - private _isRendering = false; - - /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ - private readonly _blockedIndicator: BlockedSessionsIndicatorModel; - - /** The currently open blocked-sessions dropdown, if any. */ - private _openContextView: IOpenContextView | undefined; - /** The blocked-sessions list rendered inside the open dropdown, if any. */ - private _blockedList: BlockedSessionsList | undefined; - - /** Tracks whether the blocked-sessions dropdown is open (drives the Escape keybinding). */ - private readonly _blockedSessionsVisibleContext: IContextKey; - - /** Drives the transient "Approved N sessions" confirmation. Owned by the widget. */ - private readonly _sessionActionFeedback: SessionActionFeedback; - constructor( action: SubmenuItemAction, options: IBaseActionViewItemOptions | undefined, - sessionActionFeedback: SessionActionFeedback | undefined, - approvalModel: AgentSessionApprovalModel | undefined, - blockedSessions: BlockedSessions | undefined, - ciFixModel: BlockedSessionsCIFixModel | undefined, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, - @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, - @ICommandService private readonly commandService: ICommandService, - @IContextViewService private readonly contextViewService: IContextViewService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(undefined, action, options); - - this._blockedSessionsVisibleContext = SessionsBlockedSessionsVisibleContext.bindTo(contextKeyService); - - // The widget owns the approval-feedback state; the optional parameter is a - // test seam so fixtures can supply a preset instance. - this._sessionActionFeedback = sessionActionFeedback ?? this._register(new SessionActionFeedback()); - - // The blocked-session indicator model owns the requires-input logic (the - // visible-filtered blocked set, the requires-input kind, optimistic approval - // dismissals, labels and blink detection). The optional `approvalModel`, - // `blockedSessions` and `ciFixModel` are test seams forwarded to it so - // fixtures can preset them. - this._blockedIndicator = this._register(this.instantiationService.createInstance(BlockedSessionsIndicatorModel, approvalModel, blockedSessions, ciFixModel)); - - // Replay the attention blink when the model reports a genuinely new, not-yet- - // visible block. Invalidate the cached render state so the identical pill is - // rebuilt with the blink class (see `_render`). - this._register(this._blockedIndicator.onDidRequestBlink(() => { - this._lastRenderState = undefined; - this._render(); - })); - - // Re-render when the active session's title, workspace, or quick-chat kind changes - this._register(autorun(reader => { - const sessionData = this.sessionsService.activeSession.read(reader); - if (sessionData) { - sessionData.title.read(reader); - sessionData.workspace.read(reader); - sessionData.isQuickChat?.read(reader); - } - this._lastRenderState = undefined; - this._render(); - })); - - // Re-render when the set of blocked sessions changes; it feeds the - // "N sessions require input" state. Keep an open dropdown in sync. this._register(autorun(reader => { - const blocked = this._blockedIndicator.blockedSessions.read(reader); - this._sessionActionFeedback.approvedCount.read(reader); - this._blockedIndicator.requiresInputKind.read(reader); - if (this._openContextView && this._blockedList) { - this._blockedList.setSessions(blocked.map(entry => entry.session)); - this.contextViewService.layout(); - } + const activeSession = this.sessionsService.activeSession.read(reader); + activeSession?.title.read(reader); + activeSession?.workspace.read(reader); + activeSession?.isQuickChat?.read(reader); + activeSession?.isCreated.read(reader); this._render(); })); - - // Re-render when sessions data changes (e.g., changes info updated) - this._register(this.sessionsManagementService.onDidChangeSessions(() => { - this._lastRenderState = undefined; - this._render(); - })); - - // Re-render when providers change (affects provider picker visibility) - this._register(this.sessionsProvidersService.onDidChangeProviders(() => { - this._lastRenderState = undefined; - this._render(); - })); - - // Ensure any open dropdown is closed when the widget is disposed. - this._register(toDisposable(() => this._openContextView?.close())); } override render(container: HTMLElement): void { @@ -259,118 +99,44 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._container = container; container.classList.add('agent-sessions-titlebar-container'); - // Initial render this._render(); } override setFocusable(_focusable: boolean): void { - // Don't set focusable on the container + this._container?.setAttribute('tabindex', '-1'); } - // Override onClick to prevent the base class from running the underlying - // submenu action when the widget handles clicks itself. - override onClick(): void { - // No-op: click handling is done by the pill handler - } + override onClick(): void { } private _render(): void { if (!this._container) { return; } - if (this._isRendering) { + const icon = this._getActiveSessionIcon(); + const sessionTitle = this._getSessionTitle(); + const workspaceLabel = this._getRepositoryLabel(); + const renderState = `${icon?.id ?? ''}|${sessionTitle}|${workspaceLabel ?? ''}`; + if (this._lastRenderState === renderState) { return; } - this._isRendering = true; - - try { - const approvedCount = this._sessionActionFeedback.approvedCount.get(); - const blockedCount = this._blockedIndicator.blockedSessions.get().length; - const requiresInput = blockedCount > 0; - - // The transient "Approved N sessions" confirmation takes precedence over the - // requires-input state while it is showing. - const showApproved = approvedCount > 0; - const showRequiresInput = requiresInput && !showApproved; - - // The attention blink fires only when the indicator model reports a - // *genuinely new* blocked session while the requires-input state is shown — - // including the very first one. `consumePendingBlink` is short-circuited so - // the pending blink is only consumed when it actually plays; navigating - // between sessions (which changes the visible set, not the model) never blinks. - const shouldBlink = showRequiresInput && this._blockedIndicator.consumePendingBlink(); - - const requiresInputKind = this._blockedIndicator.requiresInputKind.get(); - - let renderState: string; - if (showApproved) { - renderState = `approved|${approvedCount}`; - } else if (showRequiresInput) { - renderState = `blocked|${blockedCount}|${requiresInputKind ?? 'mixed'}`; - } else { - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); - renderState = `normal|${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; - } - - // Skip re-render if state hasn't changed - if (this._lastRenderState === renderState) { - return; - } - this._lastRenderState = renderState; - - // Close the open blocked-sessions dropdown only when there are no blocked - // sessions left to show. Note this keys off `requiresInput`, not - // `showRequiresInput`: approving a session shows the transient green state - // (suppressing `showRequiresInput`) but the dropdown must stay open while - // other sessions remain blocked — it just drops the approved row. - if (!requiresInput && this._openContextView) { - this._openContextView.close(); - } + this._lastRenderState = renderState; - // Clear existing content - reset(this._container); - this._dynamicDisposables.clear(); - - // Set up container as the button directly - this._container.removeAttribute('aria-hidden'); - this._container.setAttribute('role', 'button'); - this._container.tabIndex = 0; - // Preserve an in-progress blink when re-rendering the SAME requires-input - // pill without a new blink. Other autoruns (e.g. onDidChangeSessions) - // invalidate the cached render state and force a redundant rebuild of the - // identical pill; without this guard that rebuild would strip the freshly- - // added blink class and cut the animation short — which is why the first - // "1 session requires input" never appeared to animate. - if (!(showRequiresInput && !shouldBlink)) { - this._container.classList.remove('agent-sessions-titlebar-blink'); - } - this._container.classList.toggle('agent-sessions-titlebar-requires-input', showRequiresInput); - this._container.classList.toggle('agent-sessions-titlebar-approved', showApproved); - - if (showApproved) { - this._renderApproved(approvedCount); - } else if (showRequiresInput) { - this._renderRequiresInput(blockedCount, requiresInputKind, shouldBlink); - } else { - this._renderActiveSession(); - } - } finally { - this._isRendering = false; - } + reset(this._container); + this._container.removeAttribute('role'); + this._container.removeAttribute('aria-label'); + this._container.tabIndex = -1; + this._renderActiveSession(); } /** - * Render the active-session pill: icon + title + workspace. Clicking opens the - * sessions picker. + * Render the active-session identity: icon + title + workspace. */ private _renderActiveSession(): void { const container = this._container!; - container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); + const sessionTitle = this._getSessionTitle(); const workspaceLabel = this._getRepositoryLabel(); // Session pill: icon + title + workspace together @@ -404,285 +170,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { sessionPill.appendChild(centerGroup); - // Click handler on pill - this._dynamicDisposables.add(addDisposableGenericMouseDownListener(sessionPill, (e) => { - e.preventDefault(); - e.stopPropagation(); - })); - this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this._showSessionsPicker(); - })); - container.appendChild(sessionPill); - - // Keyboard handler - this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this._showSessionsPicker(); - } - })); - } - - /** - * Render the requires-input pill. Clicking toggles a dropdown that lists the - * blocked sessions below the command center box. - */ - private _renderRequiresInput(count: number, kind: RequiresInputKind | undefined, shouldBlink: boolean): void { - const container = this._container!; - const label = this._blockedIndicator.getRequiresInputLabel(count, kind); - container.setAttribute('aria-label', label); - - const pill = $('div.agent-sessions-titlebar-pill'); - const labelEl = $('div.agent-sessions-titlebar-requires-input-label'); - labelEl.textContent = label; - pill.appendChild(labelEl); - - this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { - e.preventDefault(); - e.stopPropagation(); - })); - this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this._toggleBlockedSessions(); - })); - - container.appendChild(pill); - - this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this._toggleBlockedSessions(); - } - })); - - if (shouldBlink) { - this._triggerAttentionBlink(); - } - } - - /** - * Render the transient green "Approved N sessions" confirmation shown briefly - * after the user approves one or more sessions' pending actions from the list. - */ - private _renderApproved(count: number): void { - const container = this._container!; - const label = count === 1 - ? localize('oneSessionApproved', "Approved 1 session") - : localize('nSessionsApproved', "Approved {0} sessions", count); - container.setAttribute('aria-label', label); - - const pill = $('div.agent-sessions-titlebar-pill'); - const labelEl = $('div.agent-sessions-titlebar-approved-label'); - labelEl.textContent = label; - pill.appendChild(labelEl); - - // The confirmation is transient but stays clickable: clicking does whatever - // the widget's underlying (non-approved) state would do. - this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { - e.preventDefault(); - e.stopPropagation(); - })); - this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this._activateDefaultAction(); - })); - - container.appendChild(pill); - - this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this._activateDefaultAction(); - } - })); - } - - /** - * Activate the widget as its non-approved state would: reveal the blocked - * sessions when the requires-input state applies, otherwise the sessions picker. - */ - private _activateDefaultAction(): void { - const requiresInput = this._blockedIndicator.blockedSessions.get().length > 0; - if (requiresInput) { - this._toggleBlockedSessions(); - } else { - this._showSessionsPicker(); - } - } - - /** - * Restart the attention blink animation on the command center box. Re-adding - * the class after a forced reflow guarantees the CSS animation replays even - * when the container element persists across renders. - */ - private _triggerAttentionBlink(): void { - const container = this._container; - if (!container) { - return; - } - container.classList.remove('agent-sessions-titlebar-blink'); - container.getBoundingClientRect(); // force reflow so the animation restarts - container.classList.add('agent-sessions-titlebar-blink'); - // Own the listener outside `_dynamicDisposables` (cleared on every render) so a - // redundant re-render can't drop it before the animation finishes. - this._blinkListener.value = addDisposableListener(container, 'animationend', () => { - container.classList.remove('agent-sessions-titlebar-blink'); - this._blinkListener.clear(); - }); - } - - /** - * Toggle the blocked-sessions dropdown open/closed. - */ - private _toggleBlockedSessions(): void { - if (this._openContextView) { - this._openContextView.close(); - return; - } - this._showBlockedSessions(); - } - - /** - * Show the blocked sessions as a flat list in a dropdown anchored below the - * command center box. - */ - private _showBlockedSessions(): void { - const container = this._container; - if (!container) { - return; - } - if (this._blockedIndicator.blockedSessions.get().length === 0) { - return; - } - - // Match the dropdown width to the command center box it hangs off, but keep - // it within a sensible min/max so it stays readable on wide layouts and - // doesn't overflow on narrow ones. - const width = this._computeBlockedDropdownWidth(container); - - const store = new DisposableStore(); - this._openContextView = this.contextViewService.showContextView({ - getAnchor: () => this._getBlockedDropdownAnchor(container), - anchorAlignment: AnchorAlignment.LEFT, - anchorPosition: AnchorPosition.BELOW, - render: (viewContainer): IDisposable => { - const list = store.add(this.instantiationService.createInstance(BlockedSessionsList, viewContainer, { - width, - approvalModel: this._blockedIndicator.approvalModel, - ciFixModel: this._blockedIndicator.ciFixModel, - onSessionOpen: (resource, preserveFocus, sideBySide) => { - this._openContextView?.close(); - this._openBlockedSession(resource, preserveFocus, sideBySide); - }, - onIgnoreSession: session => this._blockedIndicator.ignoreSession(session), - onShowAllSessions: () => { - this._openContextView?.close(); - this._showSessionsPicker(); - }, - onIgnoreAllSessions: () => this._blockedIndicator.ignoreAllSessions(), - onClose: () => this._openContextView?.close(), - })); - list.setSessions(this._blockedIndicator.blockedSessions.get().map(entry => entry.session)); - store.add(list.onDidChangeContentHeight(() => this.contextViewService.layout())); - store.add(list.onDidApproveSession(approved => { - this._blockedIndicator.dismissApproval(approved); - this._sessionActionFeedback.notifyApproved(); - })); - - // Keep the dropdown width matched to the command center box as the - // window resizes (the command center reflows to a new width, and the - // min/max clamp tracks the new window width). - store.add(this.layoutService.onDidLayoutActiveContainer(() => { - list.setWidth(this._computeBlockedDropdownWidth(container)); - this.contextViewService.layout(); - })); - - // Dismiss the dropdown when a quick pick opens on top of it (e.g. the - // sessions picker), so it doesn't linger behind the quick input. Close - // our specific context view rather than whatever happens to be open. - store.add(this.quickInputService.onShow(() => this._openContextView?.close())); - - this._blockedList = list; - return store; - }, - focus: () => this._blockedList?.focus(), - onDOMEvent: (e: Event) => { - // Dismiss on a click outside the dropdown. Clicks on the anchor are - // ignored here because the anchor toggles the dropdown itself. Escape - // is handled by a dedicated high-weight keybinding (see - // HIDE_BLOCKED_SESSIONS_COMMAND_ID) so it dismisses the dropdown even - // when focus is outside of it. - if (e.type === EventType.CLICK) { - const target = e.target as HTMLElement | null; - if (target - && !isAncestor(target, this.contextViewService.getContextViewElement()) - && !isAncestor(target, container)) { - this._openContextView?.close(); - } - } - }, - onHide: () => { - this._blockedSessionsVisibleContext.set(false); - store.dispose(); - this._openContextView = undefined; - openBlockedSessionsView = undefined; - this._blockedList = undefined; - }, - }); - - openBlockedSessionsView = this._openContextView; - this._blockedSessionsVisibleContext.set(true); - } - - /** - * Compute the width of the blocked-sessions dropdown: at least as wide as the - * command center box (the anchor) and {@link BLOCKED_DROPDOWN_MIN_WIDTH}, but - * never wider than {@link BLOCKED_DROPDOWN_MAX_WIDTH_RATIO} of the window so it - * stays within the viewport on narrow layouts. - */ - private _computeBlockedDropdownWidth(container: HTMLElement): number { - const anchorWidth = getDomNodePagePosition(container).width; - const windowWidth = getWindow(container).innerWidth; - const minWidth = Math.max(anchorWidth, BLOCKED_DROPDOWN_MIN_WIDTH); - const maxWidth = windowWidth * BLOCKED_DROPDOWN_MAX_WIDTH_RATIO; - return Math.round(Math.min(minWidth, maxWidth)); - } - - /** - * Anchor the blocked-sessions dropdown so it is horizontally centered on the - * command center box. Because the dropdown can be wider than the box, we hand - * the context view a zero-width anchor positioned at the dropdown's target - * left edge (the box center minus half the dropdown width). - */ - private _getBlockedDropdownAnchor(container: HTMLElement): IAnchor { - const position = getDomNodePagePosition(container); - const width = this._computeBlockedDropdownWidth(container); - const centerX = position.left + position.width / 2; - return { - x: Math.round(centerX - width / 2), - y: position.top, - width: 0, - height: position.height, - }; - } - - private _openBlockedSession(resource: URI, preserveFocus: boolean, sideBySide: boolean): void { - if (sideBySide) { - const session = this.sessionsManagementService.getSession(resource); - if (session) { - openSessionToTheSide(this.sessionsService, session, { preserveFocus }).catch(onUnexpectedError); - return; - } - } - this.sessionsService.openSession(resource, { preserveFocus }).catch(onUnexpectedError); } /** @@ -701,7 +189,15 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { */ private _getSessionTitle(): string | undefined { const sessionData = this.sessionsService.activeSession.get(); - return sessionData?.title.get()?.trim() || undefined; + if (!sessionData) { + return undefined; + } + if (!sessionData.isCreated.get()) { + return sessionData.isQuickChat?.get() + ? localize('newChat', "New chat") + : localize('newSession', "New session"); + } + return sessionData.title.get()?.trim() || getUntitledSessionTitle(sessionData.isQuickChat?.get() ?? false); } /** @@ -718,9 +214,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } - private _showSessionsPicker(): void { - this.commandService.executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); - } } /** @@ -757,37 +250,11 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben when: IsAuxiliaryWindowContext.negate() })); - // The blocked-sessions dropdown header's "Show All Sessions" action dismisses - // the dropdown (a transient context view) before opening the full sessions - // picker, so the popup doesn't linger behind it. - this._register(registerBlockedSessionsHeaderCommands()); - this._register(registerBlockedSessionsHeaderActions()); - this._register(registerBlockedSessionsItemActions()); - this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } - return instantiationService.createInstance(SessionsTitleBarWidget, action, options, undefined, undefined, undefined, undefined); + return instantiationService.createInstance(SessionsTitleBarWidget, action, options); }, undefined)); } } - -// Escape closes the blocked-sessions dropdown while it is open. Registered as a -// high-weight keybinding scoped to `SessionsBlockedSessionsVisibleContext` (rather -// than relying on focus being inside the dropdown) so it reliably wins over other -// Escape handlers, mirroring how the quick pick scopes its dismiss keybinding to an -// "is visible" context key. -KeybindingsRegistry.registerCommandAndKeybindingRule({ - id: HIDE_BLOCKED_SESSIONS_COMMAND_ID, - weight: KeybindingWeight.SessionsContrib + 100, - when: SessionsBlockedSessionsVisibleContext, - primary: KeyCode.Escape, - handler: (_accessor, context?: IBlockedSessionsHeaderActionContext) => { - if (context) { - context.close(); - } else { - openBlockedSessionsView?.close(); - } - }, -}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts new file mode 100644 index 0000000000000..498f5ad9065cb --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { SessionsTitleBarWidget } from '../../browser/sessionsTitleBarWidget.js'; + +suite('SessionsTitleBarWidget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders passive session identity', () => { + const workspace = new class extends mock() { + override readonly label = 'vscode'; + }(); + const session = new class extends mock() { + override readonly icon = Codicon.copilot; + override readonly title = constObservable('Fix authentication redirect loop'); + override readonly workspace: IObservable = constObservable(workspace); + override readonly isQuickChat = constObservable(false); + override readonly isCreated = constObservable(true); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession: IObservable = constObservable(session); + }(); + const action = new class extends mock() { + override readonly id = 'workbench.agentSessions.titlebar'; + override readonly label = 'Agent Sessions'; + override readonly tooltip = ''; + override readonly enabled = true; + override async run(): Promise { } + }(); + const container = document.createElement('div'); + const widget = store.add(new SessionsTitleBarWidget(action, undefined, sessionsService)); + widget.render(container); + + assert.deepStrictEqual({ + title: container.querySelector('.agent-sessions-titlebar-title')?.textContent, + workspace: container.querySelector('.agent-sessions-titlebar-workspace')?.textContent, + role: container.getAttribute('role'), + ariaLabel: container.getAttribute('aria-label'), + tabIndex: container.tabIndex, + }, { + title: 'Fix authentication redirect loop', + workspace: 'vscode', + role: null, + ariaLabel: null, + tabIndex: -1, + }); + }); + + test('renders new session identity for a draft', () => { + const session = new class extends mock() { + override readonly icon = Codicon.copilot; + override readonly title = constObservable(''); + override readonly workspace: IObservable = constObservable(undefined); + override readonly isQuickChat = constObservable(false); + override readonly isCreated = constObservable(false); + }(); + const sessionsService = new class extends mock() { + override readonly activeSession: IObservable = constObservable(session); + }(); + const action = new class extends mock() { + override readonly id = 'workbench.agentSessions.titlebar'; + override readonly label = 'Agent Sessions'; + override readonly tooltip = ''; + override readonly enabled = true; + override async run(): Promise { } + }(); + const container = document.createElement('div'); + const widget = store.add(new SessionsTitleBarWidget(action, undefined, sessionsService)); + widget.render(container); + + assert.strictEqual(container.querySelector('.agent-sessions-titlebar-title')?.textContent, 'New session'); + }); +}); diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 228f55d7ebfa0..de7fe6cbf9af0 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -107,6 +107,23 @@ suite('Sessions - SessionHeader', () => { assert.strictEqual(dragEvent.defaultPrevented, true); }); + test('renders metadata and actions in one row without a duplicate session title', () => { + const { header } = createHarness(disposables); + const row = header.element.querySelector('.chat-composite-bar-header'); + + assert.deepStrictEqual({ + hasTitle: !!header.element.querySelector('.chat-composite-bar-session-title'), + children: Array.from(row?.children ?? []).map(child => child.className), + }, { + hasTitle: false, + children: [ + 'chat-composite-bar-session-icon', + 'chat-composite-bar-meta-row', + 'chat-composite-bar-title-actions', + ], + }); + }); + test('a drag starting elsewhere in the header still initiates a session drag', () => { const { header } = createHarness(disposables); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 0f518c37ebef6..98558cca0c71e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -5,39 +5,25 @@ import { $, append } from '../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; import { IObservable, constObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; -import { URI } from '../../../../../base/common/uri.js'; import { SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; // eslint-disable-next-line local/code-import-patterns -import { IChat, ISession, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; +import { ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns -import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; // eslint-disable-next-line local/code-import-patterns -import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; -// eslint-disable-next-line local/code-import-patterns -import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../../../../sessions/contrib/blockedSessions/browser/blockedSessions.js'; -// eslint-disable-next-line local/code-import-patterns -import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/browser/sessionActionFeedback.js'; -// eslint-disable-next-line local/code-import-patterns import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; -// eslint-disable-next-line local/code-import-patterns -import { BlockedSessionsCIFixModel, IBlockedSessionsCIFixModel } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsCIFixModel.js'; -import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // ============================================================================ // Mock helpers // ============================================================================ -function createMockActiveSession(title: string, workspaceLabel: string): IActiveSession { - const workspace = new class extends mock() { +function createMockActiveSession(title: string, workspaceLabel: string | undefined, isCreated = true): IActiveSession { + const workspace = workspaceLabel === undefined ? undefined : new class extends mock() { override readonly label = workspaceLabel; }(); return new class extends mock() { @@ -45,61 +31,12 @@ function createMockActiveSession(title: string, workspaceLabel: string): IActive override readonly title: IObservable = constObservable(title); override readonly workspace: IObservable = constObservable(workspace); override readonly isQuickChat: IObservable = constObservable(false); + override readonly isCreated: IObservable = constObservable(isCreated); }(); } -/** A blocked session to synthesize for a fixture. */ -interface IBlockedSpec { - /** Why the session is blocked. */ - readonly reason: BlockedSessionReason; - /** For `NeedsInput`, the kind of pending approval (terminal vs question). */ - readonly approvalKind?: AgentSessionApprovalKind; -} - -/** - * Build mock blocked sessions plus an approval model that reports the requested - * approval kind for each session's chat, so the widget classifies them exactly. - */ -function buildBlocked(specs: readonly IBlockedSpec[]): { blocked: IBlockedSession[]; approvalModel: AgentSessionApprovalModel } { - const approvals = new Map(); - const blocked = specs.map((spec, i): IBlockedSession => { - const chatResource = URI.parse(`session-chat:/blocked/${i}`); - if (spec.approvalKind) { - approvals.set(chatResource.toString(), { - approvalId: chatResource.toString(), - kind: spec.approvalKind, - label: 'npm run build', - languageId: undefined, - since: new Date(), - confirm: () => { }, - }); - } - const chat = new class extends mock() { - override readonly resource = chatResource; - }(); - const session = new class extends mock() { - override readonly sessionId = `blocked-${i}`; - override readonly chats: IObservable = constObservable([chat]); - }(); - return { session, reason: spec.reason, occurrenceId: `${spec.reason}:${i}` }; - }); - const approvalModel = new class extends mock() { - override getApproval(resource: URI): IObservable { - return constObservable(approvals.get(resource.toString())); - } - }(); - return { blocked, approvalModel }; -} - interface ITitleBarState { - /** The active session shown in the default pill (falls back to "New Session"). */ - activeSession?: IActiveSession; - /** Number of blocked sessions (drives the orange "N sessions require input"). */ - blockedCount?: number; - /** Explicit typed blocked sessions (drives the specific requires-input message). */ - blocked?: readonly IBlockedSpec[]; - /** Number of recently approved sessions (drives the green "Approved N sessions"). */ - approvedCount?: number; + readonly activeSession: IActiveSession; } // ============================================================================ @@ -109,49 +46,19 @@ interface ITitleBarState { function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): void { const { container, disposableStore } = ctx; - // Blocked sessions: either an explicit typed list, or a plain count of - // unclassified needs-input sessions (which yield the generic message). - const specs: readonly IBlockedSpec[] = state.blocked - ?? Array.from({ length: state.blockedCount ?? 0 }, (): IBlockedSpec => ({ reason: BlockedSessionReason.NeedsInput })); - const { blocked, approvalModel } = buildBlocked(specs); - - // A no-op CI-fix model seam: the fixture never clicks "Fix CI", so it only - // needs to report no sessions hidden. Supplying it avoids the real model, - // which would depend on services not registered in this fixture. - const ciFixModel = new class extends mock() { - override readonly hiddenSessions: IObservable> = constObservable>(new Set()); - }(); - const instantiationService = createEditorServices(disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); - reg.defineInstance(IBlockedSessionsCIFixModel, ciFixModel); reg.defineInstance(ISessionsService, new class extends mock() { override readonly activeSession: IObservable = constObservable(state.activeSession); override readonly visibleSessions: IObservable = constObservable([]); }()); - reg.defineInstance(ISessionsManagementService, new class extends mock() { - override readonly onDidChangeSessions = Event.None; - }()); - reg.defineInstance(ISessionsProvidersService, new class extends mock() { - override readonly onDidChangeProviders = Event.None; - }()); - reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { - override readonly onDidChangePartVisibility = Event.None; - }()); - reg.defineInstance(IQuickInputService, new class extends mock() { - override readonly onShow = Event.None; - }()); - // The blocked-sessions feature is only enabled outside of stable builds. - reg.defineInstance(IProductService, new class extends mock() { - override readonly quality = 'insider'; - }()); }, }); - // The widget's pill styles are scoped under `.command-center`, so recreate - // that ancestor. The command center box sizes itself relative to the + // The widget's identity styles are scoped under `.command-center`, so recreate + // that ancestor. The command center sizes itself relative to the // viewport, so give the host a representative width. container.classList.add('agent-sessions-workbench'); container.style.width = '460px'; @@ -166,17 +73,7 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo override async run() { } }(); - const sessionActionFeedback = new class extends mock() { - override readonly approvedCount: IObservable = constObservable(state.approvedCount ?? 0); - override notifyApproved(): void { } - }(); - - const blockedSessionsModel = new class extends mock() { - override readonly blockedSessions: IObservable = constObservable(blocked.map(entry => entry.session)); - override readonly blockedSessionsWithReasons: IObservable = constObservable(blocked); - }(); - - const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined, sessionActionFeedback, approvalModel, blockedSessionsModel, ciFixModel)); + const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined)); widget.render(widgetHost); } @@ -186,79 +83,21 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo export default defineThemedFixtureGroup({ path: 'sessions/' }, { - // Default: shows the active session pill (icon + title + workspace). SessionsTitleBar_ActiveSession: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), }), }), - // Requires-input: generic orange state (a mix, or unclassified needs-input). - SessionsTitleBar_RequiresInput: defineComponentFixture({ + SessionsTitleBar_LongTitle: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blockedCount: 3, + activeSession: createMockActiveSession('Investigate authentication redirect behavior across desktop and web clients', 'vscode'), }), }), - // Requires-input (terminal): all blocked sessions are waiting on a terminal command. - SessionsTitleBar_RequiresInputTerminal: defineComponentFixture({ + SessionsTitleBar_NewSession: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blocked: [ - { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, - { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, - ], - }), - }), - - // Requires-input (question): all blocked sessions are asking a question. - SessionsTitleBar_RequiresInputQuestion: defineComponentFixture({ - render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blocked: [ - { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Question }, - ], - }), - }), - - // Requires-input (failing CI): all blocked sessions have failing CI checks. - SessionsTitleBar_RequiresInputFailingCI: defineComponentFixture({ - render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blocked: [ - { reason: BlockedSessionReason.FailingCI }, - { reason: BlockedSessionReason.FailingCI }, - ], - }), - }), - - // Requires-input (mixed): a mix of reasons falls back to the generic message. - SessionsTitleBar_RequiresInputMixed: defineComponentFixture({ - render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blocked: [ - { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, - { reason: BlockedSessionReason.FailingCI }, - ], - }), - }), - - // Approved (one): transient green confirmation after approving a session action. - SessionsTitleBar_ApprovedOne: defineComponentFixture({ - render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - approvedCount: 1, - }), - }), - - // Approved (many): green confirmation after approving several sessions in a row. - // Takes precedence over the orange requires-input state while visible. - SessionsTitleBar_ApprovedMany: defineComponentFixture({ - render: (ctx) => renderTitleBar(ctx, { - activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), - blockedCount: 3, - approvedCount: 3, + activeSession: createMockActiveSession('', undefined, false), }), }), });