sessions
代理視窗架構 — 涵蓋代理優先應用程式、分層結構、資料夾結構、聊天小工具、選單、貢獻、進入點及開發…
npx skills add https://github.com/microsoft/vscode --skill sessionsBefore Making Any Changes
MANDATORY: Before writing or modifying any code in src/vs/sessions/, you must read these documents:
.github/instructions/coding-guidelines.instructions.md— Naming conventions, code style, string localization, disposable management, and DI patterns..github/instructions/source-code-organization.instructions.md— Layers, target environments, dependency injection, and folder structure conventions.
Then read the relevant spec for the area you are changing (see table below). If you modify the implementation, you must update the corresponding spec to keep it in sync.
Specification Documents
| Document | Path | When to read |
|---|---|---|
| Layer rules | src/vs/sessions/LAYERS.md | Before adding any cross-module imports. Defines the internal layer hierarchy (core → services → contrib → providers) with ESLint-enforced import restrictions. Key rule: contrib/* must NOT import from contrib/providers/*. |
| Layout spec | src/vs/sessions/LAYOUT.md | Before changing any part, grid structure, titlebar, or CSS. Documents the fixed grid layout (Sidebar | ChatBar | AuxiliaryBar), part positions, the modal editor system, per-session layout state persistence, and the titlebar's three-section design. |
| Layout controller spec | src/vs/sessions/LAYOUT_CONTROLLER.md | Before changing LayoutController or per-session layout state. Details how the auxiliary bar, panel, and editor working sets are captured/restored when switching sessions, multi-session suppression, the auto-reveal-on-changes flow, workspace-folder ordering, and storage/migration. |
| Sessions spec | src/vs/sessions/SESSIONS.md | Before changing session/provider interfaces or data flow. Covers the pluggable provider model (ISessionsProvider → ISessionsProvidersService → ISessionsManagementService), ISession/IChat interfaces, observable state propagation, workspace/folder model, and session type system. |
| Sessions list spec | src/vs/sessions/SESSIONS_LIST.md | Before changing the sessions sidebar list. Covers the tree widget (WorkbenchObjectTree), renderers, grouping (workspace/date), filtering (type/status/archived/read), pinning, read/unread state, workspace capping, mobile adaptations, storage keys, and registered actions. |
| Mobile spec | src/vs/sessions/MOBILE.md | Before adding any phone-specific UI. Covers the mobile part subclass architecture, viewport classification (phone < 640px), MobileTitlebarPart, drawer-based sidebar, MobilePickerSheet, view/action gating with IsPhoneLayoutContext, and the desktop → mobile component mapping. |
| AI Customizations | src/vs/sessions/AI_CUSTOMIZATIONS.md | Before working on the customization editor or tree view. Documents the management editor (in vs/workbench) and the tree view/overview (in vs/sessions/contrib/aiCustomizationTreeView). |
Common Pitfalls
-
Keep workspace fallback discovery out of the picker UI class: provider-session subscriptions, ranking, deduplication, and existence checks belong in a focused helper such as
SessionWorkspaceFallback. The picker should only enforce source priority, reject stale async results, and publish the selected workspace. -
Paired experiment treatments must resolve atomically: when a prompt and its editable placeholder are separate treatment values, use them only when both are non-empty; otherwise use both defaults so copy from different variants is never mixed. The prompt may omit the placeholder token entirely, in which case it is used literally and placeholder highlighting is simply absent.
-
Onboarding variations share structural steps and vary only their run step: keep one scenario for workspace selection, then resolve the experiment/developer variation when the run step executes. Personalized GitHub prompts use existing authentication silently, stay within a bounded cancellable lookup, verify that the selected draft workspace is still current, and fall back to the default prompt without surfacing an error.
-
Prompt-option headings and cards are one visual surface: group them with one enclosing border on the session surface; filling the entire group with the chat-input background makes it compete with the input, so reserve that fill for the cards. In each card, keep the icon and title on the first row, let supporting text span from the card's leading edge beneath the icon, and balance top/bottom padding so short copy does not leave a hollow corner.
-
GitHub prompt-option numbers belong with the action title: render labels such as "Tackle issue #123" or "Fix CI #456" on the first row and reserve the second row for the issue/PR title. This preserves the scarce full-width line for descriptive repository content.
-
Prompt-option selection has two generated input forms: the selected option stays switchable while the input is empty, exactly matches its full prompt, or exactly matches that prompt after its editable placeholder was activated and removed; every other edit disables the cards. Animate only the first insertion, replace later selections immediately, focus the input on activation, and use the card foreground for selected borders rather than conflating selection with the blue focus color.
-
Repository prompt options follow the draft workspace: keep a contribution-owned watcher after the onboarding run step completes. A workspace change clears stale cards immediately, shows loading skeletons, cancels the old lookup, and resolves a fresh bounded option set for the replacement draft; completed candidates from a timed-out lookup remain available only when their repository context is still current.
-
Prompt-option disabled presentation must not reuse Button's shared
.disabledopacity: the base rule is!important, so component-specific selected/disabled opacity cannot win without another forbidden override. Keep cards focusable, exposearia-disabled, gate activation in the component, and use a component-owned state class; gate skeleton animation with.monaco-reduce-motionso the effectiveworkbench.reduceMotionsetting wins over the OS media query. -
Ellipsized prompt-option copy needs an explicit full-content hover: register an
IHoverServicemanaged hover on every card and include both the complete action title and complete supporting text. Do not rely on clipped DOM text or a native title tooltip; GitHub titles routinely exceed the card width. -
Three prompt options must fill every row: render three equal growing cards on one row when space allows. At the two-column breakpoint keep
flex-growenabled so the third card occupies the full wrapped row instead of leaving an empty column. -
GitHub prompt-option numbers are secondary title metadata: model the issue/PR number separately from the action title, render it beside the title using the same muted color as the second line, and combine both only for ARIA and full-content hovers. Do not parse or color substrings inside one title string.
-
Agent-host onboarding readiness comes from advertised session types, not provider registration: an agent-host provider exists before its root state connects, while its
sessionTypesstay empty. Gate tours that need a usable host on a context key derived from anylocal-agent-host/agenthost-*provider exposing a session type, and update it fromISessionsManagementService.onDidChangeSessionTypes. -
Diagnostic log text is not a unit-test contract: add consistently prefixed, actionable logs, but do not add tests that assert log messages or levels. Validate the underlying behavior and keep diagnostics free to evolve.
-
Shared visual-module gates must not activate broader layout contracts: the Agents window may opt into shared editor-tab styles through a tab-specific root class, but must not apply the broad
style-overrideclass unless it also loads every matching Modern UI layout module. Keep shared tab runtime metrics aware of both gates, and preserve structural behavior such as a sticky add-tab action when removing a Sessions-owned stylesheet. Since the Agents workbench is always modern, chat-tab presentation belongs in the owningchatCompositeBar.css, scoped through.session-chat-tabs-barand chat-specific classes while consuming shared state tokens; do not add Sessions selectors to or rewrite the shared editor stylesheet. -
Minimum-size activation across the Sessions/Editor split must be symmetric and layout-aware: when either part is at minimum width, pointer or keyboard activation expands it by shrinking its sibling to minimum width. In single-pane layout, the Editor grid node's effective minimum includes the visible docked Auxiliary Bar width; using
editorPartView.minimumWidthalone collapses Details. -
Do not inject
ISessionsServiceinto editor-part construction: the sessions service depends on editor parts through the sessions-part graph, so injecting it intoSinglePaneMainEditorPartcauses recursive service instantiation during startup. Prefer lower-level services such asILabelServicewhen the editor only needs resource presentation. -
Workspace-folder labels must distinguish physical paths from repository identity: a worktree URI basename is the worktree directory, not the repository name. Route breadcrumb and workspace-projection labels through the delayed
IWorkspaceFolderLabelService; the Agents implementation may readISessionsService.activeSessionbecauseBreadcrumbsModelis created fromBreadcrumbsControl.update()after editor-part construction, but never injectISessionsServiceintoSinglePaneMainEditorPartitself. Breadcrumbs omit a workspace root whenever only one folder exists in any VS Code window; folder changes rebuild the model and recompute labels. -
Session repository labels come from
ISessionFolder.name, not the repository URI basename: providers may supply a richer display identity such asorg/repo. Use the session folder name for plain and verbose labels, and use URI basenames only as the final no-session fallback. -
Breadcrumb item equality must include presentation overrides:
BreadcrumbsWidget.setItemsretains equal prefix items, so a customFileElement.labelmust participate in equality. Otherwise folder or session-label changes for the same URI update the model but leave stale DOM text. -
Animation performance must preserve perceptual smoothness: reducing a continuous title shimmer to 10 stepped updates per second makes the sweep visibly choppy even if paint counts improve. Use a smooth baseline such as 30 updates per second, then measure the remaining performance win; do not optimize decorative motion by callback counts alone.
-
Pet placement must align the visible sprite, not only its absolute-positioning box: anchoring the button at
bottom: 100%leaves the pet visually detached because the input stack has top padding and transient confirmation/question surfaces add their own top margin. Keep the host on the complete stack and derive the optical offset from the actual input-to-host inset, capped at the confirmation/question alignment; one fixed deeper offset makes the bare input look overlapped. -
Keep only the rendering pet's speech bubble inside its input bounds without turning the pet around: the speech sprite's visible pixels overhang the button on the right, so dragging the rendering pet to the input's right edge can clip the ellipsis. Move the mirrored bubble fully to the pet's left with only its tail touching; do not center it over the pet, and do not apply the special treatment to yapping or any other state.
-
Do not conflate custom-agent selection with Agent Host execution mode:
chat.modeChangedescribes workbench mode/custom-agent picker selections, whileinteractive,plan, andautopilotare Agent Host execution modes and belong inagentHost.executionModeChanged. Preserve the SDK-native mode event as a peer rather than reshaping either axis into the other. -
Picker telemetry must use the scoped session and active chat: Agents Window action view items can belong to a non-active visible session or peer chat. Resolve previous selection and request counts from scoped
ISessionContext.session.activeChat, never a window-global picker model or parent session resource. -
Regrouping already-visible picker entries as enrichment arrives: grouped async pickers must append groups in their final order rather than initially showing fallback groups and moving entries later. Fetch the source set once, run small group-membership queries in parallel, then append each completed group in display order.
-
Viewer-group PR results and paged catalog results are one loaded set: search, deduplication, and re-rendering must use their union. Replacing picker items from catalog pages alone can make an already-visible matching review/assignment PR disappear.
-
Folder isolation is not worktree isolation:
isolationMode: 'workspace'means the ordinary folder mode and must not requiresupportsWorktreeConfiguration. Gate provider selection only for actual worktree settings. -
GitHub fork PR branches are not base-repository branches: use the base repository's checkoutable
refs/pull/<number>/headref (or equivalent head-repository identity/commit), never fetch the unqualified fork branch name fromorigin. -
PR-session exclusion must understand checkout refs: after using
refs/pull/<number>/head, older or partially hydrated sessions may only exposeorigin/pull/<number>/headas their upstream. Recover the PR number from that ref instead of comparing it only with the source branch name. -
Hidden queued requests stay hidden while pending: propagate transcript visibility onto the pending request model and filter hidden pending rows before adding queue dividers.
-
Creating a custom materialization path for a hidden bootstrap turn: use
ISessionsManagementService.createAndSendNewChatRequestwith the normal session options (includingworktreeBranchTrack) and mark the bootstrap request/response hidden from the transcript. Keep the turn in durable model history instead of inventing a second session-creation lifecycle. -
Showing local-worktree actions on cloud-only repository headers: a GitHub-backed section is not necessarily a usable checkout, while session-type capabilities register asynchronously and
file-only checks incorrectly exclude remote-host workspaces. Hide the action only forgithub-remote-filecloud sections, resolve within the originating section, and enforce provider capability when creation starts. -
Reading observable repository metadata once while rendering a section header: local workspace and GitHub metadata can hydrate after the tree template is created, leaving scoped menu context keys permanently false. Bind repository-derived header keys in an element-scoped
autorunand clear it whenever the recycled template changes elements. -
Requiring GitHub identity and a usable checkout on the same session folder: mixed workspace sections can carry repository identity on a cloud session while a separate local/remote-host session carries the checkout. Combine identity and non-cloud workspace presence at section scope, then resolve the non-cloud folder from that originating section.
-
Hiding repository actions when optional
gitHubInfois absent: some local providers intentionally leavegitHubInfoundefined even for GitHub clones. Gate only on a non-cloud workspace, then resolve identity lazily from session metadata or the checkout's ordered Git remotes when the action runs. -
Awaiting repository discovery before showing a picker: opening an unscanned checkout can take several seconds and makes the toolbar action appear inert. Show the picker immediately with a localized disabled/busy state, await repository/remotes hydration via observables, and cancel that wait when the picker closes.
-
Steering visible input into a hidden bootstrap turn: tools and confirmations produced after steering still belong to the hidden response, leaving the session in Needs Input without an actionable transcript row. Keep hidden bootstrap prompts tool-free and convert steering submissions to queued messages while the active request is hidden.
-
Rejecting a headless worktree create before late session types register: local Agent Host types can arrive after repository and PR selection, so a synchronous capability check produces a false "no provider" error. Keep the initiating picker busy and await
onDidChangeSessionTypeswithout a timeout, canceling from the picker lifecycle. -
Opening a provisional chat with a filtered bootstrap and no visible state: the chat looks empty even though model loading/materialization is active. Show a quiet, accessible transcript-level readiness status driven by model/request observables, and remove it deterministically when the hidden bootstrap completes or visible content appears.
-
Mirroring a child chat widget's model in the owning view: the view already owns the load reference while
ChatWidgetowns the active view model; a second model observable can drift during clear/rebind races. Derive view-only state fromChatWidget.onDidChangeViewModelandwidget.viewModel.model, while retaining only the disposable model reference needed for lifetime ownership. -
Exposing a chat widget's internal transcript element for caller-owned markup: raw DOM access couples consumers to private structure and bypasses widget visibility/layout invariants. Add a semantic widget operation such as
setTranscriptProgress(...)and keep creation, accessibility, and visibility state inside the widget. -
Keeping bootstrap context in a view-only side channel: context needed by the next request must use the normal attachment pipeline so it reaches provider history and survives restore. Attach it to the hidden bootstrap with
_metadescribing its transcript presentation, mirror it through a semantic widget API, and synchronously move the same entry into the first visible request before context capture. -
A sash element's
left/topis the hit-area edge, not the split boundary:SplitView.getSashPositionreturns the exact boundary after the preceding view, thenSash.layoutsubtracts half the sash size so the draggable element is centered on that boundary. Align the Sessions/Editor and bottom-Panel grid sash hit areas toagents.layout.floatingPanelGap; do not apply that token to independent geometry such as the Auxiliary Bar's leading padding. -
Wrong menu IDs: Never use
MenuId.*fromvs/platform/actionsfor Agents window UI. Always useMenus.*frombrowser/menus.ts. -
Editor-group header existence must not depend on an editor-provided scope: the group-level
showHeaderoption enables both header actions and header-hosted breadcrumbs; each menu item gates itself with an active-editorwhenclause. An editor's optionalscopedInstantiationServiceproperty only supplies context for evaluating those clauses, and breadcrumbs compose into the same fixed-height header row rather than creating a second persistent slot. -
The empty Files breadcrumb belongs to the editor area, not the docked detail: expose the session working directory as the empty Files input's breadcrumb resource only while the editor area is visible. A detail-only layout may keep header actions visible, but it must not show a root breadcrumb for hidden editor content; preserve the underlying resource separately for serialization and refresh breadcrumbs from the editor-part visibility signal.
-
Pass the complete session workspace to the empty Files input, not a preselected folder URI: the input currently derives the first mounted working directory as its breadcrumb resource at one documented fallback point. A complete multi-root design can then change that input-owned decision to a workspace-level breadcrumb that identifies the workspace and exposes all roots, without rewiring every caller.
-
A superseded managed-tab reconcile must not publish input state after an await: foreign-editor cleanup can yield while a newer session queues a successor reconcile. Check the reconcile generation immediately after cleanup and before updating the retained Files input's workspace, so only the current reconcile can publish breadcrumbs.
-
Editor-header DOM must mirror its visual groups: breadcrumbs are a direct child of the header and a sibling of one actions container; that actions container directly owns the primary and secondary action hosts. Create the actions container and its hosts inside the
showHeaderconstruction branch, then attach it after breadcrumbs so ownership and DOM order stay explicit. -
Editor-header state belongs to the owned nodes and widgets, not their parent or CSS marker classes: do not add placement/state classes to the title parent, branch layout on the
showHeaderoption after construction, or infer action presence fromhas-no-actions. Use the created header element to distinguish placement andMenuWorkbenchToolBar.getItemsLength()to derive visibility; the toolbar may still set its internal compatibility class, but header logic and styling must not depend on it. -
Secondary editor-header actions need a structural trailing column: custom secondary view items can stretch their host, so
margin-left: autoplus content-sized flex is not sufficient. Use an actions grid with a flexible leading column and explicit secondary/separator/layout columns so secondary-only states remain trailing regardless of child sizing. -
Breadcrumbs and editor-header actions share an edge, not a parent gap: do not add
column-gapto the header row; it creates a visible hole between the flexed breadcrumb box and actions box. Breadcrumb content owns its trailing breathing room, while the two sibling containers remain contiguous. -
Header actions must not retain an empty leading grid track beside breadcrumbs: when breadcrumbs are visible, make the actions grid content-sized with auto columns; otherwise its flexible primary column becomes a visible hole inside the actions element. Only use the full-width
1frprimary column when breadcrumbs are absent, where it keeps primary actions left-anchored and secondary/layout actions trailing. -
Header breadcrumb layout measures the control, not its padded wrapper:
.breadcrumbs-below-tabshas Sessions-owned left padding, andclientWidthincludes that padding. PassbreadcrumbsControl.domNode.clientWidthtoBreadcrumbsControl.layout()so the widget receives its actual usable width and does not overrun or clip its trailing scroll range. -
Header-hosted breadcrumbs inherit the shared header background: the breadcrumb widget generates a light-theme background on its inner
.monaco-breadcrumbssurface, which contrasts with the editor-header tab background inherited by sibling actions. Override both the control and inner widget surface to transparent in the Sessions-scoped header breadcrumb stylesheet so the entire row uses one theme-owned surface color. -
Single-tab titles and the Sessions header are separate, aligned rows:
SingleEditorTabsControlrenders directly into the flex title parent, unlike the multi-tab control's nested strip. In the single-pane layout, allow that parent to wrap, giveEditorHeaderControla full-row flex basis and the bottom separator, align the title label to the header content inset, and order editor actions → Add Tab toolbar (with the toolbar'strailingSeparator) → layout actions. Do not hand-style a sibling separator. WhenevershowHeaderis enabled, breadcrumbs live only in that header for every tab mode; do not retain the single-title inline breadcrumb. Since other tabs are hidden, Add Tab shows every supported editor type even when already open; multi-tab mode keeps the missing-tab filter. -
A single-tab title must not repeat the header breadcrumb as its description: when
showHeadermoves breadcrumb ownership toEditorHeaderControl, render only the editor name inSingleEditorTabsControl. Falling back toeditor.getDescription(...)duplicates the same path above the breadcrumb row even though only one breadcrumb widget exists. -
Header breadcrumb ownership must not alter normal VS Code single-tab mode:
EditorHeaderControlcreates the single-tab breadcrumb only whenshowHeaderis explicitly enabled. Without a header, preserve the existingSingleEditorTabsControlinline breadcrumb and do not create a second control in the shared title parent. -
The single-pane Agents editor supports only single or multiple tabs: while
workbench.editor.showTabsis'none', hold a Sessions-ownedenforcePartOptions({ showTabs: 'single' })disposable; clear it for every other value. Do not add a shared editor-part normalization hook or force one mode unconditionally; explicitsingleandmultiplechoices, including live updates, must remain respected. -
Auxiliary-Bar-only composition always uses multiple tabs: when the docked Auxiliary Bar is visible and the editor area is hidden, the Sessions-owned tab override enforces
multipleregardless of the configured mode. Recompute the override from both relevant part-visibility events; when the editor returns, restore the configuredsingle/multiplebehavior (or thenone→singlefallback). -
Hidden header widgets must collapse their parent box: hiding only an inner control leaves the stable parent with its previous inline width, which can push sibling header actions to the right. Mirror visibility onto the parent container and define the common row height on the shared header, not on one child widget, so Changes actions and file breadcrumbs align without stale geometry.
-
An empty editor-group header must collapse completely: keep the stable header content hidden by default and show it only when breadcrumbs are visible or a primary/secondary menu host has actions. Hiding only the children can leave the header border or reserved height visible as an empty row.
-
Title content spans the full editor-group width: breadcrumbs and tabs use the full group width in every layout. Only the editor pane is narrowed beside the docked detail panel; do not thread the editor content inset through
EditorTitleControl.layout(). -
Agents header styling must not modify shared editor CSS: keep
vs/workbencheditor styles byte-for-byte unchanged so normal VS Code windows cannot regress. Add an explicit header-placement class in shared DOM code, then put every visual override under a Sessions-owned stylesheet scoped to.agent-sessions-workbench.dock-detail-panel. -
Header presentation has its own control:
EditorGroupViewpassesmenuIdsandshowHeadertoEditorTitleControl, which createsEditorHeaderControlwhen enabled.EditorHeaderControlowns header DOM, breadcrumbs, menu toolbars, scoped action rendering, visibility, and fixed height;EditorTitleControlowns tabs and includes the header height in its total. -
The title-owned header is a child of the title container and follows its recreate lifecycle: create it after the tabs inside
EditorTitleControl.parent, include its height in the title control's layout result, and rebuild it with tabs/breadcrumbs afterclearNode(parent)when editor options change. Do not make it a sibling that requires separate DOM cleanup and duplicate group layout subtraction. -
Header visibility updates distinguish creation from live changes:
updateHeaderVisibility(relayout)updates DOM/state in both cases, but creation and option-driven reconstruction passfalsebecause their caller already owns layout. Live menu-item and breadcrumb changes passtrueso the parent group is explicitly relaid out. -
Durable chat source/origin references: Store only
turnIdin durable fork/side-chat references. Active versus historical is mutable lifecycle state that consumers must resolve against the currentactiveTurnand retainedturnswhen needed; do not encode lifecycle state in the reference type. -
Selected side-chat text is an immutable snapshot, not a live range:
SideChatSource.selection/ChatOrigin.selectionpreserve the exact text captured at side-chat creation time. Never model it as offsets into the source transcript or try to recompute it from later DOM/protocol state. -
ChatSourceis fully discriminated: Fork and side-chat sources both require explicitkindplus stable top-levelturnId. Do not add no-kind compatibility helpers or route by structural property presence; switch directly onsource.kind. -
Sessions menu ids must live in the shared menu registry: Do not declare sessions-owned
new MenuId(...)constants ad hoc inside individual parts. Add them tobrowser/menus.tsunderMenuswith discoverableSessionsEditor...names so ownership and reuse stay obvious. -
Events instead of observables: Session state must flow through
IObservable, notEvent. Useautorun/derivedfor reactive UI, notonDid*event listeners. -
Importing from providers: Non-provider
contrib/*code must never import fromcontrib/providers/*. Extract shared interfaces toservices/orcommon/. -
IAgentSessionsServicein shared code:IAgentSessionsService(vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService) is a Copilot-provider internal and may be imported only by the Copilot chat sessions provider (contrib/providers/copilotChatSessions/). Shared sessions code (core/services/non-provider contribs, e.g. the sessions list or visible-sessions grid) must stay provider-agnostic and go throughISession/ISessionsManagementService— never reach intomodel.observeSession(...)etc. for lazy loading. This is enforced by an ESLintno-restricted-importsban scoped tosrc/vs/sessions/**(Copilot provider exempted). -
Missing entry point import: New contribution files must be imported in the appropriate
sessions.*.main.tsentry point to be loaded (for examplesessions.common.main.ts,sessions.desktop.main.ts,sessions.web.main.ts, orsessions.web.main.internal.ts). -
Modifying workbench code: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components.
-
Do not repeat subagent identity beside the open-chat pill: The subagent pill's title is the complete inline affordance. Do not render the agent name or a generic "Subagent" phrase before it; that duplicates identity and adds visual noise.
-
Subagent model metadata is differential inline, but complete in the hover: Show the subagent's model beside the pill by default and hide it only when it concretely matches the parent chat's selected model. Compare canonical ids, registered display names, and the parent input's selected-model metadata (active turns may not yet expose resolved response metadata). Keep the unfiltered subagent model in the hover/ARIA label regardless of inline visibility; if the parent model is unresolved, still show it inline because no match can be established.
-
Optional metadata owns its separator: Keep separators such as
·on the conditional metadata element and create that element hidden. An empty optional label must never leave punctuation behind while its reactive visibility is still resolving. -
Live numeric labels must not jitter: Apply
font-variant-numeric: tabular-numswithfont-feature-settings: "tnum"as a fallback to elapsed-time and other numeric labels that update in place. -
Collapsed work summaries must quantify what they hide: Prefer outcome-oriented copy such as "Completed 6 steps in 2m" over vague elapsed-only text such as "Worked for a few minutes." Count the visible items placed inside the disclosure so the summary matches what expanding it reveals.
-
Created-session result pills belong with the final response, not the collapsed work steps: A completed
create_session/create_chatpill is a durable outcome and navigation affordance. Render it only once the response completes and order it after the final response markdown, so it stays visible beside the completed-turn adjuncts without being repositioned while focused. -
Logical final markdown is not necessarily rendered final markdown: With incremental rendering, response completion accelerates the buffer but does not synchronously drain it to the DOM. Delay completed-work disclosure and bottom-summary placement until the final markdown part reports that its morpher has drained, then retry from that signal.
-
Custom action proxies must propagate owner-observed state: When an action view wraps a menu action in a proxy, state that controls surrounding UI (such as enabled/available) must also be written to the original menu action observed by the owner. Re-subscribe on
IActionViewItemService.onDidChangefor late factory registration, but do not assume the replacement proxy's state automatically reaches the menu action. -
Editor feedback glyph placement: Use Monaco's
lineNumberClassNamewhen the feedback affordance should replace the number only while its line is hovered; it eliminates a dedicated glyph lane while preserving the number at rest. Style the line-number pseudo-element as the full feedback control, including its themed hover background, so its visual and click target match. -
Line-number decoration tooltips belong in Monaco decoration options: A
lineNumberClassNamenode is regenerated as the editor renders and scrolls, so DOM-managed hovers can silently attach to a stale or never-decorated element. Set the localizedlineNumberHoverMessagewith the same decoration instead; Monaco's glyph hover controller follows the rendered line-number lifecycle. -
Compact multi-diff control alignment: The file-header twistie, unchanged-region expand control, and fold control form one visual column in the Agents editor. Remove the header content's left padding and use the same small inset for both unchanged-region controls; do not let the shared multi-diff defaults leave each control at a separate horizontal offset.
-
Embedded multi-diff gutters need a shared minimum width: Each embedded editor otherwise sizes line numbers from its own largest line number, causing the content and nearby feedback glyph to appear to drift between file entries. Set a common
lineNumbersMinCharswidth for the compact multi-diff; it remains stable through three-digit line numbers and grows only when a file exceeds that reserved capacity. -
Editor-content overlays must anchor to the inset pane, not the full editor group: In single-pane mode the editor group spans both the editor and docked detail panel, while
EditorGroupView.editorPaneContainerbounds only the editor content. Mount submit/navigation overlays to that pane container so their bottom-right position stays inside the diff when the detail panel is visible or resized. -
Timeouts as fixes: Never use
setTimeout/disposableTimeout/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (autorun/derived), explicit events (onDidChange*), lifecycle phases, or awaiting the actual async operation. -
The prompt timeline dock treats the bottom as the latest prompt and pairs both hover directions: when the transcript is fully scrolled down, resolve the final prompt directly rather than scanning every prompt's top offset. Hovering either a dot or its prompt row must preview both elements; when the dots are capped, map the row to the nearest sampled dot.
-
Sticky prompt navigation must match the rail/title reveal: Previous/Next and the sticky title must all reveal the prompt (request) row aligned to the top via the shared
reveal(requestId)— the same path the dock/ruler rail uses. Do not align the following response to keep the header pinned, and do not add a "navigation pin" that forces the header to stay visible after a jump: the header is atop:0overlay, so it would cover the freshly top-aligned prompt (the prompt shows only a sliver). Let the header follow scroll tracking (it hides once the prompt is at the top), consistent with the dock. Use the chat request-bubble hover background (--vscode-chat-requestBubbleHoverBackground, toolbar hover as fallback), composited over the opaque panel base, for the sticky title affordance rather than an underline. -
Sticky prompt header transition is a label roll, not a moving band: on prompt change the label text rolls (WAAPI slide+fade of absolutely-positioned line elements inside an
overflow:hiddenclip viewport), while the opaque band stays fixed. Do NOT translate the whole band to get an Explorer-style push-off: the band would move above the transcript top (its container.interactive-sessionisoverflow:visible, so it'd overlap the session header) and clipping it would cut the band's soft drop-shadow. The roll gives the "header gives way to the next" feel with none of that risk. Gate the roll on the header already being visible (snap on first appearance/jumps) and honorprefers-reduced-motion. -
Sticky prompt header must mirror the session header's box model, not the message column: the band lives in a two-level structure — an outer full-width
.prompt-timeline-sticky(positioning + reveal only), a.prompt-timeline-sticky-contentcentered host (max-width: 950px; margin: 0 auto; padding: 0 10px, matching.session-view-centered-content+.chat-composite-bar.session-header-bar's 10px side padding), and an inner.prompt-timeline-sticky-band(flex: 1) that carries the background/border/shadow (mirroring.chat-composite-bar-header). This makes the band's background align exactly with the session header's bottom-border line above it. Do NOT paint the background on the full-width outer (bleeds past the header and over the scrollbar gutter — a ~1px right-edge "bump") or on the 950 message column (20px wider than the header, since the header is inset 10px). The band'spadding-left: 22px(16px icon + 6px gap) reproduces the header's status-icon column so the prompt text lines up with the title and the 32px-inset prompts below. -
Grid
onDidChangeis not a sash-drag signal: the workbenchSerializableGrid/GridViewonDidChangefires for size changes and view add/remove, but not internal splitview sash drags. If logic must react to a part node being resized by a sash, route it through that part'slayout(width, ...)callback, which receives the in-progress node width. -
Docked detail collapse must use the raw sash width before clamping: the docked auxiliary bar keeps a minimum visible width, so checking the clamped width can never detect a drag-to-zero collapse. Decide collapse from the raw requested sash width, then route the hide through
setPartHidden(AUXILIARYBAR_PART)so context keys and per-session capture stay in sync. -
Stashed state read back later (side-channels): Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a
Set/flag set inopenSessionand consumed by ashouldX()pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set atomically together with its source of truth and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as anIObservableset in the same transaction asactiveSession(via a single internal setter so it can never go stale), and read with.read(reader)in the consumer'sautorun— never via a consume-once getter. -
Provider-owned model/mode selection belongs in the loaded chat model, with draft persistence driven by debounce: For AHP-backed chats,
setModel/setAgentmust push the selection into the loadedIChatModel.inputModel(like_updateChatSessionState) and let the draft-sync debounce emitchat/draftChanged. Do not immediately dispatch a model/agent-only draft from the provider, because it can overwrite unsaved typed text before the debounced full input-state draft is persisted. -
Blocking on a "pending/waiting" state instead of creating + upgrading: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then replace/upgrade it once the awaited dependency arrives (driven by an
onDidChange*/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do not bound the upgrade with a timeout or even a lifecycle milestone likeLifecyclePhase.Eventually— an agent host connects lazily and can surface its session types arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. -
Over-commenting: Don't write long explanatory comments narrating what the code does or justifying ordinary patterns. Hard rules: JSDoc = 1–2 short sentences max (never enumerate every branch/feature, restate the signature, or list what the function does NOT do); inline method comments = 1 line max, only for a genuine workaround/non-obvious constraint, never to narrate the next statement. Default to no comment — if code needs a paragraph to explain, rename/extract instead. Before writing any comment longer than one line, delete it or shorten it to one line.
-
Inserting/removing DOM on demand for transient UI (e.g. inline rename inputs): Don't
insertBefore/appendChild+remove()a widget on the tab/row element itself when an interaction starts/ends — that churns the parent's child list and depends on event ordering during teardown. Also don't eagerly build a heavy widget (e.g. anInputBox) per row "just in case", since most rows never use it. Instead, create a stable, empty container alongside the label once, toggle its visibility via a CSS class on the row (e.g..editing), and create the widget inside that container lazily only while editing — disposing it and emptying the container (reset(container)) when done (InputBox.dispose()does not detach its own node). Prefer the shared themed widget (InputBox+defaultInputBoxStyles) over a hand-rolled<input>. -
Collapsing distinct provider identities in pickers: Do not collapse extension-backed chat session ids (e.g.
copilotcli) and agent-host ids (e.g.agent-host-copilotcli) based only on friendly names or well-known provider enums. They can coexist in the Agents window and route to different infrastructure; keep the exact session type id through selection/delegation and hide ambiguous legacy targets when an agent-host target supersedes them. -
Permission picker copy must stay provider-neutral and aligned: Reuse the same labels and descriptions across Copilot Chat and Agent Host permission pickers. Avoid provider-specific phrasing such as "Copilot uses..." when the same choice appears in the Agents window.
-
Interactive tool denial must use an accepted SDK decision with feedback, not a completion outcome: In the Agent Host Copilot provider, a user choosing Skip must resolve the permission request with
{ kind: 'reject', feedback: '...' }. The feedback makes the SDK feed the denial back to the model and continue the turn;denied-interactively-by-useris a completion outcome that the current runtime rejects as a malformed permission-host response. Reserve a barerejectfor abort, disposal, or requests that cannot be presented to the user. -
Internal Agents workspace in recent history: Collapse every internal
User/agent-sessions.code-workspacevariant into one canonical Agents Window entry. Recognize the reserved path across profile and worktree user-data directories, and make the single picker entry point at the current environment's Agents workspace. -
Resolving a session's provider via the create-only tracking map: On the agent host, resolve the owning provider for any per-session operation (createChat, disposeChat, sendMessage, …) through
AgentService._findProviderForSession, never the raw_sessionToProvidermap. That map is populated only bycreateSession, so a restored session (alive in the state manager after a host restart but never created in this process) is absent from it — a direct lookup throwsno provider for sessionand silently breaks the feature (e.g. Add Chat did nothing for restored sessions while messaging worked, because messaging already used the fallback)._findProviderForSessionfalls back to the session URI's scheme provider, which is what makes restored sessions work. -
Dispatching per-chat side-channel actions (agent/model) to the session URI: An agent-host session can own multiple peer chats, each with its own backend conversation (
CopilotAgent._chatSessions). Conversation side-channel actions likeSessionAgentChanged/SessionModelChangedmust be dispatched to the per-chat turn channel (_resolveTurnDispatchChannel, which carries achatIdfragment for peer chats), notsession.toString(). The session URI resolves to the session's default chat (_sessions), so dispatching there silently applies the change to the wrong conversation and an additional chat never sees the agent/model swap. The host must also forward thechatChannelthroughagentSideEffects.handleAction→changeAgent/changeModel, which apply it to_chatSessionswhen present. The protocol modelssummary.agent/summary.modelat session level only, so equality guards comparing against session summary are valid for the default chat but must be skipped for peer chats. -
Do not infer or fall back from a peer chat channel after progress was emitted: Agent progress signals for chat-scoped actions, especially tool-call readiness and permission requests, must be emitted with the exact
ahp-chat://...channel that owns the tool. Do not recover by scanning active turns, remappingChatToolCallConfirmed, or usingparseDefaultChatUri(...) ?? sessionUriinAgentSideEffects; malformed/misrouted chat channels should fail loudly so the producer or dispatch path is fixed.handleToolCallConfirmedand_toolCallAgentsmust use the chat channel URI containing the tool call; keying by the parent session URI makes confirmations miss the pending SDK request. -
Do not synthesize default chat URIs in the workbench handler:
AgentHostSessionHandlermust source the upstream default chat URI from hydratedSessionState.defaultChat/SessionState.chatsand store that mapping in its chat-resource-to-upstream-URI map. CallingbuildDefaultChatUri(session)in the handler assumes one server URI shape and hides protocol/provider bugs; dispatch turn lifecycle and pending/input actions through the mapped upstream chat URI instead. -
Opening a subagent editor must carry the exact upstream chat channel: A fragment-only editor resource forces
AgentHostSessionHandlerto rediscover the child inSessionState.chats, which races catalog hydration and renders "Cannot resolve chat". Encode the exactahp-chat://subagent/...channel in an internal query parameter, validate its chat id and owning backend session, and subscribe directly. -
Model subagents as chats, not sessions: A subagent spawned from a tool call belongs to the parent session as an additional chat with
origin.kind === "tool", hidden from the chat tab strip. Do not callrestoreSessionfor subagents; that creates_sessionStateswithout a matching_chatStatesentry, so later chat actions hit "Action for unknown chat". Add a chat on the parent session and dispatch the subagent turn to that chat URI. -
Keep case-sensitive ids out of URI authority: URI authorities are case-insensitive, so do not place tool call ids in the
ahp-chatauthority. Subagent chat URIs use a stablesubagentauthority and put the encoded tool call id in the path; usebuildSubagentChatUri(...)instead ofbuildChatUri(..., \subagent-${toolCallId}`)`. -
Selected custom agent must be in the SDK's
customAgents, not justpluginDirectories: The Copilot SDK validates the session-startagent:option (passed tocreateSession/resumeSession) against thecustomAgentslist by name only — it does NOT consultpluginDirectories.copilotSessionLauncher._buildSessionConfigdeliberately omits agents from file-dir plugins fromcustomAgents(relying on the SDK'spluginDirectoriesdiscovery to avoid duplicates), so selecting a plugin/extension-contributed agent (e.g. "Inbox") otherwise fails withCustom agent '<name>' not found. The fix (toSdkSessionCustomAgents) force-adds the resolved selected agent intocustomAgentswhile every other file-dir agent still loads viapluginDirectories. Note the agent picker offers VS Code chat modes fromIChatModeService, but onlyplugin/extensionstorage agents are synced to the host (SYNCABLE_STORAGE_SOURCES);user/localagents are never synced, so_resolveAgentNamereturnsundefinedfor them and noagent:is sent. -
Derive SDK custom-agent names exactly like
parseAgentFile:_resolveAgentNameresolves the selected agent through the plugin parser, which trims the frontmattername(getStringValue('name')?.trim() || nameFromFile). When building the SDKcustomAgentslist (toSdkCustomAgents), derive the name the same way (?.trim() || agent.name); reading the raw frontmatternamewithout trimming yields a config name that won't match the trimmedresolvedAgentName, so the SDK still rejects the session withCustom agent '<name>' not found. -
Peer chats have no server
summary, so dedup side-channel dispatch against the last value sent for that chat: equality guards before dispatchingSessionModelChanged/SessionAgentChangedcompare againstsummary.model/summary.agent, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on theAgentHostChatSessioninstance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (undefined) can't be detected. -
Scrollable transcript surfaces must use workbench scrollbars: Don't make Agents/voice transcript regions scrollable with native
overflow-y: autoon the content node. Wrap transcript content inDomScrollableElement/list widgets so scrollbars match VS Code theming and remain usable in narrow auxiliary-window layouts. -
Background-sending a multi-chat composer must reset the composer before dispatching the send, not concurrently: in
NewChatInSessionWidget._send, creating the replacement untitled chat (openNewChatInSession({ forceNew: true })→provider.createNewChat) and the fire-and-forget backgroundsendRequestboth reach into shared chat-session state (acquireOrLoadSession/getOrCreateChatSession) for chats in the same group. Running them concurrently (send first, reset second) raced and left the sent chat stuck spinning with its message never dispatched, plus a second empty "New Chat" tab. Fullyawaitthe composer reset first, then fire the background send so it runs on its own. -
Chat tab order is the provider's stable creation order; don't reorder in the renderer: the agent host delivers
state.chatsin stable creation order (append on add, replace-in-place on update — seeagentHostStateManager/the session reducer), and a genuinely new chat is appended last. The renderer's rebuild autorun (chatCompositeBar.ts) must render that order as-is. Do not partition/move in-composerUntitledchats to the end: a draft is already last, and reordering by status makes a tab jump when a draft commits out of creation order (e.g. sending the 3rd of three drafts first moved it to the front). A chat'sUntitledpresentation (viaAdditionalChat._isNew, needed sosessionView.tsshows the composer) is independent of tab order and must not drive it. Also note_restorePeerChats(agentService.ts) must seed restored chats ingetChats()order, not inPromise.allresolution order, or the catalog scrambles on reload. -
A new chat must report
SessionStatus.Untitleduntil its first request is sent, regardless of how the provider creates it:sessionView.tsonly shows the new-chat composer (which owns the Alt+Enter background-send handler) whenactiveChat.status === Untitled. The agent host commits a new peer chat eagerly, so its host status isCompleted— surfacing the standard chat widget and breaking background send. Gate the chat's presented status on a provider-sideisNewflag (AdditionalChat.markNew/markSent, set increateNewChatand cleared insendRequest's committed-chat branch), not on the host-reported status. -
Service operations should return a result or throw, not
undefinedfor unsupported cases: capability-gated operations likeforkChatInSessionmust throw when the provider/session cannot perform them. Keep fallback decisions in the caller before invoking the service instead of encoding fallback as anundefinedservice result. -
A provisional session abandoned during commit detection must not be returned as successful: its status can remain
InProgressafter its lifecycle owner is disposed, so consumers waiting for a terminal status never settle. Clean up the provisional session and reject the send when commit detection times out or the connection is lost. -
Drop a fork when its turn point is unknown, don't forward it empty: in
AgentService.createChat/createSession, if the requested forkturnId/turnIndexresolves to no source turns, setfork: undefinedand fall through to a fresh create. Forwarding the fork with an empty turn slice makes the Copilot provider callsessions.forkwith notoEventId, inheriting the entire backend conversation while the new chat UI is seeded with zero turns — an inconsistent hidden-history chat. -
Side-chat context belongs to the provider, not AgentService message mutation:
AgentServicerecords and forwards the side-chat origin, but must not synthesize a first-turnChatattachment or strip provider-added context on restore. Each supporting provider establishes hidden backend context and removes inherited/provider-added history from the turns it returns. -
MessageAttachmentKind.Chatis generic and may reference unloaded chats: resolve chat attachments through a generic async path, enforce same-session ownership before hydration, and restore the referenced session/chat when it is absent from the state manager. Do not name this logic after side chats or assume/btwis its only producer. -
Agent capabilities are provider-specific: do not implement side chats for an agent merely because the protocol supports them. Advertise
multipleChats.sideChatand run shared side-chat tests only for providers with a complete provider-owned context/restore implementation. -
User-created side chats use the standard peer-chat tab model; only tool-origin subagents stay hidden by default: a
ChatOriginKind.SideChatchat is a normal user-facing peer chat, so it must flow throughvisibleChatTabs, the Conversations menu, pickers, and close/reopen like any other peer chat. Do not create a separate editor/detail surface for it; reserve the hidden/read-only default only for tool-origin subagents. -
/btwmust bypass queue/steer, may anchor toactiveTurn, and should activate the new peer chat through the normal sessions API: mark the silent slash commandexecuteDuringRequestso the chat widget invokes it independently, validate its anchor against completed turns or the current active turn, and after creating the side chat activate it viaISessionsService.openChat(...)before sending on that chat. Provider side-chat creation must lock on the new chat, not the source send key. Wrap the first provider prompt with a succinct instruction to prefer explanation over action and avoid work unless explicitly requested; include bounded user-visible active-turn markdown when native forks omit it, then strip the private wrapper from reconstructed visible history. Never inject reasoning or tool payloads. -
A responsive-layout autorun must re-baseline (not react) to controller-driven restores, holding the flag across the async reveal: the desktop [D7] responsive sidebar hides the sessions sidebar when small + editor + aux-bar are all open. Switching sessions restores layout via two async paths — the desktop aux-bar restore (
openView/openViewContainer) and the base controller's editor working-set apply (_applyWorkingSet, which reveals the editor part after anawaitand runs on aSequencermicrotask). Both reveal parts in a later autorun run, so an inline "same-run session changed" check only absorbs the synchronous transition and the async reveal still auto-hid the sidebar on navigation. Fix: a shared base-controller_withSessionLayoutRestore(work)epoch wraps both restore paths (the working-set wrap is the critical one for non-modal editors); the D7 autorun re-baselines_previousSpaceConstrainedwhile_isRestoringSessionLayoutis true. Also gate the constrained derivation on!multipleSessionsVisibleObsso the feature is disabled with multiple sessions visible. Never use asetTimeoutto bridge the async reveal — tie the flag to the actual promise. -
A promise-tied "epoch" helper must decrement synchronously for void/sync work, only deferring for real Promises:
_withSessionLayoutRestoreincrements a depth counter, runswork(), and decrements when done. If it always schedules the decrement on a microtask (Promise.resolve(result).finally(...)) — even whenwork()returnsundefined(the common no-op restore, e.g. a session with no workspace) — the depth stays elevated for the entire synchronous caller/test body, so_isRestoringSessionLayoutreadstrueforever and the consumer (D7) silently stops acting. Only defer the decrement whenwork()returns a thenable; for void/sync (or throwing) work, decrement in thefinally. -
A quick-chat's workspace-less kind is seeded at adapter construction and only ever promoted — every path that can carry
_metamust carry it:AgentHostSessionAdapterseeds its session-kind (QuickChatSessionKindvsWorkspaceSessionKind) fromreadSessionWorkspaceless(metadata._meta)in the constructor, and_promoteToQuickChatIfWorkspaceless(fromupdate()/setMeta()) later flips it to a quick chat the first time an authoritative_metasays workspace-less — never back, since an absent marker means "not included", not "cleared". So the_meta.workspacelesstag must ride on every metadata path:_refreshSessions()/listSessions, the live_handleSessionAdded(summary)notification, and both ends of the AHProot/listSessionsround-trip (protocolServerHandler.tsandremoteAgentHostProtocolClient.tsbuild their wire items field-by-field, andsatisfies SessionSummarydoes not catch a dropped optional field — this is exactly how the bug shipped twice). Dropping it makes a committed quick chat render under a section header labelled with its raw session UUID, leaking<userHome>/.copilot/chats/<id>as aworkspace(breaking the archive-on-delete fallback, list badges, changes/files) until a later_metaheals it._persistCachemust overlay the adapter's liveisQuickChatrather than the_metaByRawIdsnapshot, or the nextupdateAdaptersilently strips the marker from the startup cache. On the host,AgentService.listSessions()overlays_meta.workspacelessonto the provider listing from the persistedagentHost.workspacelesssession-database key (AH_META_WORKSPACELESS_DB_KEY) (the providers themselves, e.g.CopilotAgent.listSessions(), do not emit it) so restored sessions carry the tag once the state-manager live summary is gone. The host still keeps the tag on both the summary_metaandSessionState._meta(createSessionState(summary)copies it) so the channels stay consistent. Note the provider tests cannot catch a wire-level drop —MockAgentHostService.listSessionsreturns storedIAgentSessionMetadataverbatim, bypassing both mappers — so wire regressions need tests at the protocol layer. -
Don't infer "quick chat" from
workspace === undefined: a session's workspace observable isundefinedfor genuine quick chats but also transiently/edge-case for workspace-bound sessions, so keying quick-chat UI (context keys, list grouping) on it is imprecise. Expose the intent explicitly via the optionalISession.isQuickChat: IObservable<boolean>(only quick-chat-capable providers set it; absent ⇒false). The agent-host adapter exposes it as a monotonic observable seeded fromreadSessionWorkspaceless(metadata._meta)at construction and promoted by later authoritative_meta; non-quick-chat providers omit it. Consume it throughisQuickChatSession(session)/session.isQuickChat?.read(reader) ?? false. -
Workspace-less is inferred from absent
workingDirectory— exclude forks from that inference: inCopilotAgent.createSession,isWorkspacelessmust be!sessionConfig.fork && !sessionConfig.workingDirectory. A fork that arrives without an explicitworkingDirectoryshould inherit the source session's context, not be taggedagentHost.workspacelessand dropped into a scratch dir + quick-chat system prompt. -
On a failed quick-chat create, don't activate an unrelated draft:
SessionsService.openQuickChatmust, oncreateQuickChatthrowing, log and returnundefinedwithout falling back to_activate(newSession.get())— that observable can hold a workspace-bound draft from a different call site, so activating it is surprising. Return the activatedIActiveSessionfrom_activate/openQuickChat(setActive is synchronous and yields the wrapper) and have the caller focus that exact value, rather than re-readingactiveSession.get()afterwards. -
Hide an aux-bar view CONTAINER via
hideIfEmpty: true+ a viewwhen, not a containerwhen:IViewContainerDescriptorhas nowhenproperty (onlyIViewDescriptordoes). To conditionally hide a whole container (its tab/title) — e.g. the Agents-window Changes/Files containers for workspace-less sessions — put the context-keywhenon the inner view(s) and sethideIfEmpty: trueon the container. Container visibility ishideIfEmpty && activeViewDescriptors.length === 0(viewsService.updateViewContainerEnablementContextKey), andactiveViewDescriptorsalready respects each view'swhen, so the container hides reactively when all its views'whengo false. -
Don't gate "is this a quick chat?" routing on
isCreated && isQuickChat: a quick-chat draft isUntitled, soisCreated(= status !== Untitled,visibleSessions.ts) isfalse— yet it is still a quick chat. Gating quick-chat routing onisCreated && isQuickChat(e.g. the "New" action) makes a draft fall through to the workspace path (scratch-dir composer, no session-type picker, "No models available"). Route onisQuickChatalone so drafts and committed quick chats behave identically; useisCreatedonly when you genuinely need to distinguish a committed session from an in-composer draft. -
Cmd+N in the Agents window is a new-session gesture only — don't fold quick-chat/peer-chat creation into it: session creation (Cmd+N,
NewChatInSessionsWindowAction/workbench.action.sessions.newChat→ alwaysopenNewSession), quick-chat creation (Chats-section "+", Cmd+K Cmd+N,NewQuickChatAction→openQuickChat), and peer-chat creation (chat "+", Cmd+T,AddChatToSessionAction) are three distinct keybindable actions. Keep them separate — Cmd+N must not become context-aware/mirror-route to a quick chat based on the active session's kind. -
The New action must never inherit a quick chat's folder into the workspace composer:
openNewSessionFromActiveseeds the new-session composer withactiveSession.workspace.get()?.uri. A quick chat is workspace-less by intent, but if itsworkspaceobservable is ever non-undefined (e.g. a stale-build/coupling leak where_kindresolved toWorkspaceSessionKindbecause_meta.workspacelesswas dropped, exposing the host's scratch~/.copilot/chats/<id>cwd as a workspace), Cmd+N would carry that scratch dir intocreateNewSession→ "New session in<scratch>", single/no session type, no picker, "No models available". Gate the folder inheritance onactiveSession.isQuickChatso a quick chat always falls to the clean folder-picker composer regardless of any leaked workspace value. -
A reused new-session composer must re-seed its workspace draft when it swaps out of quick-chat mode: the session-type picker hides itself when it has no folder types (
sessionTypePicker_folderSessionTypes.length === 0), which is the case whenever the composer has no active session (refresh(undefined)clears the types). A freshly opened new-session composer avoids this by seeding a workspace draft from the restored folder in its constructor — but the sameNewChatWidgetinstance is reused across the quick-chat→new-session transition (sessionView.tskeepskind==='newSession'), and Cmd+N'sopenNewSessiondiscard branch only_activate(undefined), leaving the reused composer session-less → picker hidden. Fix by re-running the constructor's seed (_seedWorkspaceDraft()) from an autorun when_isQuickChatComposerflips true→false with no active session, so the reused composer matches a fresh one (folder + visible picker). Don't assume the constructor-time restore covers a reused composer. -
Every untitled-session-title fallback must be quick-chat aware: an untitled session's title observable is
'', so a hardcodedlocalize(…, "New Session")fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route all such fallbacks through the sharedgetUntitledSessionTitle(isQuickChat)helper (services/sessions/common/session.ts, boolean param so each caller controls reader-tracked.read(reader)vs.get()). There are ≥5 sites — titlebar (sessionsTitleBarWidget), session header (×2: title + rename placeholder), list-row hover (sessionHoverContent), sessions picker (sessionsActions) — keep them on the helper; never hardcode "New Session". (The Cmd+N action title stays "New Session" — that action creates a session, unrelated to a session's own title.) -
NeedsInputis still an active turn for live turn UI: agent-host tool and input confirmations intentionally transition a running chat fromInProgresstoNeedsInputwithout endingactiveTurn. Live status surfaces such as the chat input pills must useisActiveSessionStatusso they do not disappear until the next output returns the chat toInProgress. -
Chat file pills classify files against the owning session, never the window-global workspace context: multiple sessions can render concurrently, so
IChat.lastTurnChangescarriesisOutsideWorkspacederived from that session's workspace/worktree roots, and per-response file edits carry the same metadata.AgentHostSessionAdapterowns a generic session-output cache passed to output reducers; workspace classification uses the namespaced keyisOutsideWorkspace:${uri.toString()}, and workspace changes clear the cache. Keep change counts/diffs workspace-only, preview only external markdown, and open resources throughchat.editorAssociationsrather than invokingmarkdown.showPreviewdirectly. -
Agent-host-only exclusions for built-in client tools belong in
ClientToolSetsContribution, not the global tool registration:AgentHostActiveClientService.getClientToolsadvertises enabled members of every non-deprecated tool set, including extension-contributed sets. Forvscode-general, the Agents window exposes only Tool Search; gate every other member on!IAICustomizationWorkspaceService.isSessionsWindowso editor-window Agent Host sessions and normal Copilot chat retain them. -
Non-interactive MCP authentication probes must not create dynamic authentication providers: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With
allowInteraction: false, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes themcpAuthenticationRequiredaction. -
Use structured maps for the state that is actually multi-keyed, not for an incidental cache: If MCP tracking is addressed by session + server, model that source of truth directly with
NKeyMap. Do not add a separateNKeyMapthat merely caches serialized storage keys while leaving the real tracking state in nested or synchronized maps. -
Subagent activity rows must preserve rich tool presentation, stable height, tool identity, and protocol intent: Do not flatten markdown invocation messages into text, omit the shared tool icon, or show a raw terminal command when
ToolCallBase.intentionexists. Render invocation markdown with the shared chat/file-widget path and the registered/inferred compact tool icon, keep it constrained to one line within a static minimum-height slot so text, code, and file chips do not shift surrounding content, and use terminal intention before invocation-message fallback. -
Subagent reasoning preserves the last tool activity: Show "Working on it..." only during startup (before any tool is known) and while child markdown is streaming. Child reasoning must not replace the activity row; retain the most recent tool presentation, or keep the startup placeholder when no tool has run yet.
-
Cross-window pending cloud tasks must be consumed only by the matching repository workspace: the source window observes the shared pending-session file before
vscode.openFoldercreates the target window. Keep the pending task until the Git API is initialized andpendingSession.urlmatches an open repository; then open the task and clear global state.
Capturing Feedback (meta-rule)
Whenever the user flags a wrong pattern, rejects an approach, or gives design/rules feedback, automatically add it as a concise pitfall/learning to this Common Pitfalls section (or the most relevant spec doc) in the same change — without being asked again. Keep each entry 1–3 sentences: the anti-pattern, why it is wrong, and the preferred pattern.
-
Menu-order changes must update every registration assertion: action ordering can be covered by tests outside the action's owning contribution. Search for the previous order and command id, then update all affected expectations so focused tests do not leave the broader suite stale.
-
Shared commands must delegate behavior to the layout service, not inspect a layout implementation:
workbench.action.toggleAuxiliaryBarmust call the semanticIWorkbenchLayoutService.toggleSecondarySideBar()operation. Do not branch on optional layout properties or concrete workbench shape in the shared action; each workbench owns how its secondary-sidebar affordance maps to visible parts. -
Definitive session deletion and temporary list eviction are different operations: deletion clears durable provenance and pending state; filtering a still-existing session only removes its visible list entry. Keep the list-removal helper side-effect-free, and let each caller explicitly update its mutation generation instead of passing an "already incremented" boolean.
-
Durable user intent must never be discarded on
onDidChangeSessions.removed: pins, manual sort keys, and group membership (SessionsListModelService,SessionGroupsService) are cleared only onISessionsManagementService.onDidDeleteSession(or archive), never on the provider'sremoveddelta.removedis an eviction, not a deletion:BaseAgentHostSessionsProvider._refreshSessionsreconciles against one listing that the host aggregates across all its agents, and an agent that cannot answer yet returns[]instead of failing (CodexAgent.listSessionsreturns[]for a missing_githubToken, a not-yet-downloaded SDK, or a failedthread/list;ClaudeAgent.listSessionsdoes the same). Persisting the removal turned a ~300 ms startup race into permanent loss of the user's pins and groups. Runtime-only consumers ofremoved(terminals, grid slots, layout) are fine as-is — only persisted state needs the delete event. -
_refreshSessionsmust not evict a cached session whose agent contributed no rows: a listing with zero rows for an agent means "unknown", not "empty", so scope eviction tolistedAgentProviders(the set ofAgentSession.provider(...)schemes actually present in the response) and compare againstadapter.agentProvider. Real deletions still arrive throughdeleteSessionsand thesessionRemovednotification; the only cost is that an agent's last session, deleted elsewhere, lingers until it lists something again. -
Keep session-list refresh filtering linear: when retention pruning needs the complete backend key set, collect those keys while filtering entries in the original loop, then reconcile last-seen/pruning afterward. Do not introduce a candidate-map/filter/map pipeline when one loop plus one reconciliation call expresses the lifecycle more clearly.
-
Centralize session workspace filtering behind a semantic predicate: refresh, add-notification, and summary-update paths should call one
_isSessionInWorkspace(entry)-style helper. Keep key construction, working-directory parsing, pending-local lookup, and provenance checks out of each caller so the high-level list flow stays readable and all paths apply identical rules. -
Multi-root Editor filtering belongs to durable session metadata, not a workspace memento: sessions with
_meta.multiRoot.workspaceFilematch a multi-root Editor window by URI identity againstIWorkspace.configuration. Metadata-less sessions use containment against any current folder; do not retain a parallel workspace-scoped membership store whose lifecycle can drift from the host-owned session metadata. -
Name semantic layout operations after the user-facing surface: a shared operation must use the stable UI concept (
toggleSecondarySideBar()), not the implementation term (AuxiliaryBar) that happens to back it in classic layouts. This keeps single-pane mappings clear and avoids leaking layout internals through the API. -
Semantic layout commands need matching visibility and focus semantics: when a shared command maps to a different surface, expose a semantic visibility query for its toggled state and transfer focus before hiding the currently focused mapped surface. Otherwise menu labels lie about the action and keyboard users retain focus in hidden content.
-
In the single-pane workbench, the secondary side bar is the side pane:
SinglePaneWorkbench.toggleSecondarySideBar()(backingworkbench.action.toggleAuxiliaryBar) delegates totoggleSidePane()andisSecondarySideBarVisible()reports the side pane's visibility. The docked detail lives inside the editor part, so hiding transfers focus from either side-pane part to the sessions list. Do not add a separate editor-pane toggle operation; consolidate ontoggleSidePane(). -
ISession.capabilitiesmust be observable, not a live plain getter: capabilities can hydrate/change after a session first surfaces (e.g. an agent host whose root state arrives after the session's firstSessionState). A plain getter cannot be tracked by the context-key autorun (setActiveSessionContextKeysreads it inside anautorun), sosupportsMultipleChats/sessionSupportsForkwould stay stale, and a multi-chat catalog processed whilesupportsMultipleChatswas stillfalsewould stay collapsed to[defaultChat]. ExposecapabilitiesasIObservable<ISessionCapabilities>(agent host derives it fromconnection.rootStateviaobservableFromEvent+derivedOptswithstructuralEquals; static providers useconstObservable), have consumers read.read(reader)/.get(), and re-apply the chat catalog from the lastSessionStatein anautorunon capability change. Do not fix this by firing_onDidChangeSessions— the active-session context autorun tracks the session's own observables, not the provider's session-list event. -
A managed/default editor tab must be re-ensured every sync, not opened once: the per-session editor working-set restore (
baseSessionLayoutController[B2]_applyWorkingSet) runs on session activation and is not docked-gated, so it reinstates the session's saved editor set and will close any tab not in it (e.g. a set persisted before a new tab existed). A controller that opens a default tab once (guarded by aSetof initialized sessions) therefore loses it permanently after the restore. Ensure managed tabs (the pinned Changes tab, the default File tab) idempotently on every sync (group.editors.some(e => e instanceof X)→ open if absent), exactly like the Changes tab — never with an open-onceSet. Also:IEditorService.openEditor(input, group)on a typedEditorInputbindsgroupto theoptionsparam (overload is(editor, options?, group?)); passopenEditor(input, undefined, group). -
A "keep the editor closed" rule must not react to the editor's visibility transition: the single-pane new-session rule (R1,
_registerSinglePaneNewSessionRules) must drive its hide off the session + active editor, never offonDidChangePartVisibility/aneditorVisibleObs.onWillOpenEditorreveals the editor before the opened file becomes the active editor, and toggling the detail panel off reveals the empty editor viasetAuxiliaryBarHidden— both fire the visibility event synchronously with the active editor still stale (managed empty tab). A rule that re-runs on that transition re-hides the editor the user just asked to show (file never appears; the whole side pane vanishes on detail-toggle). Hide only when the active editor is non-real content, read the current visibility untracked when deciding to hide, and block spurious width-based reveals at the source withsetSuppressDockedEditorRevealSync(true)rather than a visibility backstop. Refinement: dropping the visibility trigger entirely loses the backstop for automatic post-activation reveals (working-set restore, an editor left visible across a session switch), so the editor can appear in a fresh new-session view. Keep the visibility trigger but gate the hide on an explicit-reveal flag: the workbench records_editorRevealedExplicitly(set true only ononWillOpenEditorand the detail-toggle reveal, cleared on any hide and on the suppress false->true transition so a stale cross-session flag can't leak) and exposesisEditorRevealedExplicitly(); R1 re-hides only when the editor is visible and the reveal was not explicit. -
When the docked editor is hidden while the detail stays visible, clear the sidebar-grow snapshots: hiding the editor resizes the docked node down to the detail width (
_dockedAuxiliaryBarWidth). Any_editorSizeGrownForSidebarHide/_detailWidthGrownForSidebarHidesnapshot captured earlier (while the editor was visible and the sessions list was hidden) is now stale — restoring it when the sessions list is later shown re-inflates the node so the detail fills the whole side pane instead of the chat reclaiming the freed editor width.setEditorHidden(true)must drop both snapshots in the detail-still-visible branch. -
Overriding a workbench toolbar hover needs matching specificity: the core rule
.monaco-workbench .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover(and the:hoveroutline rule) sets the toolbar hover background/outline at ~6-7 class specificity. An action-item label that needs to override that hover (either to suppress it for a non-interactive label, or to re-skin it) must use an equal-or-higher-specificity selector (prefix.monaco-workbench ... .monaco-action-bar:not(.vertical) .action-item.<class> .action-label:hover), not a short.<class> .action-label:hoverthat loses the cascade. (The single-pane diff-stats pill was once suppressed this way while static; it is now a clickable action that opens the multi-file diff, so it keeps the standard--vscode-toolbar-hoverBackgroundhover.) -
A docked-detail editor must not reveal the editor area while the detail panel is already showing its content — model it as a base editor input the single-pane workbench recognises: the empty Files placeholder (
EmptyFileEditorInput) and the Changes multi-diff (SessionChangesEditorInput) surface their content in the docked detail panel (auxiliary bar). Activating one (closing a neighbouring tab so the workbench auto-opens the next editor viaeditorGroupView.doCloseActiveEditor→doOpenEditor, or clicking the tab) firesonWillOpenEditorunsuppressed and would otherwise reveal the hidden editor area. Both inputs extend the abstract baseDockedEditorInput(src/vs/sessions/common/dockedEditorInput.ts, extendsEditorInput). The baseWorkbench.revealEditorOnOpen(e)(theonWillOpenEditorhandler — a protected method, renamed from_handleWillOpenEditor) does the generic reveal;SinglePaneWorkbenchoverridesrevealEditorOnOpenand returns early (no reveal) whene.editor instanceof DockedEditorInput && partVisibility.auxiliaryBar && !partVisibility.editor— i.e. only when the detail panel is open and the editor area is closed — otherwise it callssuper.revealEditorOnOpen(e). So the docked-editor policy lives inSinglePaneWorkbench(the only workbench with a docked detail panel) via a proper type + the current part visibility, not a per-input marker, a contrib-registered predicate, or a remembered set. Note the condition means that when the detail panel is closed (whole side pane closed), opening a docked editor does reveal the editor area so its content is visible. Caveat: a deliberate open of the already-open Changes tab (session-header pillViewAllChangesAction) or a file diff (_openMultiFileDiffEditor) while the detail panel is open is still suppressed by this rule, so those must explicitly reveal viarevealEditorPartExplicitly()before opening (revealing before the open also avoids the multi-diff hanging while laid out in a hidden 0-size editor part). KeeprevealEditorOnOpena named protected method so it is unit-testable viaReflect.get(...prototype, ...). -
Gate single-pane editor-title layout/view actions on
MainEditorAreaVisibleContext; the Create Pull Request bar lives in the title bar, not the editor: single-pane (config.<DOCK_DETAIL_PANEL_SETTING>-gated) editor-title layout/view items (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal, the diff-view actions collapse/expand/toggle-inline/list-tree) must includeMainEditorAreaVisibleContextso they disappear when the editor content is closed. The Create Pull Request anchor (CHANGES_HEADER_ACTIONS_ID) is not an editor-title action: it is contributed toMenus.TitleBarSessionMenu(the sessions title bar's session-actions area) byChangesHeaderActionsActioninchangesViewActions.ts, gated onIsSessionsWindowContext+IsAuxiliaryWindowContext.toNegated()+config.<DOCK_DETAIL_PANEL_SETTING>+SessionHasChangesContext(independent of editor-area visibility), and itsChangesActionsBarview item is registered for(Menus.TitleBarSessionMenu, CHANGES_HEADER_ACTIONS_ID)viaIActionViewItemService. The docked reveal-sync (_syncDockedEditorVisibility) must be symmetric: it reveals when the node widens past the detail width and hides (setspartVisibility.editor=false, flipsMainEditorAreaVisibleContext) when a sash drag squeezes the editor content back down to the detail width — same guards (_syncingDockedEditorVisibility,_suppressDockedEditorRevealSync,_dockDetailPanel, and only while the detail is visible). -
The managed Files placeholder tab is conditional, not always-on:
SinglePaneManagedTabsStrategyopens the empty Files placeholder (with the Changes tab) only when the editor group is empty on a view-open trigger (see the "add only when the group is empty" pitfall below), and removes it while a real (non-managed) file/diff is open in a visible editor area. It is not re-added when that file closes — the defaults return only when the group empties and the side pane is reopened. Drive the removal as a one-shot reaction ononWillOpenEditor(a genuinely new real-file open — skip re-activations wheregroup.contains(editor)is already true, non-main-part groups, and restore-driven opens), not a standingautorun/editor-change invariant (that would re-remove a Files tab the user just added via+). Close/open the placeholder undersuppressEditorPartAutoVisibility(). -
A per-session aux-bar (detail) capture must be skipped during a session-switch restore: the D2
onDidChangePartVisibilitylistener that records a created session's detail visibility must bail while_isRestoringSessionLayoutis true. During restore an external component (e.g.DetailPanelController) can transiently reveal the aux bar for the incoming active editor; capturing that overwrites the session's saved detail-hidden state, so switching back shows the detail even though the user had closed it. Keep the aux-bar sync work synchronous (fire-and-forget the view-open calls) so the restore epoch ends promptly and legitimate post-restore user toggles are still captured. -
The detail-panel forced reveal must be gated on the editor content being visible:
DetailPanelController._syncForcedTargetreveals the docked detail (aux bar) when a Changes/File editor becomes active while the detail is hidden. That reveal must additionally requireisVisible(EDITOR_PART, mainWindow)— otherwise, on a window reload where the user had closed the whole side pane (editor + detail both hidden, persisted), the restored managed tab becoming active re-reveals the detail and the closed state is lost. Reveal the detail only to accompany a visible editor; when the whole pane is intentionally closed, leave it closed. -
Auto-managed tabs stay user-closable via "add only when the group is empty" — not a dismissal set:
SinglePaneManagedTabsStrategyowns the managed Changes/Files docked tabs. They re-ensure on many signals (session state, editor visibility, editor changes), so naively re-creating them makes a close feel un-closable (they are non-previewpinEditor, NOT sticky — they do have close buttons; the blocker is the re-ensure). The clean rule that needs no_dismissedManagedTabsbookkeeping: open the default tabs only when the editor group is completely empty (group.editors.length === 0), and only on a "view opened" trigger — a session switch (the add-allowed session-state autorun) or the layout service'sonDidRevealSidePaneevent (fired by the workbench whenever the docked editor part and/or aux-bar detail transitions from fully hidden to visible). A plain editor-list / visibility change reconciles (e.g. removes the Files placeholder while a real file is open) but is add-disallowed. Why this is close-respecting for free: closing one managed tab while another (or a real file) remains leaves the group non-empty → not re-added; closing the last one closes the side pane → reopening it (empty group) restores the defaults. Opening a file firesonDidRevealSidePanetoo, but the sync is deferred on the docked-tab sequencer (which runs afteronWillOpenEditorhas added the editor), so the group is non-empty when it runs → defaults are not forced back → closing that file still closes the side pane. The add-disallowed editor-change trigger is essential: without it, closing the last tab (group empty) would immediately re-add the defaults and the pane could never close. The layout-driven add is done on the settled restore, not during it: the base controller firesonDidEndSessionLayoutRestorewhen the restore depth returns to 0 (after the — possibly async — working-set apply completes), exposed viaISinglePaneLayoutContext; the strategy reconciles off that ([Trigger D],openDefaultsIfEmpty: true). This is required for a new session, whose empty working set closes the previous session's docked tabs after the switch — reading the group during the async apply (an editor-change trigger) races the empty state and drops the Files tab; reconciling on the settled restore-end reads the reliably-empty group. Do not gate the add onisRestoringSessionLayoutcaptured in the editor-change autorun — that fires mid-apply and is fragile. A user file-open/close is not a restore, so it stays add-disallowed and a close still sticks. One exception — new-session submit: when the active session transitionsisCreatedfalse → true (in place, or via a resource-replace commit), the new-session view already holds the Files placeholder, so the empty-group rule would skip opening Changes; the submit transition is treated as a one-shot "ensure the Changes tab (pinned first, opened active)" even when the group is non-empty — opening it active (notinactive) is what makes the detail panel map to the Changes container rather than the still-present Files placeholder; it is a genuine one-time transition, so it never fights a later user close. Because submit fires two triggers (the session-state autorun'sensureChangesActiveand, via the submit restore,onDidEndSessionLayoutRestore's Trigger D), a single shared generation counter would let the later trigger's reconcile supersede and drop the earlier's intent — so the triggers' intents are accumulated (mergeTriggers, OR-combined into a pending trigger consumed by the surviving reconcile, re-merged infinallyif superseded mid-run) rather than replaced. Scope the pending intents to the session they were queued for (IPendingReconcile.sessionKey= the active session resource): a reconcile can be superseded mid-await(e.g. it stalls opening the Changes editor) by a session switch; if the superseded reconcile'sfinallymerged its old trigger back unconditionally, anensureChangesActive/ensureAllInputsintent for session A would leak onto session B and reopen a user-closed tab or activate Changes for the wrong session. Merge back (and accumulate on queue) only when the successor targets the samesessionKey; a session switch drops the previous session's stale intents. Second exception — a details-only reveal: whenonDidRevealSidePanefires with the aux-bar detail panel visible but the editor area hidden (isVisible(AUXILIARYBAR_PART) && !isVisible(EDITOR_PART)), the docked details panel shows the managed docked inputs, so they are ensured (Changes if created + Files) even when the group is non-empty — restoring one the user had closed earlier. This is tied to the reveal gesture (a close within an open details view still sticks until the next reveal); an editor-included reveal keeps the strict empty-group rule. Do NOT re-introduce a_dismissedManagedTabsset, anonDidCloseEditordismissal listener, infer the reopen from aux-bar visibility, or gate on a generic "side pane became visible (editor || aux)" check. The empty Files placeholder is tidied away when a real workspace file opens — a one-shot reaction ononWillOpenEditor(a realfile/vscode-remoteinput, skipped during a restore), not a standing "no placeholder while a real file is open" invariant enforced every reconcile. The standing invariant broke+Files: adding the placeholder while a file was open re-triggered the reconcile which immediately removed it again. Because+Files opens anEmptyFileEditorInput(not a real file), the one-shot listener ignores it, so a user-added Files tab survives while a real file is open (a tidy[Changes][file]strip still results from a real-file open). -
Aux-only managed inputs are a state invariant, not a reveal-time exception: whenever the Auxiliary Bar is visible and Editor is hidden, every managed-tab reconcile must re-read that current composition and ensure both Changes and the empty Files input, even in a non-empty group. Do not capture Aux-only state on
onDidRevealSidePane; queued work can run after the composition changes, and closing either managed input while Aux-only must restore it immediately. -
Observe single-pane part visibility as a signal when the composition matters: deriving
editorVisible || auxiliaryBarVisiblesuppresses Editor+Aux → Aux-only transitions because the derived boolean staystrue. Managed-tab reconciliation must react to every relevant part-visibility event, then read the settled Editor/Aux composition when queued work executes. -
Editor-area collapse (closing non-docked tabs) fires only on a detail-only hide, never when the whole side pane closes:
SinglePaneEditorAreaCollapseStrategyreacts to the editor part hiding by closing every non-docked editor (capturing reopenable ones, dropping non-restorable ones). It must gate that on the aux bar still being visible (isVisible(AUXILIARYBAR_PART)): a Detail-only hide (Hide Editor keeps the detail) collapses the editors, but closing the whole side pane (both editor + aux hidden) must leave the editors intact so they return when the pane is reopened. The gate is reliable because the two hide paths order theirsetPartHiddencalls consistently —toggleSidePanehides the aux bar before the editor, so when the editor-hidden event fires the aux is already hidden (⇒ skip collapse);Hide Editorsets the aux visible before hiding the editor (⇒ aux visible ⇒ collapse). Don't collapse purely off "editor part hidden" — that also dropped dirty/non-restorable editors when the user just closed the side pane. -
D10 (empty aux-bar cleanup) must gate on quick-chat, not the racy container-active check, or it flickers the side pane closed on reload: the Agents-window Changes/Files aux-bar views gate on
SessionHasWorkspaceContext+WorkspaceFolderCountContext, which are set asynchronously (via thesetActiveSessionContextKeysautorun reading the session's asyncworkspace) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar,isViewContainerActive(Files/Changes)is transientlyfalse(context keys not settled) even for a real workspace session. The D10 reconcile (_syncAuxiliaryBarPartVisibility, which runs synchronously on theonDidChangePartVisibility(visible)signal and only ever hides) then closes the just-opened side pane, and since it never re-reveals, it stays closed — the reload "side pane opens then closes" flicker, "Files not shown when opening the side pane", and "new-session side-pane state not remembered". Fix: D10 hides only when the aux is genuinely empty for the active session's lifetime — no active session, or a workspace-less quick chat (activeSession.isQuickChat?.get() === true, its Changes+Files permanently gated off) — never for a workspace-backed session whose gating context keys are merely still settling. Do NOT use the transient_hasActiveAuxViewContainers()result to hide a workspace session's aux. -
DetailPanelControllermust not hide the detail on an empty editor group in the new-session view:_computeTargetreturnsHiddenwhen the main editor part is empty (a created session's all-tabs-closed → whole side pane closed). But the new-session (uncreated) view's editor group is transiently empty while its Files tab is (re)ensured, and its Files detail is open by default and owned by the layout controller's D3b. Gating the empty-groupHiddenonactiveSession.isCreatedavoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequentcmd+nopen with the side pane hidden. Combined with the editor-visibility reveal gate, the new-session default stays open while a user's explicit hide is still remembered (D3b_newSessionViewState). -
Don't mirror an environment fact onto a shared widget API: an Agents-window check (e.g. "is this the sessions window?") belongs on
IWorkbenchEnvironmentService.isSessionsWindow, read directly by the consumer that needs it (e.g.getChatAccessibilityHelpProviderinchatAccessibilityHelp.tsinjecting the service itself). Do not add anisSessionsWindow-style property to a shared interface likeIChatWidgetjust to thread that fact through — it leaks a sessions-specific concept into shared workbench chat surfaces and every future consumer would need the same plumbing instead of injecting the service once. -
A view-lifecycle
setChat/setModel-style hook can be re-invoked for the same underlying resource:ChatView.setChatfires again on unrelated status/interactivity observable changes, not only on a genuine view swap. A consumer likeResponseSelectionSideChatControllerthat force-dismisses its own transient UI on every call discards an in-progress draft and, worse, clears a pending busy submission mid-flight, letting a duplicate submission race in. Compare the incoming resource against the previously tracked one and only treat a genuine change (or the first call) as dismiss-worthy; a same-resource re-invocation must preserve visible/busy state. -
A pending async submission's completion/error handler must no-op after a genuine force-dismiss, not just after a same-resource re-invocation: even with the same-resource guard above,
ResponseSelectionSideChatController._submit'screateAndSendSideChat().then()/.catch()can still settle after the user has genuinely navigated away (a different-resourcesetChat, or any other force-dismiss) — reopening the overlay, restoring the typed query, refocusing the input, or showing a stale error notification for UI the user already dismissed. Capture a_generationcounter bumped only on a genuine force-dismiss, snapshot it before the async call, and have the settle handlers bail when the counter no longer matches; don't rely solely on resource comparison, since the overlay's own dismissed state (not the chat identity) is what must gate the mutation.
Validating Changes
You must run these checks before declaring work complete:
npm run typecheck-client— TypeScript compilation check. Do not runtscdirectly.npm run valid-layers-check— MANDATORY. Catches layering violations. If this fails, fix the imports before proceeding.scripts/test.sh --grep <pattern>— unit tests for affected areas
Progress dashboards / local HTML in the integrated browser
- The integrated browser blocks
file://outside trusted workspace folders (403 "File does not reside within a trusted folder"). To preview session-folder HTML (e.g. a live progress dashboard), serve it overhttp://127.0.0.1:<port>with a backgroundpython3 -m http.serverand open that URL instead. - For a self-updating dashboard, embed
<meta http-equiv="refresh" content="5">and keep the HTML task data in lockstep with the todo store — update both at every task transition so they never drift.
Don't rely on the LLM to render links/actions from tool result text
-
Tool result text is fed to the model, which may drop or reformat markdown links (e.g. render a session URI as an inline code span), so an explicit
[label](uri)in a tool result is NOT a reliable way to give the user a clickable action. -
For a deterministic, client-rendered action (a "pill"/button) tied to a specific tool call, set
toolSpecificDataon theChatToolInvocationinstateToProgressAdapter.ts(keyed on the tool name + parsed result) and add a custom subpart inchatToolInvocationPart.ts— the completed-state section already routes customtoolSpecificDatakinds (seeresources/simpleToolInvocation). Follow theagentFeedbackReviewConfirmationpattern. -
Managed-tab reconciliation must run entirely under
suppressEditorPartAutoVisibility():SinglePaneManagedTabsStrategy._reconcileCorecloses stale/foreign managed tabs (e.g. a restored Changes tab whose session's workspace hasn't resolved yet on reload) via_closeForeignChangesEditorsbefore ensuring the current ones. If any close runs unsuppressed and empties the group, the workbenchhandleDidCloseEditordocked branch treats it as "user closed all tabs" and closes the whole side pane — the reload flicker where the side pane appears then vanishes. Wrap the full reconciliation body in one suppression window so transient empty states are never mistaken for a user action; don't rely on per-open suppressions alone. (Historical note: the managed Files placeholder was once removed/re-added reactively with a_dismissedManagedTabsset; that bookkeeping is gone — see the "add only when the group is empty" pitfall.) -
Layout-driven editor closes (working-set apply) must not be mistaken for user closes: On any single-pane session switch (incl. Cmd+N to a new untitled session and reload), the base controller applies the target session's editor working set — an empty working set closes the managed Changes/Files tabs externally. The workbench
handleDidCloseEditordocked branch would otherwise see the empty group and close the whole side pane (reload/Cmd+N flicker: pane/Files-tab appears then vanishes). Fix:_withSessionLayoutRestore(base controller) holdssuppressEditorPartAutoVisibility()for the whole (async) restore only whenisSinglePaneLayoutEnabled(OFF layout unchanged), so layout-driven closes never reachhandleDidCloseEditor.suppressEditorPartAutoVisibility()onIAgentWorkbenchLayoutServicereturns theIDisposablesuppression window; the boolean it toggles is observed internally through the Workbench's protected_isEditorPartAutoVisibilitySuppressedgetter. (Historical note: this fix also once protected a_dismissedManagedTabsset from being poisoned by external closes; that set no longer exists — managed tabs are now re-populated purely by "add only when the group is empty on a view-open trigger".) -
DetailPanelController must not force-reveal the detail during a layout-driven restore:
_syncForcedTargetreveals the aux-bar detail to accompany the active Changes/Files editor. On a session switch the target session's editor working set is restored, making its Changes/Files editor active — an editor change that is NOT a user open. If the user had hidden the detail for that session, that restore-driven editor change would force-reveal it, losing the per-session detail-hidden state. Gate the reveal (setPartHidden(false, AUXILIARYBAR)) on!isEditorPartAutoVisibilitySuppressed()(in addition to the existing editor-visible guard): during_withSessionLayoutRestorethe base controller holdssuppressEditorPartAutoVisibility()(single-pane), so a restore-driven forced target skips the reveal while a genuine user editor open (unsuppressed) still reveals. Note the existing[D3c/single-pane]test passed because it simulated the reveal synchronously during apply (so D3 re-hid last); the real bug is the async DetailPanelController reveal firing on the editor-change after D3 already hid. -
The width-based docked reveal-sync (
_syncEditorVisibility) must bail while editor-part auto-visibility is suppressed:SinglePaneWorkbench._syncEditorVisibilityreveals/hides the docked editor purely from the node width (for user sash drags). A session-switch / reload layout restore holdssuppressEditorPartAutoVisibilitywhile it applies the working set, which can widen the docked node before the controller has set the target editor-part visibility. Because the width-sync ran regardless of suppression, restoring a Detail-only session (aux open, editor closed) flickered the editor open on switch (the working-set apply widened the node → reveal → the controller then re-hid it) and could persist it open on reload. Gate_syncEditorVisibilityon!this._isEditorPartAutoVisibilitySuppressed(alongside the existing_syncingEditorVisibilityreentrancy guard) so only a real user sash drag (unsuppressed) drives width-based visibility. Relatedly,baseSessionLayoutController._applyWorkingSet'sisInitialRestorebranch must, for single-pane, apply_shouldHideEditorPartOnApply(editorPartHidden)after the working-set apply (a no-op for the classic layout) — otherwise a Detail-only session's persisted editor-hidden state is not re-applied on reload and the editor is left visible. -
Single-pane detail ownership is split cleanly between shared visibility and contextual content:
SinglePaneSidePaneVisibilityStrategypersists exactly two durable{ editorVisible, auxiliaryBarVisible }profiles (New and Existing), suppresses Quick Chat without storing it, and restores those profiles across navigation/reload.SinglePaneDetailPanelStrategymaps the active editor to Changes/Files and may transiently hide Aux for Browser/no-content states, but those effective hides must never overwrite the durable profiles. Do not reintroduce per-session aux state, saved-container machinery, or a third cleanup strategy. -
Single-pane is a sibling of the desktop controller and composes strategy objects — it does not extend
LayoutController:SinglePaneLayoutControllerextendsBaseLayoutControllerdirectly. Its current strategies areSinglePaneSidePaneVisibilityStrategy,SinglePaneDetailPanelStrategy,SinglePaneDetailsStrategy,SinglePaneManagedTabsStrategy, andSinglePaneEditorAreaCollapseStrategy; the docked-tab strategies shareSinglePaneDockedTabsCoordinator. Shared controller state is exposed throughISinglePaneLayoutContext. View-state strategies register immediately, while managed-tab/collapse strategies register afterLifecyclePhase.Restored. -
Single-pane detail/tab behaviour lives ON the layout controller (or its strategies), not in separate contribution controllers or a shared service:
SinglePaneLayoutControllerowns both the managed docked tabs (pinned Changes multi-diff + empty Files placeholder) and the detail-panel mapping (active editor → Changes/Files container, aux-bar reveal/hide). They were previouslyChangesTabController/DetailPanelController(registered by aSinglePaneModeControllercontribution) coordinating via globalIAgentWorkbenchLayoutServiceflags, then briefly via anISessionLayoutCoordinatorService. Both were removed: "is a session-switch restore in progress?" is just the base protected getterthis._isRestoringSessionLayout(set by_withSessionLayoutRestore) — surfaced to the strategies viaISinglePaneLayoutContext.isRestoringSessionLayout— so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The base controller hasIChangesViewService+IContextKeyServicedeps and a protected_editorGroupsService(a subclass can't add DI ctor params without redeclaring all base params, so shared services live on the base). Tests: the layout harness gotactiveGroupEditors/closeSuppressionFlags, a realmainPart.activeGroup, anactivateAuxopt-in that resolves the lifecycle, and aTestSinglePaneController.runWithRestore(...)seam to hold_isRestoringSessionLayoutacross an async editor change;changesTabController.test.tswas deleted and its scenarios moved intodesktopSessionLayoutController.test.ts. -
Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar).
SinglePaneDetailPanelStrategy._syncForcedDetailTargetreveals a hidden detail ONLY when it was temporarily hidden by a full-width editor (_hiddenByFullWidthEditor), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. Exception — opening the empty Files placeholder (EmptyFileEditorInput) reveals the Files detail (its content, the Files tree, lives in the aux bar). This is a dedicatedonDidActiveEditorChangelistener in the strategy that reveals the aux bar when the placeholder becomes the active editor — NOT reactive logic inside the detail autorun (which re-readsauxBarVisibleObs, so it would re-reveal the instant the user hides the detail — bug: "can't hide the details view in the empty file editor at all"). Keying on active editor (notonWillOpenEditor) is deliberate: the managed auto-ensured Files tab is opened inactive as a background tab (fileTabOptions), so it never becomes active and never reveals — preserving the Editor-only default — while the+Files action and selecting the Files tab both make it active and reveal. Do NOT reveal from theNewFileTabActioninstead: that misses tab-selection and other activation paths (tried and rejected — "does not work"). The listener is guarded byisVisible(EDITOR_PART)(don't reveal the detail alone while the whole side pane is closed, e.g. Scenario C reload) and!ctx.isRestoringSessionLayout(a restore-driven activation must not reveal). Because hiding the aux bar firesonDidChangePartVisibility, notonDidActiveEditorChange, the user's hide sticks while the placeholder stays active. Do NOT reintroduce aDetailPanelTarget.FilesRevealin the autorun or anisEditorPartAutoVisibilitySuppressed()layout-service API — the active-editor listener needs neither. The reopen default is workbench-layout policy via_defaultSidePaneState(SinglePaneWorkbench= editor-only); the controller only filters it against available content. When changing this, update the[single-pane] reveals the Files detail when the empty Files placeholder becomes activeand[Scenario C]tests together. -
Header overflow belongs before trailing layout controls, not promoted into the inline action strip: keep secondary menu items in the toolbar's
secondarygroup so they remain under.... Render trailing layout actions from a separate header menu after that toolbar, with a separator between the two action hosts; do not make secondary actions primary just to change the overflow button's position. -
A toolbar-host separator must follow rendered actions, not menu presence: a configured layout menu can have zero visible items after context-key evaluation. Enable the secondary toolbar's trailing separator from the layout toolbar's actual item count and rebuild it when that menu changes, or the header can retain an orphan separator.
-
Hide Editor and Toggle Details share visibility, but not enablement: when an editor supports docked details, keep both actions visible so the trailing layout group stays stable. Gate Hide Editor with
AuxiliaryBarVisibleContextas aprecondition, so hiding details disables the action instead of removing it. -
Code Review switches between right-inline and first-overflow placement: while
MainEditorAreaVisibleContextis true, contribute Run Code Review to the right header menu in the first primary group (0_codeReview). While false, contribute it tosecondary/1_codeReview, so it is first in...with a separator after it; keep both placements mutually exclusive and retain identical applicability gates. -
Bridge extension editor-title actions into the right header with standard semantics:
EditorTitleMenuBridgeContributionmirrors extension-ownedMenuId.EditorTitlecommands and submenus intoMenus.SessionsEditorHeaderSecondary. Mapnavigationto an inline extension group and every other group tosecondary/extension/<original-group>so non-navigation actions stay in overflow; forward the active editor's original URI as the menu argument. -
R1 (new-session editor hide) must be transition-triggered, not level-triggered on the active editor: hiding the editor in the new-session view must fire only when the editor just became visible (visibility false→true) or when the view was just entered with the editor already visible (inherited-visible editor) — never merely because the active editor changed to a managed placeholder while the editor is already visible. A level-triggered rule ("hide whenever active editor is non-real content and editor visible") wrongly hides the editor when the user switches to the Files tab with a file already open (the reveal-sync suppression re-arm clears
isEditorRevealedExplicitly, so the level rule then hides). TrackpreviousEditorVisible+previousInNewSessionViewin the autorun and hide only on(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly(). The two workbench methodssetSuppressDockedEditorRevealSync(blocks width-based reveals at the source, avoiding flicker) andisEditorRevealedExplicitly(distinguishes an explicit toggle-details-off/file-open reveal that must stick) are still required by R1 — they are independent of the ChangesTab/DetailPanel controller merge. -
Single-pane created sessions need the docked editor part revealed on switch — the
isModalgate in_applyWorkingSetskips it:baseSessionLayoutController._applyWorkingSetonly reveals the editor part when!isModal(i.e.workbench.editor.useModal !== 'all'), because in the classic layout editors open in a modal part. But in single-pane the docked editor lives in the grid even whenuseModalis'all'(the default), so that gate wrongly skips the reveal and a created session's side pane looks fully closed (worse once the Changes editor no longer force-reveals the detail). Fix: computerevealEditorPart = !editorPartHidden && !isInitialRestore && (isSinglePaneLayoutEnabled ? isCreatedSession : !isModal)and also reveal for the'empty'working-set case in single-pane (a first-visit created session has no saved editors but still shows its managed Changes editor). This restores the Editor-only default while respecting the per-sessioneditorPartHidden(Detail-only / side-pane-closed) state and excluding new-session views (R1 keeps their editor closed). Note the layout test harness leavesisSinglePaneLayoutEnabledfalsy by default, so base single-pane branches are inert in tests unless a test opts in via thesinglePaneLayoutEnabledcreate option. -
A draft replaced by a committed session must inherit the draft's side-pane layout before
_applyWorkingSetruns: some providers commit a new-session draft by firingonDidReplaceSessionwith a new session resource, not by flippingisCreatedon the same resource. Without transferring the active draft's_editorPartHiddenBySessionand aux-bar state, the committed resource has no saved layout, so the delayed B2 working-set apply treats it as a first-visit created session and reveals the editor (Editor-only default) even though the user submitted from the new-session Detail-only view. Handle the replacement event as D4 submit: copy the active draft's editor-hidden state to the committed resource, record Changes as the committed aux container, and open Changes only if the draft detail was visible; switching to an unrelated existing created session still uses the Editor-only default. -
Single-pane D3c: a created session with NO saved detail state must be left in its current on-screen state — never force-hidden: the detail (aux-bar) restore for a created single-pane session (
SinglePaneDetailVisibilityStrategy._syncDetailVisibilityD3c) must only act whenviewStateBySessionhas a saved entry — hide when it says hidden, reveal when it says visible. When there is no saved state (savedState === undefined), return without touching the aux bar. Force-hiding on the no-state path re-closes the detail the user had open in the new-session view on submit: the committed session's resource can change again after the initial draft→committed transition, so a later restore run lands in D3c with no saved state andpreviousIsCreatedalreadytrue(the intrinsic!previousIsCreated && isCreatedsubmit detection ([D4]) no longer matches), and would re-hide. Leaving the current state also covers a first-time-seen created session gracefully; the detail-panel strategy keeps the container in sync, and the visibility is captured on the next switch-away or user toggle. (The intrinsic [D4] submit routing to_onNewSessionSubmittedis still kept for the clean first transition — it records the state and opens Changes — but D3c-leave-current is the backstop for every follow-up run.) -
A replace-based submit must be detected intrinsically in the aux/detail restore autorun (
!previousIsCreated && isCreated), not via_onSessionReplaced: the same ordering trap as the editor reveal, but for the detail (aux-bar) visibility.sessionsServicelistens toonDidReplaceSessionfirst (it's a core service) and its handler callsupdateSession→ setsactiveSessionin a transaction → the single-paneSinglePaneDetailVisibilityStrategyD3 restore autorun fires synchronously inside that handler. The layout controller's_onSessionReplaced(registered later, atBlockRestore) runs after — so any aux-state transfer it does is too late: the autorun has already run D3c. The classic same-resourceisSubmitguard (!isSessionSwitch && !previousIsCreated && isCreated) misses this because the agent-host/Copilot provider commits by replacing the draft with a new resource (isSessionSwitchis true). Fix: relaxisSubmittopreviousSessionResource && !previousIsCreated && isCreated && !viewStateBySession.has(activeSessionResource)— detect the submit purely from the transition, independent of_onSessionReplacedordering. The!has(state)guard keeps a genuine navigation from a draft to an existing created session on the normal D3 restore path._onSessionReplacedthen only needs to cover the background submit (a session committed while a different session is active, so the autorun never fires for it). Because the committed resource can still change again after the first transition, this intrinsic detection alone isn't enough — pair it with the D3c-leave-current rule above. General rule: for any "on submit, preserve/transfer layout" logic, detect the submit from the reactive transition the consumer already observes — never from a flag/transfer set by a separately-registeredonDidReplaceSessionlistener. -
onDidReplaceSessionalways means submit — never re-checkfrom.status === Untitled, and never try to preserve visibility via a flag consumed byrunOnChange: two related traps when suppressing the docked-editor reveal on new-session submit. (1) By the timeonDidReplaceSessionfires, the draft has already transitionedUntitled→Completed, so a_isNewSessionReplacement(from,to)guard checkingfrom.status === SessionStatus.Untitledis always false and silently skips the whole editor-hidden/aux transfer. The event is documented to fire only when an untitled draft is atomically replaced by its committed session, so treat everyonDidReplaceSessionas a submit — no status guard. (2) The B2 working-setrunOnChange(on the workspace-gatedactiveSessionForWorkingSetderive) fires before the synchronousonDidReplaceSessionhandler, so a boolean flag set in_onSessionReplacedand read synchronously inrunOnChangeis captured stale (false) and cannot suppress the reveal. The correct, ordering-robust mechanism is to have_onSessionReplacedwrite the draft's live editor-part visibility into_editorPartHiddenBySession[to]synchronously; because_applyWorkingSetreads that map inside itsSequencer.queueasync microtask body (which runs after the sync replace handler), the reveal decision (_shouldRevealEditorPartOnApply/_shouldRevealEditorPartForEmptyWorkingSet) seeseditorPartHidden=trueand skips. Do NOT add apreserveEditorPartVisibilityapply option keyed off event ordering — it's impossible to set in time. -
R1 can drop
setSuppressDockedEditorRevealSync— always hide on new-session-view entry instead: the width-based reveal-sync suppression (setSuppressDockedEditorRevealSync/_suppressDockedEditorRevealSync) was removed. It did two jobs: (1) block a momentary width-reveal of the editor in the new-session view, and (2) clear_editorRevealedExplicitlyon entering the view so R1 re-hides an inherited-explicit editor across a session switch (the working-set apply runs undersuppressEditorPartAutoVisibility, sohandleDidCloseEditordoesn't clear the flag naturally). Job (1) is now handled by R1 re-hiding any non-explicit reveal (a sash-drag reveal flickers then re-hides — acceptable). Job (2) is handled by making R1's hide conditionjustEnteredNewSessionView || (editorJustRevealed && !isEditorRevealedExplicitly())— i.e. entering the new-session view always resets to editor-closed (a stale cross-session explicit flag can't keep the editor open), while the explicit flag is only honored for in-session reveals (toggle-details-off revealing the empty editor).isEditorRevealedExplicitlyis still needed for that in-session case. -
Quick chats have no side pane — don't auto-reveal the editor part, and hide it when switching in from a session that had it open: in single-pane,
SinglePaneLayoutController._shouldRevealEditorPartOnApplymust exclude quick chats (!editorPartHidden && isCreatedSession && !isQuickChat); a created quick chat would otherwise reveal the docked editor part on switch (bug: "side pane opened automatically for quick chat"). Excluding the reveal is not enough — switching in from a workspace session leaves the editor part visible (the working-set apply is suppressed and never hides it), so a dedicated_registerQuickChatEditorHide()autorun hides the editor part while a quick chat's editor group is empty (gated on_isMainPartEmpty()so a real editor, e.g. the integrated browser, opened in a quick chat is never hidden). The aux bar is already handled by D10 + the detail-panelHiddentarget. -
Single-pane per-session editor-part visibility must be restored both ways —
_applyWorkingSetonly ever revealed it:baseSessionLayoutController._applyWorkingSetrevealed the editor part when a session wanted it visible but never hid it, so returning to a session whose docked editor was closed (Detail-only or whole side pane closed) left the editor visible/inherited from the previously-active session (bug: "side pane opened when returning to a session where it was closed"). The per-session_editorPartHiddenBySessionstate was only consumed to suppress the reveal (!editorPartHidden), never to actively hide. Fix: add a symmetric Template-Method hook_shouldHideEditorPartOnApply(editorPartHidden)(base returnsfalse— classic layout doesn't treat editor-part visibility as per-session; single-pane returnseditorPartHidden && isCreated && !isQuickChat) and, in both the empty and non-empty_applyWorkingSetbranches, hide the editor part (mutually exclusive with revealing, skipped onisInitialRestorewhich preserves the workbench-restored visibility). The hide runs inside_withSessionLayoutRestore'ssuppressEditorPartAutoVisibilitywindow so it is never mistaken for a user close. Note the aux bar was already restored both ways by the inherited D3_syncAuxiliaryBarVisibility; only the editor part lacked the hide. -
Explicit managed-editor opens must reveal outside the auto-reveal path — and mark the reveal explicit: docked-detail Changes/Files editors (
DockedEditorInput) are kept from revealing the docked editor bySinglePaneWorkbench.revealEditorOnOpen(see the entry above), so tab activation and layout-driven restores do not reveal it. A deliberate user gesture that should show managed editor content (session-header Changes pillViewAllChangesAction, opening a file diff in_openMultiFileDiffEditor) must reveal the editor part before opening the managed editor viaIAgentWorkbenchLayoutService.revealEditorPartExplicitly()— not the genericsetPartHidden(false, EDITOR_PART). The generic call routes tosetEditorHidden(hidden, explicit=false), leaving_editorRevealedExplicitly = false, so R1 / the working-set apply (_shouldHideEditorPartOnApply) can re-hide it (especially across a session-switch race).revealEditorPartExplicitly()sets the explicit flag (and re-asserts it even when already visible, sincesetEditorHiddenearly-returns when the part is already visible). Do not weaken theDockedEditorInputreveal suppression or add timing delays. -
Per-session editor-part (side-pane) hidden state must be captured eagerly on the visibility change, not lazily re-read at switch-away:
baseSessionLayoutController._saveWorkingSetused to record_editorPartHiddenBySession[prev] = !isVisible(EDITOR_PART)at the moment it saved the outgoing session. That races: the working-set derive (activeSessionForWorkingSet) lags the rawactiveSession(it gates on workspace-folder readiness), so other autoruns driven by the raw active session (managed-tab open, D3 aux sync) have already revealed the editor for the incoming session by the time_saveWorkingSet(prev)runs — so the previous session gets recorded aseditorPartHidden=falseand its closed side pane reopens on return (symptom: only the editor content re-appears, details stay closed, and nosetEditorHiddenfires on the switch because nothing on the switch path toggles it). Fix: capture it in a[B2]onDidChangePartVisibility(EDITOR_PART)listener (mirroring the existing[B1]panel-visibility capture) guarded by!multipleSessionsVisibleObs && !_isRestoringSessionLayout, so the value is written the instant the user closes/opens the side pane and layout-driven restore changes are ignored. Remove the lazy read from_saveWorkingSetentirely (keeping it would let the racy switch-time value overwrite the good eager one). The unit harness can't reproduce the derive-lag, so add a focused test that fires the EDITOR_PART event to assert eager capture, plus one that fires a reveal inside_withSessionLayoutRestoreto assert the captured closed state is preserved. -
Single-pane detail sync must re-read aux-bar visibility when queued work runs: the detail-panel autorun queues container opens through a sequencer, so a task can be computed while the previous session's detail is visible and run after D3 has restored the incoming session's detail to hidden. Do not trust an
auxBarVisiblevalue captured before the queue boundary; readisVisible(AUXILIARYBAR_PART)inside the queued sync, otherwiseopenViewContainercan re-reveal the detail and overwrite the incoming session's saved hidden state. -
The managed Changes tab must open non-stealing so the working-set-restored active editor is preserved: on a single-pane session switch,
baseSessionLayoutController._applyWorkingSetrestores the session's editor working set including which editor was active (e.g.package.json).SinglePaneManagedTabsStrategythen idempotently re-ensures the pinned Changes tab — but ifchangesEditorOptionsopens it as active (noinactive/activation), it steals active state from the just-restored editor, so the wrong tab is active after the switch. GivechangesEditorOptionsinactive: true+activation: EditorActivation.PRESERVE(matchingfileTabOptions); the workbench still makes it active when the group is empty (active: this.count === 0 || !options?.inactive,editorGroupView.doOpenEditor), so the first-visit created-session default (Changes active) is preserved while a restored session keeps its own active editor. -
Save the outgoing session's working set eagerly on the raw active session change, not on the workspace-gated
activeSessionForWorkingSetderive: the derive (baseSessionLayoutController) holds back while the incoming session's workspace folders resolve, and other autoruns driven by the rawISessionsService.activeSession(e.g. the single-pane managed-tabs sync) async-close the outgoing session's docked editors (_closeInactiveChangesEditors) during that lag. If the working-set save is on the lagged derive it runs after those closes, so the outgoing session's Changes tab (or whichever editor was active) is already gone and its active state is lost — on return the working set restores the wrong active editor (symptom: switching back to a session whose Changes tab was active shows a different tab active). Fix: a dedicated[B2] runOnChange(activeSession, …)saves the previous session's working set synchronously (guarded by resource-inequality,!Untitled,!_isRestoringSessionLayout) — this runs before the managed-tab sequencer microtask closes anything — and the save is removed from the gated applyrunOnChange(which now only applies). Save doesn't need workspace readiness; only apply does. Pairs with making the managed Changes tab open non-stealing (inactive: true+EditorActivation.PRESERVE) so the restored active editor is preserved. The unit harness can't reproduce the derive-lag directly, so assert the decoupling: switching to a session whose workspace isn't in the folders (gated apply holds back) still records asaveWorkingSetfor the outgoing session. -
Docked side-pane width persistence must be symmetric about the detail (aux) width: in single-pane the docked detail (auxiliary bar) lives inside the editor grid node, so the workbench persists the pure editor-content width (
_persistedEditorWidth= node − detail) and the grid descriptor reconstructs node = editor-content + detail. These must use the same condition for including the detail: only when the detail is visible (partVisibility.auxiliaryBar). Subtracting the detail width unconditionally at save while adding it back only when the detail is visible shrank an Editor-only session's side pane by the detail width on every reload, compounding toward zero ("side pane always tiny on reload"). Fix_persistedEditorWidthto subtract only when the detail is visible. -
Side-pane (editor grid node) size is workbench-level, not per session: the editor grid node width is owned by the workbench grid and persisted globally (
workbench.sessions.partSizesvia_savePartSizes/createDesktopGridDescriptor), so switching sessions keeps the same width and reload restores it in one paint. Do not add a per-session width map in the layout controller that re-applies a width on session switch/reveal — it makes switching sessions jump the side pane around, and a post-paint restore on reload flickers. -
Express equal sibling sizing through the grid, not width arithmetic in a part: reveal the side pane with
SerializableGrid.setViewVisible(editorPartView, true, Sizing.Distribute). The visibility operation already knows the view location and can distribute its containing split; do not add a separate distribution API that requires passing the same view again. Do not compute percentages from the window/sidebar or expose a fixedpreferredWidth; those duplicate grid geometry and make the relationship less descriptive. -
Single-pane must not smart-hide the Sessions sidebar: opening or closing Details and opening a real editor leave the sidebar untouched at every window width. The sidebar is an explicit user-controlled surface in this layout; keep responsive auto-collapse confined to the classic desktop controller.
-
Single-pane side-pane visibility is shared by lifecycle type, not individual session: persist independent
{ editorVisible, auxiliaryBarVisible }profiles for New Sessions and Existing Sessions. Ordinary type navigation captures outgoing and applies incoming; submit instead preserves the on-screen composition and seeds Existing from it to avoid a jump. Let quick chats suppress the pane without overwriting either profile. -
Submit activates Changes only when changes exist: record a per-session pending Changes activation on the New→Existing transition, keep Files active while the submitted session has no file changes, then activate Changes when
session.changesbecomes non-empty. The activation must remain scoped to that session and must not reveal Editor. -
Aux-only width belongs to layout, not lifecycle visibility profiles:
DockedAuxiliaryBarController.DEFAULT_WIDTH(300px) is only the no-history initial value. Once the user drags the detail sash,_dockedAuxiliaryBarWidthis directly authoritative for Editor-hide, Aux-show, session switches, and reload, including values below 300px. Do not wrap a direct property return in a helper, floor transitions back to the default, or persist width in New/Existing visibility profiles. -
Core editor restoration must not reveal Editor before the single-pane workbench is restored: restored tabs fire
onWillOpenEditorduring startup before lifecycle/profile restoration settles. InSinglePaneWorkbench.revealEditorOnOpen, if!isRestored()and Editor is persisted hidden, return without revealing; after restore, normal user opens still reveal. Otherwise an Aux-only reload paints Editor briefly and the visibility profile closes it one frame later. -
Do not add a content-readiness handshake between the single-pane workbench and detail strategy: the layout node's visibility must not depend on
openViewContainercompletion or amarkSidePaneContentReady-style API. Keep layout visibility and content restoration independently owned; solve startup presentation issues without coupling the workbench grid to asynchronous feature content. -
Persisted side-pane composition must not depend on tabs already existing: do not auto-hide Editor from
onDidRevealSidePanemerely because the editor group is empty during restore. Render the persisted Editor/Aux composition and let managed tabs restore afterward; the normal last-editor-close handler remains responsible for closing the pane after an explicit user close. -
Apply a side-pane composition by hiding undesired parts before showing desired parts: transitioning Editor-only → Aux-only must hide Editor first, then show Aux. Showing Aux first creates a transient Editor+Aux composition that is visible during New Session restoration; the inverse transition similarly hides Aux before showing Editor. Keep the sequence inside editor-auto-visibility suppression.
-
Same-type session navigation still restores the shared lifecycle profile: Existing→Existing and New→New resource switches can transiently empty the editor group and hide Aux while working sets/tabs restore. Capture the one outgoing type profile, reapply it on every resource switch, ignore transient Aux hides, and reassert an aux-visible profile when content returns. Do not create per-session view-state entries.
-
A draft submitted while Quick Chat is active must not seed profiles from the suppressed layout: Quick Chat intentionally hides both parts. Exclude transitions spanning Quick Chat from foreground-submit detection; when leaving Quick Chat, apply the already-saved Existing profile rather than capturing the hidden effective state into New and Existing.
-
No active session during startup means preserve detail layout, not hide it:
SinglePaneDetailPanelStrategy._computeDetailTargetruns after the workbench reaches Restored but beforerestoreVisibleSessions()finishes. ReturningHiddenthere closes persisted Aux;SinglePaneWorkbench._onWillHideAuxiliaryBarthen reveals Editor, producing the reload flicker. ReturnPreserveuntil an active session exists; quick chats and real empty-session targets can hide once their identity is known. -
Restoring an Editor-visible profile from Quick Chat must not reveal Editor into a fully closed empty group:
onDidRevealSidePanenormally hides Editor while the group is empty, which races managed-tab recreation and leaves the pane closed. Skip that empty-pane guard while_isRestoringSessionLayout; do not stage the restore through a temporary auxiliary-bar reveal, because restoring saved editor-content width while Details is temporarily visible adds the detail width to the grid node and leaves the side pane wider after Details is hidden. -
An aux-visible profile restored from Quick Chat must survive the transient empty editor group: reveal the aux bar before Editor so saved editor-content width is reconstructed with the detail width, then keep an aux-restore pending while the main group is empty. Reassert aux visibility on the deterministic editor-group content signal; the detail strategy may hide it after the synchronous restore but before managed tabs settle.
-
Entering Quick Chat must hide Editor before the docked aux bar:
_persistedEditorWidthsubtracts the detail width only whilepartVisibility.auxiliaryBaris true. Hiding aux first makes the subsequent Editor hide persist the combined editor+detail node width as pure editor width, so returning to an aux-visible Existing Session adds the detail width again and grows the side pane. -
Quick Chat activates before the outgoing editor working set is cleared: do not wait for
mainPartEmptyon the initial Quick Chat transition before hiding Editor. Hide Editor immediately (then Aux) on entry so width capture sees Details visible; only subsequent Quick Chat re-evaluations use the empty-group guard, preserving a real editor deliberately opened inside Quick Chat. -
Per-session menu visibility must flow through scoped context keys, not widget DOM hiding or global active-session keys: toolbars evaluate
whenagainst their scopedIContextKeyService, while their pickers act on scopedISessionContext. Expose provider-specific availability as an observable onISessionand publish it throughsetSessionContextKeys, so every visible session surface evaluates the same scoped state declaratively. -
The fixed Chats/Subagents picker does not need a group registry: contribute scoped chat actions to
Menus.SessionConversationswith stable group ids, then map those ids to local presentation metadata inSessionConversationsActionViewItem. Add a registry only when external/dynamic group extensibility is a real requirement. -
An active subagent inherits its parent conversation's picker scope: use
activeChat.origin.parentChatfor a tool-origin active chat, otherwiseactiveChat.resource, when filtering the flat Subagents group. Keep this local expression aligned withSessionActiveChatHasSubagentsContext; do not export a one-line helper from the session model. -
The Chats dropdown opens on the active conversation: pass a dynamic
getInitialFocusActionIdto the Action Widget dropdown. The active chat or subagent is an actual flat row and must be focused directly; do not mark rows checked merely to obtain initial focus. -
The Chats picker must explain grouping and progress (#329176) without repeating quiet defaults: the existing Subagents group title names the separation, while the redundant top-level Chats header stays hidden. Every row uses the existing Sessions status icon; show text for New/In Progress/Input Needed/Failed, keep Completed visually quiet, and always include an accessible State: status description.
-
A single first-level chat is omitted from the Chats dropdown: show only its active-chat Subagents group. Side chats count as first-level chats, so their presence retains the first-level list.
-
Action Widget checkbox semantics come from
checked, not a parallel flag: an explicitchecked: true/falseis a checkable item; omittedcheckedis ordinary navigation. Do not add anisCheckableopt-out—the duplicate state can drift and is unnecessary for chat/subagent actions. -
Do not add Action List header ARIA-label branches when headers retain separator semantics: title-only group headers already render visually and are exposed as separators by the accessibility provider. Add custom header labels only when the header role/semantics intentionally change.
-
Custom Sessions toolbar renderers use
IActionViewItemServiceregistration: register submenu renderers by(owning menu, submenu id)and letMenuWorkbenchToolBarresolve them before its generic fallback. Do not hardcode a Sessions-specific submenu check inside a part'sactionViewItemProvider. -
Action Widget navigation items must omit ARIA checked state: when an action's
checkedisundefined,IListAccessibilityProvider.isCheckedmust also returnundefined, notfalse; otherwise amenuitemincorrectly receivesaria-checked="false". -
Action List initial focus is one-shot: consume
initialFocusItemIdduring initial opening, then retain current focus or use the normal checked/first fallback. Never reapply the configured ID during filtering or laterfocus()calls, which would override user navigation. -
Explicit resource scopes must carry their owner and announce eligibility changes: never bind a plan/file to the globally active session because multiple sessions can render concurrently. Pass the owning session resource into registration and fire the scope-change event when the binding is added or removed so already-open editors refresh.
-
Per-editor overlay actions must carry editor command context: an overlay can be invoked from an inactive split group, so resolving commands from the globally active editor can mutate the wrong resource. Set the toolbar context from its owning group and resolve it through
resolveCommandsContext. -
A single-file diff layout toggle must target the resource-owning live control and disable adaptive inline fallback: the configured
renderSideBySidevalue can betruewhileuseInlineViewWhenSpaceIsLimitedkeeps the actual editor inline, making a setting-only toggle appear broken. Match the forwarded resource across visibleTextDiffEditorpanes (or use the active pane when no resource is forwarded), persist its modified model resource, apply bothrenderSideBySideanduseInlineViewWhenSpaceIsLimited: false, and derive the menu state fromdiffEditorInlineModerather than configuration. -
Single-file diff input identity and renderer capability need separate context keys: a
DiffEditorInputcan render through text, binary, or custom editor panes, so actions that apply to the owning Changes detail must gate on the input while text-layout actions gate onTextCompareEditorActiveContext. Reusing the text-pane key for both silently drops input-level actions on binary/custom fallbacks. -
Extension-host webview identity lives in
providerId, not onlyviewType: dynamic webview panels use an internal prefixedviewType(for examplemainThreadWebview-markdown.preview) and keep the contributed id inWebviewInput.providerId. Classify contributed webviews usingproviderIdas well as direct/internalviewType, and model both fields in tests. -
Keep validation proportional to the change: once focused tests cover a small correction, do not chain repeated reviewers or broad suites unless a concrete failure warrants escalation.
-
Use one side-pane state type and keep the default on the workbench layout:
ISidePaneStatecontains{ editor, auxiliaryBar }; overall visibility is derived when needed.Workbench.toggleSidePane()remembers and restores raw editor/aux visibility; when no state was previously captured it uses_defaultSidePaneState(base = Editor+Aux,SinglePaneWorkbench= Editor-only).BaseLayoutControllerlistens on did only to filter the restored/default state against available editors and aux containers; do not reintroduce a controller default hook. -
Keep the side-pane toggle lifecycle in one method:
Workbench.toggleSidePane()owns will-event emission, un-maximize preparation, visibility memory/transition, did-event emission, focus transfer, and the final visibility return. Do not split this linear lifecycle into_runSidePaneToggle/_toggleSidePanehelpers; the ordering is easier to audit when it remains contiguous. -
A side-pane toggle needs one explicit settled reveal, with natural per-part reveals suppressed:
setEditorHidden(false)/setAuxiliaryBarHidden(false)naturally notify on the first visible part, which is too early for listeners that must inspect/filter the full restored composition. The toggle passesskipSidePaneReveal: trueexplicitly to both reveal setters; after all parts are applied,toggleSidePane()invokes_onSidePaneRevealed()once. Avoid hidden toggle-state guards in the reveal hook. Keep exact-once test coverage so the explicit call cannot become a duplicate. -
Remembered and default side-pane compositions must share the same editor/aux-only type: type
_sidePaneStateBeforeHideand_defaultSidePaneStateas the sameISidePaneStateeditor/aux composition. Overall visibility is derived and must not be redundantly stored. -
Expose completed side-pane state on did-toggle, not will-toggle:
onWillToggleSidePaneremainsEvent<void>for pre-transition suppression.onDidToggleSidePanecarries{ before, after }, each with{ editor, auxiliaryBar }, so consumers can distinguish collapse versus reopen and preserve pre-toggle aux visibility without side-channel fields. -
Do not record a side-pane collapse from the will event: will fires before the UI transition and only establishes
_togglingSidePane. Record from the did event after verifyingbeforewas visible andafteris hidden; the did payload preserves the pre-toggle aux visibility needed for collapse semantics.