diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 5d1beb94889..efc9a0d40df 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -9,6 +9,11 @@ "version": "1.5.0", "resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c", "integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" } } } \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 947527eb4c9..874a814fbbf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ }, "features": { "ghcr.io/devcontainers/features/desktop-lite:": {}, - "ghcr.io/devcontainers/features/rust:": {} + "ghcr.io/devcontainers/features/rust:": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "containerEnv": { "DISPLAY": "" // Allow the Dev Containers extension to set DISPLAY, post-create.sh will add it back in ~/.bashrc and ~/.zshrc if not set. diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 57d9aefef5d..ae38896d85c 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -33,6 +33,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`). - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. - **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. +- **Grid `onDidChange` is not a sash-drag signal**: the workbench `SerializableGrid`/`GridView` `onDidChange` fires 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's `layout(width, ...)` callback, which receives the in-progress node width. - **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 in `openSession` and consumed by a `shouldX()` 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 an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — 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` / `setAgent` must push the selection into the loaded `IChatModel.inputModel` (like `_updateChatSessionState`) and let the draft-sync debounce emit `chat/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 like `LifecyclePhase.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. @@ -75,6 +76,30 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru - **`ISession.capabilities` must 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 first `SessionState`). A plain getter cannot be tracked by the context-key autorun (`setActiveSessionContextKeys` reads it inside an `autorun`), so `supportsMultipleChats`/`sessionSupportsFork` would stay stale, and a multi-chat catalog processed while `supportsMultipleChats` was still `false` would stay collapsed to `[defaultChat]`. Expose `capabilities` as `IObservable` (agent host derives it from `connection.rootState` via `observableFromEvent` + `derivedOpts` with `structuralEquals`; static providers use `constObservable`), have consumers read `.read(reader)`/`.get()`, and re-apply the chat catalog from the last `SessionState` in an `autorun` on 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 a `Set` of 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-once `Set`. Also: `IEditorService.openEditor(input, group)` on a typed `EditorInput` binds `group` to the `options` param (overload is `(editor, options?, group?)`); pass `openEditor(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 off `onDidChangePartVisibility`/an `editorVisibleObs`. `onWillOpenEditor` reveals the editor *before* the opened file becomes the active editor, and toggling the detail panel off reveals the empty editor via `setAuxiliaryBarHidden` — 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 with `setSuppressDockedEditorRevealSync(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 on `onWillOpenEditor` and 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 exposes `isEditorRevealedExplicitly()`; 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`/`_detailWidthGrownForSidebarHide` snapshot 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 `:hover` outline rule) sets the toolbar hover background/outline at ~6-7 class specificity. A static (non-interactive) action-item label (e.g. the single-pane diff-stats label) that should show no hover affordance must override with an equal-or-higher-specificity selector (prefix `.monaco-workbench ... .monaco-action-bar:not(.vertical) .action-item. .action-label:hover`), not a short `. .action-label:hover` that loses the cascade. + +- **A managed placeholder editor must never reveal the docked editor content**: the empty Files placeholder tab (`EmptyFileEditorInput`) activates as a *side effect* of closing another tab (e.g. the Changes tab), firing `onWillOpenEditor` before it is in the group model. The workbench `onWillOpenEditor` reveal handler must skip revealing for that placeholder (check `e.editor.typeId === 'workbench.editors.agentSessions.emptyFile'` — a literal, since `src/vs/sessions/browser/*` is core and must not import the contrib `EmptyFileEditorInput`). Do NOT use a broad "editor already in group -> skip" rule: that also stops a hidden editor from revealing when the user re-opens an already-open real file. Extract the handler into a named method (`_handleWillOpenEditor`) so it is unit-testable via `Reflect.get(Workbench.prototype, ...)`. + +- **Gate single-pane editor-title actions on `MainEditorAreaVisibleContext`**: every single-pane (`config.`-gated) editor-title menu item (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal) must include `MainEditorAreaVisibleContext` in its `when` so the whole action set disappears when the editor content is closed — the docked tab bar stays but its right-hand actions do not. The docked reveal-sync (`_syncDockedEditorVisibility`) must be *symmetric*: it reveals when the node widens past the detail width and **hides** (sets `partVisibility.editor=false`, flips `MainEditorAreaVisibleContext`) 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**: `ChangesTabController` shows the empty Files tab only when the editor area is closed OR no real (non-managed) editor is open; once a real file/diff is open in a visible editor area it removes the placeholder (and re-adds it when the area closes). Drive this off an `autorun` that also reads the editor-area visibility (`observableFromEvent(onDidChangePartVisibility, () => isVisible(EDITOR_PART, mainWindow))`) and an editor-change signal (`observableSignalFromEvent(this, Event.any(onDidActiveEditorChange, onDidEditorsChange))`), and keep the ensure/remove idempotent so it settles instead of looping. Close/open the placeholder under `suppressEditorPartAutoVisibility()`. + +- **A per-session aux-bar (detail) capture must be skipped during a session-switch restore**: the D2 `onDidChangePartVisibility` listener that records a created session's detail visibility must bail while `_isRestoringSessionLayout` is 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._syncForcedTarget` reveals the docked detail (aux bar) when a Changes/File editor becomes active while the detail is hidden. That reveal must additionally require `isVisible(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 must still be user-closable — remember user dismissals instead of blindly re-ensuring**: `ChangesTabController` re-ensures the managed Changes/Files tabs on many signals (session state, editor visibility, editor changes). Without care this re-creates a tab the instant the user closes it, so the tab feels un-closable (the managed tabs are non-preview `pinEditor`, NOT sticky — they *do* have close buttons; the blocker is the re-ensure, not a missing button). Track user-initiated closes in a `_dismissedManagedTabs` set (via `onDidCloseEditor`, ignoring the controller's own closes tracked in an `_internallyClosingEditors` set) and skip ensuring a dismissed kind. Clear the set only on a **session change** or when the **side pane is reopened from fully closed** (edge-detected via a stored `_sidePaneWasVisible`), so closes stick within the session while reopening/switching re-populates (Scenario B). + +- **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 the `setActiveSessionContextKeys` autorun reading the session's async `workspace`) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar, `isViewContainerActive(Files/Changes)` is transiently `false` (context keys not settled) even for a real workspace session. The D10 reconcile (`_syncAuxiliaryBarPartVisibility`, which runs synchronously on the `onDidChangePartVisibility(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. + +- **`DetailPanelController` must not hide the detail on an empty editor group in the new-session view**: `_computeTarget` returns `Hidden` when 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-group `Hidden` on `activeSession.isCreated` avoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequent `cmd+n` open 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`). + ## Validating Changes You **must** run these checks before declaring work complete: @@ -82,3 +107,20 @@ You **must** run these checks before declaring work complete: 1. `npm run typecheck-client` — TypeScript compilation check. **Do not run `tsc` directly.** 2. `npm run valid-layers-check` — **MANDATORY.** Catches layering violations. If this fails, fix the imports before proceeding. 3. `scripts/test.sh --grep ` — unit tests for affected areas + +- **Managed-tab reconciliation must run entirely under `suppressEditorPartAutoVisibility()`**: `ChangesTabController._syncChangesEditor` closes stale managed tabs (e.g. a restored Changes tab whose session's workspace hasn't resolved yet on reload) before ensuring the current ones. If any close (esp. `_closeInactiveChangesEditors`) runs **unsuppressed** and empties the group, the workbench `handleDidCloseEditor` docked 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. Relatedly, the managed Files placeholder tab must NOT be auto-removed based on editor-area visibility (old Scenario 9 auto-remove) — that removal also emptied the group on reload; only remove it on an explicit user close (tracked via `_dismissedManagedTabs`). + +- **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*. Two bugs resulted: (1) the workbench `handleDidCloseEditor` docked branch saw an empty group and closed the whole side pane (reload/Cmd+N flicker: pane/Files-tab appears then vanishes); (2) `ChangesTabController._handleEditorClosed` recorded the external close as a user dismissal, poisoning `_dismissedManagedTabs` so the Files tab toggled on/off on alternate Cmd+N presses. Fix: `_withSessionLayoutRestore` (base controller) holds `suppressEditorPartAutoVisibility()` for the whole (async) restore **only when `isSinglePaneLayoutEnabled`** (OFF layout unchanged), so layout-driven closes never reach `handleDidCloseEditor`; and `_handleEditorClosed` ignores closes while `layoutService.isEditorPartAutoVisibilitySuppressed()` is true, so only a genuine user close dismisses a managed tab. Added `isEditorPartAutoVisibilitySuppressed()` to `IAgentWorkbenchLayoutService` as the shared "this close is layout-driven, not a user action" signal. + +- **DetailPanelController must not force-reveal the detail during a layout-driven restore**: `_syncForcedTarget` reveals 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 `_withSessionLayoutRestore` the base controller holds `suppressEditorPartAutoVisibility()` (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. + +- **Single-pane detail/tab behaviour lives ON the layout controller, not in separate controllers or a shared service**: `SinglePaneDesktopSessionLayoutController` owns 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 previously `ChangesTabController`/`DetailPanelController` (registered by a `SinglePaneModeController` contribution) coordinating via global `IAgentWorkbenchLayoutService` flags, then briefly via an `ISessionLayoutCoordinatorService`. Both were removed: because everything is now one class, "is a session-switch restore in progress?" is just the base protected getter `this._isRestoringSessionLayout` (set by `_withSessionLayoutRestore`), so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The two sub-behaviours register in the `_registerAuxiliaryControllers()` base hook, deferred to `LifecyclePhase.Restored` so managed tabs reconcile on top of the restored editor group. The base controller gained `IChangesViewService` + `IContextKeyService` deps and a protected `_editorGroupsService` to support this (a subclass can't add DI ctor params without redeclaring all base params, so the shared services live on the base). Tests: the layout harness got `activeGroupEditors`/`closeSuppressionFlags`, a real `mainPart.activeGroup`, an `activateAux` opt-in that resolves the lifecycle, and a `TestSinglePaneController.runWithRestore(...)` seam to hold `_isRestoringSessionLayout` across an async editor change; `changesTabController.test.ts` was deleted and its scenarios moved into `desktopSessionLayoutController.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). `SinglePaneDesktopSessionLayoutController._syncForcedDetailTarget` reveals a hidden detail ONLY when it was transiently hidden by a browser tab (`_hiddenByBrowser`), 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. The reopen default is layout-aware via the base `_defaultReopenSidePaneParts()` hook (base returns both parts; single-pane returns `{editor:true, auxiliaryBar:false}` for a created session and `{editor:false, auxiliaryBar:true}` for a new-session view). The detail is opened only via Toggle Details or restored per-session state. When changing this, update the `[single-pane] ... file tab activation` / `[per-session detail]` / reopen-default tests together — they encode the reveal semantics. +- **Editor-title actions that only make sense with a restorable editor must also gate on `EditorMaximizedContext.negate()`**: the single-pane "Hide Editor" action is meaningless while the editor area is maximized, so its `when` includes `EditorMaximizedContext.negate()` (in addition to `MainEditorAreaVisibleContext` + `SinglePaneDetailChangesOrFilesActiveContext`). + +- **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). Track `previousEditorVisible` + `previousInNewSessionView` in the autorun and hide only on `(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly()`. The two workbench methods `setSuppressDockedEditorRevealSync` (blocks width-based reveals at the source, avoiding flicker) and `isEditorRevealedExplicitly` (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 `isModal` gate in `_applyWorkingSet` skips it**: `baseSessionLayoutController._applyWorkingSet` only 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 when `useModal` is `'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: compute `revealEditorPart = !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-session `editorPartHidden` (Detail-only / side-pane-closed) state and excluding new-session views (R1 keeps their editor closed). Note the layout test harness leaves `isSinglePaneLayoutEnabled` falsy by default, so base single-pane branches are inert in tests unless a test opts in via the `singlePaneLayoutEnabled` create option. + +- **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 `_editorRevealedExplicitly` on entering the view so R1 re-hides an inherited-explicit editor across a session switch (the working-set apply runs under `suppressEditorPartAutoVisibility`, so `handleDidCloseEditor` doesn'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 condition `justEnteredNewSessionView || (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). `isEditorRevealedExplicitly` is still needed for that in-session case. diff --git a/.github/workflows/component-fixtures.yml b/.github/workflows/component-fixtures.yml index e42f20b623d..3aa4054080c 100644 --- a/.github/workflows/component-fixtures.yml +++ b/.github/workflows/component-fixtures.yml @@ -71,9 +71,6 @@ jobs: - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf - - name: Transpile source - run: npm run transpile-client - - name: Install Playwright Chromium run: npx playwright install chromium diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index bce8dcacaee..96ee679f47a 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -135,6 +135,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + - name: Verify native optional dependency binaries + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules @@ -190,6 +194,10 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" + - name: Verify native optional dependency binaries + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules @@ -245,6 +253,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + - name: Verify native optional dependency binaries + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.node-modules-cache.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index 1bf16785e0d..d7d9cfd7b29 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -6,26 +6,26 @@ "": { "name": "agent-sdk-claude", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz", - "integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -34,9 +34,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz", - "integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -47,9 +47,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz", - "integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -60,9 +60,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz", - "integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz", - "integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz", - "integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz", - "integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz", - "integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz", - "integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], diff --git a/build/agent-sdk/agents/claude/package.json b/build/agent-sdk/agents/claude/package.json index d3ea8dc1acb..bcdae1739cf 100644 --- a/build/agent-sdk/agents/claude/package.json +++ b/build/agent-sdk/agents/claude/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } } diff --git a/build/azure-pipelines/common/checkNativeOptionalDeps.ts b/build/azure-pipelines/common/checkNativeOptionalDeps.ts new file mode 100644 index 00000000000..bd9407fa816 --- /dev/null +++ b/build/azure-pipelines/common/checkNativeOptionalDeps.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'fs'; +import path from 'path'; + +// Some dependencies ship their native binary in a per-platform package that is +// declared as an *optional* dependency of a small base package — e.g. +// `@openai/codex` and `@anthropic-ai/claude-agent-sdk` are thin launchers and +// the real binaries live in `@openai/codex--` / +// `@anthropic-ai/claude-agent-sdk--`. `npm install` / `npm ci` +// do NOT fail when an optional dependency cannot be installed, so a transient +// hiccup can leave the base package present while the per-platform package is +// missing. That broken tree then gets frozen into the node_modules cache and +// served to every consumer, failing far away from the cause +// (see https://github.com/microsoft/vscode/pull/323881). +// +// This check runs after `npm ci` when building a node_modules cache and fails +// the job when a required per-platform package is missing, so a poisoned cache +// is never saved. + +const ROOT = path.join(import.meta.dirname, '../../../'); + +// Base packages whose per-platform package (`--`) is +// required whenever the base package itself is installed. +const NATIVE_OPTIONAL_DEP_BASE_PACKAGES = [ + '@openai/codex', + '@anthropic-ai/claude-agent-sdk', +]; + +// Platform/arch combinations these packages publish a per-platform package for. +const SUPPORTED_PLATFORMS = new Set(['linux', 'darwin', 'win32']); +const SUPPORTED_ARCHS = new Set(['x64', 'arm64']); + +function isInstalled(pkg: string): boolean { + return fs.existsSync(path.join(ROOT, 'node_modules', pkg)); +} + +const { platform, arch } = process; + +if (!SUPPORTED_PLATFORMS.has(platform) || !SUPPORTED_ARCHS.has(arch)) { + console.log(`Skipping native optional-dependency check on unsupported ${platform}-${arch}.`); + process.exit(0); +} + +const errors: string[] = []; +for (const basePackage of NATIVE_OPTIONAL_DEP_BASE_PACKAGES) { + // Only enforce when the base package is installed; if it is not, the + // dependency simply was not requested here and there is nothing to verify. + if (!isInstalled(basePackage)) { + continue; + } + // The glibc Linux package (no `-musl` suffix) is the one required on the + // glibc-based CI hosts; the naming is always `--`. + const platformPackage = `${basePackage}-${platform}-${arch}`; + if (!isInstalled(platformPackage)) { + errors.push(`${basePackage}: required per-platform package '${platformPackage}' is missing from node_modules — the optional dependency was silently skipped during install`); + } +} + +if (errors.length > 0) { + console.error('\x1b[1;31m*** Missing native optional-dependency packages — refusing to save a poisoned node_modules cache ***\x1b[0m'); + for (const err of errors) { + console.error(` - ${err}`); + } + console.error('\nnpm does not fail when an optional dependency cannot be installed, so this tree would poison the shared node_modules cache. Re-run a fresh `npm ci` (e.g. after bumping build/.cachesalt) to restore the package before the cache is saved.'); + process.exit(1); +} + +console.log(`Verified native optional-dependency packages for ${platform}-${arch}.`); diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index 29d6a8e7199..5631e280b28 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -13,38 +13,29 @@ "@vscode/component-explorer-webpack-plugin": "^0.3.1-53" }, "devDependencies": { - "@rspack/cli": "^1.3.18", - "@rspack/core": "^1.3.18", + "@rspack/cli": "^2.1.2", + "@rspack/core": "^2.1.2", + "@rspack/dev-server": "^2.1.0", "@vscode/esm-url-webpack-plugin": "^1.0.1-3", "source-map-loader": "^5.0.0" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -53,9 +44,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -65,8 +56,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "peer": true, @@ -77,8 +66,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "peer": true, @@ -88,8 +75,6 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "peer": true, @@ -100,16 +85,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "peer": true, @@ -118,545 +99,50 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/buffers": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", - "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/codegen": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", - "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", - "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", - "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", - "glob-to-regex.js": "^1.0.0", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", - "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", - "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", - "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", - "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.1", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", - "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/json-pack": "^17.65.0", - "@jsonjoy.com/util": "^17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", - "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", - "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", - "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "17.67.0", - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0", - "@jsonjoy.com/json-pointer": "17.67.0", - "@jsonjoy.com/util": "17.67.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", - "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/util": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", - "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", - "@jsonjoy.com/util": "^1.9.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/util": "^1.9.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0", - "@jsonjoy.com/codegen": "^1.0.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/error-codes": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", - "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", - "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/runtime-core": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-core": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", - "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-tools": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", - "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/webpack-bundler-runtime": "0.22.0" - } - }, - "node_modules/@module-federation/sdk": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", - "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", - "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, "node_modules/@rspack/binding": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.10.tgz", - "integrity": "sha512-j+DPEaSJLRgasxXNpYQpvC7wUkQF5WoWPiTfm4fLczwlAmYwGSVkJiyWDrOlvVPiGGYiXIaXEjVWTw6fT6/vnA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.1.2.tgz", + "integrity": "sha512-/mFcRSUW7Pl19KeaBIujJvZYNJQu0wD5D3aa5h+Qcph26v7nmLYlX7eajIHGi8tt2qTZX1lXifw2KLIXKwYaRQ==", "dev": true, "license": "MIT", "optionalDependencies": { - "@rspack/binding-darwin-arm64": "1.7.10", - "@rspack/binding-darwin-x64": "1.7.10", - "@rspack/binding-linux-arm64-gnu": "1.7.10", - "@rspack/binding-linux-arm64-musl": "1.7.10", - "@rspack/binding-linux-x64-gnu": "1.7.10", - "@rspack/binding-linux-x64-musl": "1.7.10", - "@rspack/binding-wasm32-wasi": "1.7.10", - "@rspack/binding-win32-arm64-msvc": "1.7.10", - "@rspack/binding-win32-ia32-msvc": "1.7.10", - "@rspack/binding-win32-x64-msvc": "1.7.10" + "@rspack/binding-darwin-arm64": "2.1.2", + "@rspack/binding-darwin-x64": "2.1.2", + "@rspack/binding-linux-arm64-gnu": "2.1.2", + "@rspack/binding-linux-arm64-musl": "2.1.2", + "@rspack/binding-linux-riscv64-gnu": "2.1.2", + "@rspack/binding-linux-riscv64-musl": "2.1.2", + "@rspack/binding-linux-x64-gnu": "2.1.2", + "@rspack/binding-linux-x64-musl": "2.1.2", + "@rspack/binding-wasm32-wasi": "2.1.2", + "@rspack/binding-win32-arm64-msvc": "2.1.2", + "@rspack/binding-win32-ia32-msvc": "2.1.2", + "@rspack/binding-win32-x64-msvc": "2.1.2" } }, "node_modules/@rspack/binding-darwin-arm64": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.10.tgz", - "integrity": "sha512-bsXi7I6TpH+a4L6okIUh1JDvwT+XcK/L7Yvhu5G2t5YYyd2fl5vMM5O9cePRpEb0RdqJZ3Z8i9WIWHap9aQ8Gw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.1.2.tgz", + "integrity": "sha512-IYcxareUOYJZz+uNMSIwn+iDRiVyjZNOjoxO/zL4OFaPK8Ncrw0ka/9DqL9Gd7OpnAXN1zK3uS8yD0O1yIYI3Q==", "cpu": [ "arm64" ], @@ -668,9 +154,9 @@ ] }, "node_modules/@rspack/binding-darwin-x64": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.10.tgz", - "integrity": "sha512-h/kOGL1bUflDDYnbiUjaRE9kagJpour4FatGihueV03+cRGQ6jpde+BjUakqzMx65CeDbeYI6jAiPhElnlAtRw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.1.2.tgz", + "integrity": "sha512-aoifkILvx/XEHyvg8yW57xu95nx7f9f/3ah1+RguHSNKcJMcoCep9VX1Ct1N0ftqg8MC0JUObc7xWL5W14hmjA==", "cpu": [ "x64" ], @@ -682,9 +168,9 @@ ] }, "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.10.tgz", - "integrity": "sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.1.2.tgz", + "integrity": "sha512-My4m40tyJSgiCEf3bB2KIEX710q3nZg99LIjy+8Zxgi3oZTkg1bFmFRusFU5U4eN5408zfSqDDGvjDE3Yv7o4w==", "cpu": [ "arm64" ], @@ -696,9 +182,9 @@ ] }, "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.10.tgz", - "integrity": "sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.1.2.tgz", + "integrity": "sha512-yt+GGWUH7WPE8K97cRc8OpZhH7Pbj1vU+lkvKbDtF/rR8X9a/bJsA/nBqyUV2oBKOVbrp5I8rFZlnDskMqgvKw==", "cpu": [ "arm64" ], @@ -709,10 +195,38 @@ "linux" ] }, + "node_modules/@rspack/binding-linux-riscv64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-2.1.2.tgz", + "integrity": "sha512-uys8Jyw8Z3ralvICbN/L/nZfy5qELIwpOY72rhIqhoDYwFcL4fmMaY7WsvUcJOjCB2rqOcWPaWKuF2oPvo9iDQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-riscv64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-2.1.2.tgz", + "integrity": "sha512-JYNVQwqCaRGQWvjHQYzZkIzQiwllMaJwh4Rdu3ww6W2OJcJUqT08sL1pkOtU0iCxT4VUYiRRcp93VGTGpHr8fg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.10.tgz", - "integrity": "sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.1.2.tgz", + "integrity": "sha512-KDoPy0Msf/JLhxgPPrJQzZeB4Qpqd32em8AP5lSW2s6jR5I35dHgAe9xc2A++EQtnSrU4GTn6DBvFC7q84SihQ==", "cpu": [ "x64" ], @@ -724,9 +238,9 @@ ] }, "node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.10.tgz", - "integrity": "sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.1.2.tgz", + "integrity": "sha512-66hWmIGvn4zCKAYXJE9Bp5SNSLYnLFq2Ke/efE+ZtWy43Dd5vk9AAOmThVGBwdwmIxmGtHGCp+cAuS4G0wu0TA==", "cpu": [ "x64" ], @@ -738,9 +252,9 @@ ] }, "node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.10.tgz", - "integrity": "sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.1.2.tgz", + "integrity": "sha512-EB4SqH8DW/E/OmqssNQvnIVGQiVUyYNlA/pcc6Ia4MlTNwu6eNDppcNLrToH+kSZpL4CpHSFfSM3eIsSuar2Rw==", "cpu": [ "wasm32" ], @@ -748,13 +262,15 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "1.1.6" } }, "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.10.tgz", - "integrity": "sha512-FaQGSCXH89nMOYW0bVp0bKQDQbrOEFFm7yedla7g6mkWlFVQo5UyBxid5wJUCqGJBtJepRxeRfByWiaI5nVGvg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.1.2.tgz", + "integrity": "sha512-T6Fs/g32MRja/UpCq4AdyPRj8tA0cOkcEa4PrAcn/ztUgK8b/qMVxj5mhMI+n7k+kHZQnpeB1Q4HqdSJi6OocA==", "cpu": [ "arm64" ], @@ -766,9 +282,9 @@ ] }, "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.10.tgz", - "integrity": "sha512-/66TNLOeM4R5dHhRWRVbMTgWghgxz+32ym0c/zGGXQRoMbz7210EoL40ALUgdBdeeREO8LoV+Mn7v8/QZCwHzw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.1.2.tgz", + "integrity": "sha512-OtxkFVz14mVL4QK8QriSELn9B6PaYGHw1jGJwVDEzpu2ZxSHCTQPz9dVE1ekYtREEqZUkRU7Fp7VfhJSmjTt2Q==", "cpu": [ "ia32" ], @@ -780,9 +296,9 @@ ] }, "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.10.tgz", - "integrity": "sha512-SUa3v1W7PGFCy6AHRmDsm43/tkfaZFi1TN2oIk5aCdT9T51baDVBjAbehRDu9xFbK4piL3k7uqIVSIrKgVqk1g==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.1.2.tgz", + "integrity": "sha512-Am+nx9fLF3nzgD/K05Bs1Bb+WO8SFLWAYRbXkymaL1r+RQxjRj7jd5ap2PhGOCcfaNA4yVWkAFvmFP92eRu7bQ==", "cpu": [ "x64" ], @@ -794,78 +310,93 @@ ] }, "node_modules/@rspack/cli": { - "version": "1.7.12", - "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.12.tgz", - "integrity": "sha512-aiVj5hm12/rhkH/KTvs9BugpRuLEutIw5o3KmQDvSMd6EqEIASuCCUI3/97uaNP58hqZSl7mAotsLcwn/OCbJQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-2.1.2.tgz", + "integrity": "sha512-GTBsKuaeqDii6NaGPkQwvycMSjqMUMMRGiAwH0sXsFIn8X7sFa4xpoL7pIhbebVBDGzYhUBqWzNQFuL5u2FeoQ==", "dev": true, "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.7", - "@rspack/dev-server": "~1.1.5", - "exit-hook": "^4.0.0", - "webpack-bundle-analyzer": "4.10.2" - }, "bin": { "rspack": "bin/rspack.js" }, "peerDependencies": { - "@rspack/core": "^1.0.0-alpha || ^1.x" + "@rspack/core": "^2.0.0-0", + "@rspack/dev-server": "^2.0.0-0" + }, + "peerDependenciesMeta": { + "@rspack/dev-server": { + "optional": true + } } }, "node_modules/@rspack/core": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.10.tgz", - "integrity": "sha512-dO7J0aHSa9Fg2kGT0+ZsM500lMdlNIyCHavIaz7dTDn6KXvFz1qbWQ/48x3OlNFw1mA0jxAjjw9e7h3sWQZUNg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.1.2.tgz", + "integrity": "sha512-crpNQKhHfnzrIl4Sa4fjH30Ho5aAPgyqpmJZ41SkUFOzyKHdZKYfE5LF3CMh7MiFQFPPxiiKf5BcpxmtZZx4MQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime-tools": "0.22.0", - "@rspack/binding": "1.7.10", - "@rspack/lite-tapable": "1.1.0" + "@rspack/binding": "2.1.2" }, "engines": { - "node": ">=18.12.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@swc/helpers": ">=0.5.1" + "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", + "@swc/helpers": "^0.5.23" }, "peerDependenciesMeta": { + "@module-federation/runtime-tools": { + "optional": true + }, "@swc/helpers": { "optional": true } } }, + "node_modules/@rspack/dev-middleware": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@rspack/dev-middleware/-/dev-middleware-2.0.3.tgz", + "integrity": "sha512-GxnGj9jy76G3eCPyZei81fwKLAMLZaPEEqFz1/QDYquhwi/qYZX5fekFJ1XVpuwxGEK9KSX3hxZylfwrs4cmLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rspack/core": "^2.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + } + } + }, "node_modules/@rspack/dev-server": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.1.5.tgz", - "integrity": "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-2.1.0.tgz", + "integrity": "sha512-WkCi6bWThVX5Ziv04srPaRoCoUY5FJolO4gqzE7xPO0XbXShsGnwn0vGD0DFfnYFcw9VSsxlmeCDV799lNYclA==", "dev": true, "license": "MIT", "dependencies": { - "chokidar": "^3.6.0", - "http-proxy-middleware": "^2.0.9", - "p-retry": "^6.2.0", - "webpack-dev-server": "5.2.2", - "ws": "^8.18.0" + "@rspack/dev-middleware": "^2.0.3" }, "engines": { - "node": ">= 18.12.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@rspack/core": "*" + "@rspack/core": "^2.0.0", + "selfsigned": "^5.0.0" + }, + "peerDependenciesMeta": { + "selfsigned": { + "optional": true + } } }, - "node_modules/@rspack/lite-tapable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", - "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", - "dev": true, - "license": "MIT" - }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -873,52 +404,8 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, "node_modules/@types/eslint": { "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", "peer": true, @@ -929,8 +416,6 @@ }, "node_modules/@types/eslint-scope": { "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "license": "MIT", "peer": true, @@ -941,177 +426,27 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } }, - "node_modules/@types/node-forge": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", - "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vscode/component-explorer": { "version": "0.2.1-58", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-58.tgz", - "integrity": "sha512-57kMe/U0+OOlkU6hD4OKUNW9Nly9Bbw9luvYgw+xmbaHv/55SrlR7SPT6nGtqVDEukRCAQ41pF7XsOM5PUpJsA==", "license": "MIT", "dependencies": { "react": "^18.3.1", @@ -1120,8 +455,6 @@ }, "node_modules/@vscode/component-explorer-webpack-plugin": { "version": "0.3.1-53", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-53.tgz", - "integrity": "sha512-e+Ala8W19RnH/Wlh/nguhdGT2MOA9O5XElEYftabe4nBmE4Dxv7QyrETv/dJ0NIkwzg920p2LLkHrxigYg/zPg==", "license": "MIT", "dependencies": { "tinyglobby": "^0.2.16" @@ -1132,8 +465,6 @@ }, "node_modules/@vscode/esm-url-webpack-plugin": { "version": "1.0.1-5", - "resolved": "https://registry.npmjs.org/@vscode/esm-url-webpack-plugin/-/esm-url-webpack-plugin-1.0.1-5.tgz", - "integrity": "sha512-XnPSKzCW1Lti9pfc/qAvXwlp7gbFGGJmtXiurzqRrWJS8cm/g87dDoSbh45eioynUSDR6EwCW/kFbKmIR54RhQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1142,8 +473,6 @@ }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", "peer": true, @@ -1154,32 +483,24 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", "peer": true, @@ -1191,16 +512,12 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", "peer": true, @@ -1213,8 +530,6 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", "peer": true, @@ -1224,8 +539,6 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1235,16 +548,12 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", "peer": true, @@ -1261,8 +570,6 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", "peer": true, @@ -1276,8 +583,6 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", "peer": true, @@ -1290,8 +595,6 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", "peer": true, @@ -1306,8 +609,6 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", "peer": true, @@ -1318,50 +619,21 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, "license": "BSD-3-Clause", "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, "license": "Apache-2.0", "peer": true }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1371,8 +643,6 @@ }, "node_modules/acorn-import-phases": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", "peer": true, @@ -1383,25 +653,11 @@ "acorn": "^8.14.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1415,10 +671,9 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -1433,10 +688,9 @@ }, "node_modules/ajv-keywords": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -1444,57 +698,8 @@ "ajv": "^8.8.2" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1505,79 +710,8 @@ "node": ">=6.0.0" } }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1611,73 +745,12 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "dev": true, "funding": [ { @@ -1696,35 +769,8 @@ "license": "CC-BY-4.0", "peer": true }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", "peer": true, @@ -1732,264 +778,14 @@ "node": ">=6.0" } }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.345", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", - "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==", "dev": true, "license": "ISC", "peer": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enhanced-resolve": { "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "peer": true, @@ -2001,51 +797,14 @@ "node": ">=10.13.0" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "peer": true, @@ -2053,30 +812,8 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-scope": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -2090,8 +827,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -2104,8 +839,6 @@ }, "node_modules/esrecurse/node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -2115,8 +848,6 @@ }, "node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -2124,27 +855,8 @@ "node": ">=4.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", "peer": true, @@ -2152,77 +864,14 @@ "node": ">=0.8.x" } }, - "node_modules/exit-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz", - "integrity": "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -2234,25 +883,11 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2266,228 +901,20 @@ } } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, "license": "BSD-2-Clause", "peer": true }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "ISC" - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true, - "license": "MIT" + "license": "ISC", + "peer": true }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "peer": true, @@ -2495,334 +922,8 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", - "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-worker": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", "peer": true, @@ -2837,32 +938,16 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/launch-editor": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", - "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.4" - } + "peer": true }, "node_modules/loader-runner": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, "license": "MIT", "peer": true, @@ -2876,8 +961,6 @@ }, "node_modules/loose-envify": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -2886,359 +969,41 @@ "loose-envify": "cli.js" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", - "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-to-fsa": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-releases": { "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "dev": true, - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3247,83 +1012,8 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -3334,8 +1024,6 @@ }, "node_modules/react-dom": { "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -3345,119 +1033,22 @@ "react": "^18.3.1" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT" }, "node_modules/scheduler": { "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -3465,10 +1056,9 @@ }, "node_modules/schema-utils": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -3483,262 +1073,8 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true, - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "peer": true, @@ -3748,8 +1084,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3758,8 +1092,6 @@ }, "node_modules/source-map-loader": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", "dev": true, "license": "MIT", "dependencies": { @@ -3779,8 +1111,6 @@ }, "node_modules/source-map-loader/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3792,8 +1122,6 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "peer": true, @@ -3802,112 +1130,8 @@ "source-map": "^0.6.0" } }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/spdy-transport/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/spdy-transport/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/spdy/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/spdy/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "peer": true, @@ -3923,8 +1147,6 @@ }, "node_modules/tapable": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "peer": true, @@ -3938,8 +1160,6 @@ }, "node_modules/terser": { "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3958,8 +1178,6 @@ }, "node_modules/terser-webpack-plugin": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", "dev": true, "license": "MIT", "peer": true, @@ -3993,40 +1211,12 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4039,98 +1229,20 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } + "license": "0BSD", + "optional": true }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "peer": true }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -4159,47 +1271,8 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/watchpack": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "peer": true, @@ -4211,20 +1284,8 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/webpack": { "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", "peer": true, @@ -4270,233 +1331,14 @@ } } }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, "node_modules/webpack-sources": { "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", - "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", "dev": true, "license": "MIT", "peer": true, "engines": { "node": ">=10.13.0" } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/build/rspack/package.json b/build/rspack/package.json index fc81f203c25..a812e140bb2 100644 --- a/build/rspack/package.json +++ b/build/rspack/package.json @@ -7,8 +7,9 @@ "serve-out": "rspack serve --config rspack.serve-out.config.mts" }, "devDependencies": { - "@rspack/cli": "^1.3.18", - "@rspack/core": "^1.3.18", + "@rspack/cli": "^2.1.2", + "@rspack/core": "^2.1.2", + "@rspack/dev-server": "^2.1.0", "@vscode/esm-url-webpack-plugin": "^1.0.1-3", "source-map-loader": "^5.0.0" }, diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 1437c0e8b8c..c1125f03dbc 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -44,6 +44,13 @@ export default { }, }, resolve: { + // Component Explorer fixtures live in `src` as `.ts` and import sibling + // modules via `.js` specifiers; try `.ts` first so those resolve, then + // fall back to `.js` for everything loaded from `out`. + extensionAlias: { + '.js': ['.ts', '.js'], + '.mjs': ['.mts', '.mjs'], + }, fallback: { path: path.resolve(repoRoot, 'node_modules', 'path-browserify'), fs: false, @@ -59,9 +66,25 @@ export default { module: { rules: [ { - test: /\.js$/, - enforce: 'pre', - use: ['source-map-loader'], + // Component Explorer fixtures (and any `src` TypeScript they pull + // in) are compiled on the fly with rspack's built-in SWC. + test: /\.ts$/, + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: false, + useDefineForClassFields: false, + }, + target: 'es2022', + }, + }, + type: 'javascript/auto', }, { test: /\.css$/, @@ -86,7 +109,7 @@ export default { }, plugins: [ new ComponentExplorerPlugin({ - include: 'out/**/*.fixture.js', + include: 'src/**/*.fixture.ts', }), new rspack.NormalModuleReplacementPlugin(/\.css$/, resource => { if (!resource.request.startsWith('.')) { diff --git a/build/rspack/workbench-rspack.html b/build/rspack/workbench-rspack.html index 58b0ba6bd09..87882e88e8c 100644 --- a/build/rspack/workbench-rspack.html +++ b/build/rspack/workbench-rspack.html @@ -1,5 +1,5 @@ - + diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index bf5f2376781..3eae6bd6120 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1826,6 +1826,11 @@ "secret": true, "description": "API key for OpenAI", "title": "API Key" + }, + "zeroDataRetentionEnabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Whether Zero Data Retention (ZDR) is enabled for this provider group. When `true`, OpenAI Responses requests from this group do not send `previous_response_id`." } }, "required": [ @@ -4337,7 +4342,7 @@ }, "github.copilot.chat.tools.grepSearch.outputFormat": { "type": "string", - "default": "tag", + "default": "grep", "enum": [ "grep", "tag" diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 24d5c6f841b..aecb9d3a339 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -278,7 +278,7 @@ "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", - "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'tag'.", + "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'grep'.", "github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 20.", "github.copilot.chat.tools.grepSearch.maxResultsCap": "The maximum number of results that can be returned from the grep search tool. The default is 200.", "copilot.tools.errors.description": "Check errors for a particular file", diff --git a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts index cc106933576..27df5d409e3 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts @@ -9,10 +9,22 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { BYOKKnownModels } from '../common/byokProvider'; -import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider'; +import { OpenAIEndpoint } from '../node/openAIEndpoint'; +import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider'; import { IBYOKStorageService } from './byokStorageService'; -export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { +export interface OpenAIProviderConfig extends LanguageModelChatConfiguration { + readonly zeroDataRetentionEnabled?: boolean; +} + +export function applyOpenAIProviderConfig(modelInfo: IChatModelInformation, configuration: OpenAIProviderConfig | undefined): IChatModelInformation { + return { + ...modelInfo, + zeroDataRetentionEnabled: configuration?.zeroDataRetentionEnabled ?? modelInfo.zeroDataRetentionEnabled, + }; +} + +export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { public static readonly providerName = 'OpenAI'; public static readonly providerId = this.providerName.toLowerCase(); @@ -43,6 +55,14 @@ export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { return 'https://api.openai.com/v1'; } + protected override async createOpenAIEndPoint(model: OpenAICompatibleLanguageModelChatInformation): Promise { + const modelInfo = applyOpenAIProviderConfig(this.getModelInfo(model.id, model.url), model.configuration); + const url = modelInfo.supported_endpoints?.includes(ModelSupportedEndpoint.Responses) ? + `${model.url}/responses` : + `${model.url}/chat/completions`; + return this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, model.configuration?.apiKey ?? '', url); + } + protected override getModelInfo(modelId: string, modelUrl: string): IChatModelInformation { const modelInfo = super.getModelInfo(modelId, modelUrl); modelInfo.supported_endpoints = [ diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts new file mode 100644 index 00000000000..b56047aee93 --- /dev/null +++ b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { IChatModelInformation } from '../../../../platform/endpoint/common/endpointProvider'; +import { TokenizerType } from '../../../../util/common/tokenizer'; +import { applyOpenAIProviderConfig } from '../openAIProvider'; + +function createModelInfo(zeroDataRetentionEnabled: boolean | undefined): IChatModelInformation { + return { + id: 'gpt-4.1', + name: 'GPT-4.1', + vendor: 'OpenAI', + version: '1.0.0', + is_chat_default: false, + is_chat_fallback: false, + model_picker_enabled: true, + zeroDataRetentionEnabled, + capabilities: { + type: 'chat', + family: 'gpt-4.1', + supports: { + streaming: true, + tool_calls: true, + vision: false, + thinking: false, + }, + tokenizer: TokenizerType.O200K, + limits: { + max_context_window_tokens: 128000, + max_prompt_tokens: 100000, + max_output_tokens: 8192, + }, + }, + }; +} + +describe('applyOpenAIProviderConfig', () => { + it('uses provider-level zeroDataRetentionEnabled when configured', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(undefined), { + apiKey: 'test-key', + zeroDataRetentionEnabled: true, + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); + + it('falls back to model metadata zeroDataRetentionEnabled when provider-level value is unset', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(true), { + apiKey: 'test-key', + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index bd49c2e4e74..ec0ddd71baf 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -1063,7 +1063,7 @@ export function registerCLIChatCommands( ): IDisposable { const disposableStore = new DisposableStore(); - async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise { const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); @@ -1078,7 +1078,7 @@ export function registerCLIChatCommands( if (!repository) { throw new Error(l10n.t('No active repository found to delete worktree.')); } - await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath); + await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel }); } catch (error) { vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error))); } @@ -1125,7 +1125,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(id, { keepWorktree }); + await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label }); } } })); @@ -1154,7 +1154,7 @@ export function registerCLIChatCommands( const id = SessionIdForCLI.parse(sessionItem.resource); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id); const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath); - await deleteSessionById(id, { keepWorktree }); + await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label }); } } })); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index fbf3f93c564..86a67c9d174 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -2224,7 +2224,7 @@ export function registerCLIChatCommands( ); return siblings.length > 0; } - async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise { const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); @@ -2239,7 +2239,7 @@ export function registerCLIChatCommands( if (!repository) { throw new Error(l10n.t('No active repository found to delete worktree.')); } - await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath); + await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel }); } catch (error) { vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error))); } @@ -2265,7 +2265,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(sessionId, { keepWorktree }); + await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label }); copilotcliSessionItemProvider.notifySessionsChange(); } } @@ -2296,7 +2296,7 @@ export function registerCLIChatCommands( const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id; const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath); - await deleteSessionById(sessionId, { keepWorktree }); + await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label }); } } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 5db62afdf06..8d1efcc8df9 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -789,6 +789,15 @@ export class CopilotLanguageModelWrapper extends Disposable { const result = await wrappedRequest(); + if (result.type === ChatFetchResponseType.Length) { + // The model stopped generating because it hit the length/context-window limit + // (finish_reason "length"). The partial text has already been streamed to the + // consumer via the finished callback, so treat this as a successful (truncated) + // response instead of throwing "Response too long." and discarding the output. + this._logService.warn(`[LanguageModelAccess] Response from model '${_endpoint.model}' was truncated because it hit the length limit; returning the partial response.`); + return undefined; + } + if (result.type !== ChatFetchResponseType.Success) { if (result.type === ChatFetchResponseType.ExtensionBlocked) { if (extensionId) { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 937b01a53fe..35779997bb3 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -143,6 +143,41 @@ suite('CopilotLanguageModelWrapper', () => { assert.deepStrictEqual(decoded, expectedUsage); }); }); + + suite('length finish reason', () => { + let wrapper: CopilotLanguageModelWrapper; + let endpoint: IChatEndpoint; + let fetcher: MockChatMLFetcher; + setup(async () => { + createAccessor(); + fetcher = accessor.get(IChatMLFetcher) as MockChatMLFetcher; + endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-utility'); + wrapper = instaService.createInstance(CopilotLanguageModelWrapper); + }); + + test('does not throw when the response is truncated due to length', async () => { + // A model (e.g. a custom/BYOK endpoint) can legitimately stop generating + // because it hit the context window ceiling, returning finish_reason "length" + // together with the partial text it already streamed. This must not surface + // as a hard "Response too long." failure that discards the generated text. + fetcher.setNextResponse({ + type: ChatFetchResponseType.Length, + reason: 'Response too long.', + requestId: 'test-request-id', + serverRequestId: 'test-server-request-id', + truncatedValue: 'partial answer' + }); + + await wrapper.provideLanguageModelResponse( + endpoint, + [vscode.LanguageModelChatMessage.User('hello')], + { requestInitiator: 'unknown', toolMode: vscode.LanguageModelChatToolMode.Auto }, + vscode.extensions.all[0].id, + { report: () => { } }, + CancellationToken.None + ); + }); + }); }); suite('LanguageModelAccess model info', () => { diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index 627b9e36612..7911e845e42 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -47,9 +47,16 @@ interface ProxyAgentParams { loadSystemCertificatesFromNode: () => boolean | undefined; } +interface ResolvedProxyInfo { + url: string | undefined; + type: 'DIRECT' | 'PROXY' | 'HTTP' | 'HTTPS' | 'SOCKS' | 'SOCKS5' | 'SOCKS4' | 'EMPTY' | 'UNRECOGNIZED'; + source: 'localhost' | 'noProxyConfig' | 'noProxyEnv' | 'setting' | 'env' | 'remote' | 'system_cached' | 'system' | 'fallback'; +} + interface ProxyAgent { loadSystemCertificates?(params: ProxyAgentParams): Promise; resolveProxyURL?(url: string): Promise; + resolveProxyByURL?(url: string): Promise; } export class LoggingActionsContrib { @@ -471,35 +478,38 @@ function getProxyEnvVariables() { return res.length ? `\n\nEnvironment Variables:${res.join('')}` : ''; } +interface ProxyInfo { + /** Resolved proxy type: `DIRECT`, `PROXY`, `HTTP`, `HTTPS`, `SOCKS`, `SOCKS5`, `SOCKS4`, `EMPTY`, `UNRECOGNIZED` or `UNKNOWN`. */ + type: string; + /** + * Where the proxy configuration came from: `localhost`, `noProxyConfig` + * (`http.noProxy`), `noProxyEnv` (`no_proxy`), `setting` (`http.proxy`), `env` + * (`http(s)_proxy`), `remote`, `system_cached`, `system` (OS/PAC) or `fallback` + * from `@vscode/proxy-agent`, or one of the failure sentinels `missing` + * (module/API unavailable), `timeout` or `error`. + */ + source: string; +} + /** - * Resolves the proxy type for a URL using the bundled `@vscode/proxy-agent` module. - * Returns one of `DIRECT`, `PROXY`, `HTTPS`, `SOCKS` or `UNKNOWN`. + * Resolves the proxy type and configuration source for a URL using the bundled + * `@vscode/proxy-agent` module. The source reflects which configuration actually + * determined the resolved proxy (see `@vscode/proxy-agent`'s `resolveProxyByURL`). */ -async function resolveProxyType(url: string, logService: ILogService): Promise { +async function resolveProxyInfo(url: string, logService: ILogService): Promise { try { const proxyAgent = loadVSCodeModule('@vscode/proxy-agent'); - if (!proxyAgent?.resolveProxyURL) { - return 'UNKNOWN'; + if (!proxyAgent?.resolveProxyByURL) { + return { type: 'UNKNOWN', source: 'missing' }; } - const proxyURL = await Promise.race([proxyAgent.resolveProxyURL(url), timeoutAfter(5000)]); - if (proxyURL === 'timeout') { - return 'UNKNOWN'; - } - if (!proxyURL) { - return 'DIRECT'; - } - const scheme = proxyURL.split(':', 1)[0].toLowerCase(); - switch (scheme) { - case 'http': return 'PROXY'; - case 'https': return 'HTTPS'; - case 'socks': - case 'socks4': - case 'socks5': return 'SOCKS'; - default: return 'UNKNOWN'; + const info = await Promise.race([proxyAgent.resolveProxyByURL(url), timeoutAfter(5000)]); + if (info === 'timeout') { + return { type: 'UNKNOWN', source: 'timeout' }; } + return { type: info.type, source: info.source }; } catch (err) { - logService.debug(`Fetcher telemetry: Failed to resolve proxy type: ${err?.message}`); - return 'UNKNOWN'; + logService.debug(`Fetcher telemetry: Failed to resolve proxy info: ${err?.message}`); + return { type: 'UNKNOWN', source: 'error' }; } } @@ -572,8 +582,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { } } - // Resolve the proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS). - const proxyType = await resolveProxyType(capiClientService.capiPingURL, logService); + // Resolve the proxy type and configuration source for the CAPI endpoint. + const { type: proxyType, source: proxySource } = await resolveProxyInfo(capiClientService.capiPingURL, logService); // Second loop: send the actual telemetry event including probe results. const requestGroupId = generateUuid(); @@ -589,7 +599,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { "clientLibrary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher library used for this request." }, "extensionKind": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the extension runs locally or remotely." }, "remoteName": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The remote name, if any." }, - "proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS, UNKNOWN)." }, + "proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTP, HTTPS, SOCKS, SOCKS5, SOCKS4, EMPTY, UNRECOGNIZED, UNKNOWN)." }, + "proxySource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Where the proxy configuration came from: localhost, noProxyConfig (http.noProxy), noProxyEnv (no_proxy), setting (http.proxy), env (http(s)_proxy), remote, system_cached, system (OS/PAC), fallback, or a failure sentinel: missing, timeout or error." }, "electronfetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the electron-fetch fetcher." }, "nodefetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-fetch fetcher." }, "nodehttp": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-http fetcher." } @@ -601,6 +612,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { extensionKind, remoteName: vscode.env.remoteName ?? 'none', proxyType, + proxySource, ...probeResults, }; const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx index 68998fda817..5918bfd9f2b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx @@ -162,6 +162,7 @@ class Gpt56Prompt extends PromptElement { You have two channels for staying in conversation with the user:
- You share updates in `commentary` channel.
- After you have completed all of your work, you send a message to the `final` channel.
+ Do NOT put final answer in commentary channel, or ask _blocking_ question in a commentary channel that should be asked in the final channel. Message to users in the commentary channel is only for partial updates, partial results, or non-blocking questions that can provide value to users while the AI assistant continues working. The final answer must always be fully self-contained: users should never need to read earlier commentary update, since they are collapsed after the final answer is shown to users.
The user may send messages while you are working. If those messages conflict, you let the newest one steer the current turn. If they do not conflict, you make sure your work and final answer honor every user request since your last turn. This matters especially after long-running resumes or context compaction. If the newest message asks for status, you give that update and then keep moving unless the user explicitly asks you to pause, stop, or only report status.
Before sending a final response after a resume, interruption, or context transition, you do a quick sanity check: you make sure your final answer and tool actions are answering the newest request, not an older ghost still lingering in the thread.
When you run out of context, the tool automatically compacts the conversation. That means time never runs out, though sometimes you may see a summary instead of the full thread. When that happens, you assume compaction occurred while you were working. Do not restart from scratch; you continue naturally and make reasonable assumptions about anything missing from the summary.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx index 5c7abf859bb..39f29aa111d 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; -import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD, isVSCModelE } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; @@ -664,10 +664,26 @@ class VSCModelPromptD extends PromptElement { - This first `commentary` must be 1-2 friendly sentences acknowledging the request and stating the immediate next action you will take.
- The first commentary should not begin updates with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question,") or framing phrases. Do not use starters like "Got it -" or "Understood -".
+ {this instanceof VSCModelPromptE && + Operate in surgical compact mode. Correctness comes first, but every read, search, command, and note must directly reduce the chance of a wrong patch.
+
+ Default workflow:
+ - Start with one targeted search or symbol lookup. Read only the exact file and narrow line range needed for the likely edit site.
+ - Before each additional read/search, state the one missing fact it will answer. If it is only background, neighboring style, or "more context", skip it.
+ - Normal budget before the first patch: at most one search and two narrow reads. Exceed this only when the result falsified the edit site or exposed a required API contract.
+ - Keep reads tight: prefer the specific function/class/test range; avoid whole files, broad parallel reads, neighboring tests, and repeated grep variants.
+ - Patch once you know the root cause and edit site. Do not inspect unrelated neighbors, add opportunistic cleanup, or broaden scope after a plausible fix is available.
+ - Validate with the smallest existing command that checks the changed behavior, and keep command output concise. Do not run broad suites after a focused pass.
+ - Keep reasoning and final answer brief. Do not narrate routine tool use, quote long snippets, or restate tool output; record only decisions that affect the patch.
+
+ If the fix is uncertain, run one focused probe or ask for the single missing fact, then return to patch/validate. Do not continue exploration by default.
+
} ; } } +class VSCModelPromptE extends VSCModelPromptD { } + class VSCModelPromptResolverA implements IAgentPrompt { static readonly familyPrefixes = ['vscModelA']; static async matchesModel(endpoint: IChatEndpoint): Promise { @@ -730,6 +746,22 @@ class VSCModelPromptResolverD implements IAgentPrompt { } } +class VSCModelPromptResolverE implements IAgentPrompt { + static readonly familyPrefixes = ['vscModelE']; + + static async matchesModel(endpoint: IChatEndpoint): Promise { + return isVSCModelE(endpoint); + } + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return VSCModelPromptE; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return VSCModelReminderInstructionsA; + } +} + class VSCModelReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { return <> @@ -797,4 +829,5 @@ class VSCModelReminderInstructionsC extends PromptElement('chat.localIndex.enabled', ConfigType.ExperimentBased, false); /** grep_search configs */ - export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag'); + export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'grep'); export const GrepSearchDefaultMaxResults = defineSetting('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20); export const GrepSearchMaxResultsCap = defineSetting('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200); } diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 3a621ae4e04..c633084a0da 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -38,6 +38,8 @@ const VSC_MODEL_HASHES_D = [ 'e82ff0e2d4e4bae1f012dc599d520f8d61becfc4762f3717577b270be199db92', ]; +const VSC_MODEL_HASHES_E: string[] = []; + // subset to allow replace string instead of apply patch. const VSC_MODEL_HASHES_EDIT_TOOL_SET = [ @@ -215,6 +217,13 @@ export function isVSCModelD(model: LanguageModelChat | IChatEndpoint) { return VSC_MODEL_HASHES_D.includes(ID_hash) || VSC_MODEL_HASHES_D.includes(family_hash); } +export function isVSCModelE(model: LanguageModelChat | IChatEndpoint) { + const modelId = getModelId(model); + const ID_hash = getCachedSha256Hash(modelId); + const family_hash = getCachedSha256Hash(model.family); + return model.name.startsWith('vscModelE') || model.family.startsWith('vscModelE') || modelId.startsWith('vscModelE') || VSC_MODEL_HASHES_E.includes(ID_hash) || VSC_MODEL_HASHES_E.includes(family_hash); +} + export function isGpt52CodexFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { const family = typeof model === 'string' ? model : model.family; return family === 'gpt-5.2-codex'; diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 2171ee2bc98..bf1ce36b279 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -404,25 +404,33 @@ export class AutomodeService extends Disposable implements IAutomodeService { return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' }; } - // Trust the router's ranked candidate list directly. + // Prefer chosen_model — it is the router's authoritative pick after any + // server-side re-ranking (e.g. Cost Sorting experiments). candidate_models + // is the ordered fallback list per the auto-intent-service contract + // (docs/integrators_onboarding.md: "Use chosen_model for the upcoming chat + // call, and use candidate_models as the ordered fallback list"). // Same-provider preference is intentionally NOT applied here — the router // already accounts for available models and re-runs after /compact, so // overriding its pick with same-provider negates cost-saving decisions. // Same-provider is still used in _selectDefaultModel (the non-router fallback). - const selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + const routerModel = result.chosen_model ?? result.candidate_models[0]; + let selectedModel = result.chosen_model ? knownEndpoints.find(e => e.model === result.chosen_model) : undefined; + if (!selectedModel) { + selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + } if (!selectedModel) { - this._logService.warn(`[AutomodeService] None of the router's candidate_models matched knownEndpoints: [${result.candidate_models.join(', ')}]`); + this._logService.warn(`[AutomodeService] Router pick not in knownEndpoints: chosen_model=${result.chosen_model ?? 'n/a'}, candidate_models=[${result.candidate_models.join(', ')}]`); return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; } if (result.sticky_override) { - this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`); + this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${routerModel}, actual_model=${selectedModel.model}`); } return { selectedModel, lastRoutedPrompt: prompt, - candidateModel: result.candidate_models[0], + candidateModel: routerModel, routingDecision: { resolvedModel: selectedModel.model, resolvedModelName: selectedModel.name, diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index b5808204c70..7ee962e6c07 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -29,7 +29,7 @@ import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/t import { TelemetryData } from '../../telemetry/common/telemetryData'; import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; -import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess'; @@ -390,6 +390,12 @@ export class ChatEndpoint implements IChatEndpoint { } } + // Force temperature and top_p for Kimi models regardless of what the client would otherwise send (per Moonshot recommendations). Temperature 0 strongly increases chances of looping. + if (isKimiFamily(this)) { + body.temperature = 1; + body.top_p = 0.95; + } + return body; } diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 0442e0a4750..407986bef4b 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -1174,7 +1174,16 @@ export class OpenAIResponsesProcessor { switch (chunk.type) { case 'error': - return onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + // Surface the error as a progress delta, but also produce a terminal + // completion so the request resolves to a meaningful server error + // instead of collapsing into the generic "Response contained no + // choices" fallback when the stream ends without a terminal event. + onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + return this.buildTerminalCompletion( + { output: [] } as unknown as CapiResponseTerminalEvent['response'], + FinishedCompletionReason.ServerError, + { error: mapResponsesApiError({ code: chunk.code, message: chunk.message } as OpenAI.Responses.ResponseError) } + ); case 'response.output_text.delta': { const capiChunk: CapiResponsesTextDeltaEvent = chunk; // When text arrives from a new output item, emit a paragraph diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index ee6b1f453c9..a2d4e2aca4d 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -1076,6 +1076,38 @@ describe('AutomodeService', () => { }); }); + it('should emit candidateModel from chosen_model, not candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick: candidate_models[0] is gpt-5.3-codex but the + // authoritative chosen_model is gpt-5.4-mini. The telemetry candidateModel + // must reflect chosen_model so router-pick vs actual comparisons are valid. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-telemetry-chosen-model' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + expect(selectionEvent).toBeDefined(); + expect(selectionEvent![1]).toMatchObject({ + candidateModel: 'gpt-5.4-mini', + actualModel: 'gpt-5.4-mini', + overrideReason: 'none', + }); + }); + it('should emit overrideReason=clientOverride when vision fallback changes the model', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI', { supportsVision: true }); @@ -1234,13 +1266,15 @@ describe('AutomodeService', () => { ); }); - it('should iterate all candidate_models when first candidate has no endpoint', async () => { + it('should fall back to candidate_models when chosen_model has no endpoint', async () => { enableRouter(); const gpt41Endpoint = createEndpoint('gpt-4.1', 'OpenAI'); + // chosen_model is not in knownEndpoints, so selection falls back to + // the ordered candidate_models list and picks the first resolvable one. mockRouterResponse( ['gpt-4.1'], - { chosen_model: 'gpt-4.1', candidate_models: ['unknown-new-model', 'gpt-4.1'] } + { chosen_model: 'unknown-new-model', candidate_models: ['unknown-new-model', 'gpt-4.1'] } ); automodeService = createService(); @@ -1254,6 +1288,57 @@ describe('AutomodeService', () => { expect(result.model).toBe('gpt-4.1'); }); + it('should prefer chosen_model over candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick (e.g. Cost Sorting experiment): chosen_model + // is gpt-5.4-mini even though candidate_models[0] is gpt-5.3-codex. The + // client must send the chosen_model, per the auto-intent-service contract. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-chosen-model' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + expect(result.model).toBe('gpt-5.4-mini'); + }); + + it('should surface chosen_model in the routing decision the UI displays', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // The "Routed to " explainability label reads the routing + // decision surfaced via consumeLastRoutingDecision(). It must match the + // served endpoint (chosen_model), otherwise the label diverges from the + // model shown in the response footer (candidate_models[0]). + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-routing-decision' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + const decision = automodeService.consumeLastRoutingDecision(); + expect(decision?.resolvedModel).toBe('gpt-5.4-mini'); + expect(decision?.resolvedModel).toBe(result.model); + }); + it('should fall back to first known endpoint when all available_models are unknown', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index df5f0391d79..665209c8ded 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -451,4 +451,49 @@ describe('ChatEndpoint - Image Count Validation', () => { expect(() => filterImages(endpoint, messages, 2)).toThrow(/Too many images/); }); }); +}); + +describe('ChatEndpoint - Kimi temperature and top_p override', () => { + let mockServices: ReturnType; + + beforeEach(() => { + mockServices = createMockServices(); + }); + + const createEndpoint = (metadata: IChatModelInformation) => + new ChatEndpoint( + metadata, + mockServices.domainService, + mockServices.chatMLFetcher, + mockServices.tokenizerProvider, + mockServices.instantiationService, + mockServices.configurationService, + mockServices.expService, + mockServices.chatWebSocketService, + mockServices.logService + ); + + const createTextMessage = (): Raw.ChatMessage => ({ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }); + + const createOptionsWithPostOptions = (): ICreateEndpointBodyOptions => ({ + ...createTestOptions([createTextMessage()]), + postOptions: { temperature: 0, top_p: 1 } + }); + + it.each(['kimi-k2.6', 'kimi-k2.7-code'])('should force temperature=1 and top_p=0.95 for %s', (family) => { + const endpoint = createEndpoint(createNonAnthropicModelMetadata(family)); + const body = endpoint.createRequestBody(createOptionsWithPostOptions()); + expect(body.temperature).toBe(1); + expect(body.top_p).toBe(0.95); + }); + + it('should not override temperature or top_p for non-Kimi models', () => { + const endpoint = createEndpoint(createNonAnthropicModelMetadata('gpt-4o')); + const body = endpoint.createRequestBody(createOptionsWithPostOptions()); + expect(body.temperature).toBe(0); + expect(body.top_p).toBe(1); + }); }); \ No newline at end of file diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index 95ad8f9a562..9d9f1984a5e 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -1916,4 +1916,28 @@ describe('processResponseFromChatEndpoint terminal events', () => { metadata: { code: 'internal_error' }, }); }); + + it('maps a raw error stream event to a terminal ServerError completion', async () => { + // Root cause of #322209: a raw Responses API `error` stream event is only + // surfaced as a `copilotErrors` progress delta and never yields a terminal + // completion. With no completion, the downstream chat fetcher falls through + // to the generic `RESPONSE_CONTAINED_NO_CHOICES` ("Response contained no + // choices") instead of a meaningful server-error message. + const errorEvent = { + type: 'error', + code: 'server_error', + message: 'The server had an error while processing your request.', + param: null, + }; + + const [completion] = await runStream(`data: ${JSON.stringify(errorEvent)}\n\n`); + + expect(completion).toBeDefined(); + expect(completion.finishReason).toBe(FinishedCompletionReason.ServerError); + expect(completion.error).toEqual({ + code: 0, + message: 'The server had an error while processing your request.', + metadata: { code: 'server_error' }, + }); + }); }); diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index c21f8688d8f..bcdb99d2d24 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -70,7 +70,7 @@ export interface IGitService extends IDisposable { restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise; createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise; + deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index bb6ae6884df..012fe6b7cc8 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -316,7 +316,7 @@ export class GitServiceImpl extends Disposable implements IGitService { return await repository?.createWorktree(options); } - async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise { const gitAPI = this.gitExtensionService.getExtensionApi(); const repository = gitAPI?.getRepository(uri); return await repository?.deleteWorktree(path, options); diff --git a/extensions/copilot/src/platform/git/vscode/git.d.ts b/extensions/copilot/src/platform/git/vscode/git.d.ts index 2254e384fa0..33fa62c171c 100644 --- a/extensions/copilot/src/platform/git/vscode/git.d.ts +++ b/extensions/copilot/src/platform/git/vscode/git.d.ts @@ -315,7 +315,7 @@ export interface Repository { dropStash(index?: number): Promise; createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(path: string, options?: { force?: boolean }): Promise; + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts index 6b7f747c059..3f9beaf603b 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts @@ -122,7 +122,7 @@ export class MockGitService implements IGitService { return Promise.resolve(undefined); } - deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean }): Promise { + deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean; label?: string }): Promise { return Promise.resolve(); } diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index f09ba0ae0f3..d0c572b2ed1 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -801,7 +801,7 @@ export class TestingGitService implements IGitService { return undefined; } - async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise { return; } diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 28b8645881a..79eaf1ca4ca 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -354,7 +354,7 @@ export class ApiRepository implements Repository { return this.#repository.createWorktree(options); } - deleteWorktree(path: string, options?: { force?: boolean }): Promise { + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise { return this.#repository.deleteWorktree(path, options); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 725dd360663..03a06a7794b 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -326,7 +326,7 @@ export interface Repository { dropStash(index?: number): Promise; createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(path: string, options?: { force?: boolean }): Promise; + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 00b6a6de3b1..ec27cbd57fe 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -2120,7 +2120,7 @@ export class Repository implements Disposable { } } - async deleteWorktree(path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise { await this.run(Operation.Worktree(false), async () => { const worktree = this.repositoryResolver.getRepository(path); @@ -2130,12 +2130,14 @@ export class Repository implements Disposable { }; try { - await deleteWorktree(); + await deleteWorktree(options); } catch (err) { if (err.gitErrorCode === GitErrorCodes.WorktreeContainsChanges) { const forceDelete = l10n.t('Force Delete'); - const message = l10n.t('The worktree contains modified or untracked files. Do you want to force delete?'); - const choice = await window.showWarningMessage(message, { modal: true }, forceDelete); + const message = options?.label + ? l10n.t('The worktree for session "{0}" contains modified or untracked files. Do you want to force delete?', options.label) + : l10n.t('The worktree contains modified or untracked files. Do you want to force delete?'); + const choice = await window.showWarningMessage(message, { modal: true, detail: l10n.t('Worktree: {0}', path) }, forceDelete); if (choice === forceDelete) { await deleteWorktree({ ...options, force: true }); } diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 24d91a541fb..ad40dd76657 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -34,7 +34,7 @@ "input.background": "#191A1B", "input.border": "#333536FF", "input.foreground": "#bfbfbf", - "input.placeholderForeground": "#555555", + "input.placeholderForeground": "#828282", "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", @@ -54,16 +54,15 @@ "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", - "list.activeSelectionBackground": "#3994BC26", + "list.activeSelectionBackground": "#FFFFFF22", "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#ededed", - "list.hoverBackground": "#1E1F20", + "list.hoverBackground": "#FFFFFF14", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", - "toolbar.hoverBackground": "#323233", - "toolbar.activeBackground": "#3C3C3D", - "list.focusBackground": "#3994BC26", + "toolbar.activeBackground": "#FFFFFF33", + "list.focusBackground": "#FFFFFF22", "list.focusForeground": "#bfbfbf", "list.focusOutline": "#3994BCB3", "list.highlightForeground": "#48A0C7", @@ -139,7 +138,7 @@ "editorSuggestWidget.border": "#2A2B2CFF", "editorSuggestWidget.foreground": "#bfbfbf", "editorSuggestWidget.highlightForeground": "#bfbfbf", - "editorSuggestWidget.selectedBackground": "#3994BC26", + "editorSuggestWidget.selectedBackground": "#FFFFFF26", "editorHoverWidget.background": "#202122", "editorHoverWidget.border": "#2A2B2CFF", "widget.border": "#2A2B2CFF", @@ -235,7 +234,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#1E1F20", + "quickInputList.hoverBackground": "#FFFFFF14", "terminal.selectionBackground": "#3994BC33", "terminal.background": "#191A1B", "terminal.border": "#2A2B2CFF", @@ -289,7 +288,7 @@ "agentsChatInput.foreground": "#bfbfbf", "agentsChatInput.border": "#333536", "agentsChatInput.focusBorder": "#3994BCB3", - "agentsChatInput.placeholderForeground": "#555555", + "agentsChatInput.placeholderForeground": "#828282", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#bfbfbf", "agentsNewSessionButton.border": "#333536", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 9d2a97a6534..1f7a6ba4792 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -36,7 +36,7 @@ "input.background": "#FFFFFF", "input.border": "#D8D8D866", "input.foreground": "#202020", - "input.placeholderForeground": "#999999", + "input.placeholderForeground": "#767676", "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", "inputOption.activeBorder": "#F0F1F2FF", @@ -58,20 +58,20 @@ "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", - "scrollbarSlider.background": "#64646480", - "scrollbarSlider.hoverBackground": "#64646490", - "scrollbarSlider.activeBackground": "#646464A0", + "scrollbarSlider.background": "#646464C0", + "scrollbarSlider.hoverBackground": "#646464D0", + "scrollbarSlider.activeBackground": "#646464E0", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", - "list.activeSelectionBackground": "#0069CC1A", + "list.activeSelectionBackground": "#00000025", "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#DADADA99", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F1F1F3", + "list.hoverBackground": "#00000014", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", - "list.focusBackground": "#0069CC1A", + "list.focusBackground": "#00000025", "list.focusForeground": "#202020", "list.focusOutline": "#0069CCFF", "list.highlightForeground": "#0069CC", @@ -144,7 +144,7 @@ "editorSuggestWidget.border": "#E4E5E6FF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", - "editorSuggestWidget.selectedBackground": "#0069CC26", + "editorSuggestWidget.selectedBackground": "#00000025", "editorSuggestWidget.selectedForeground": "#202020", "editorSuggestWidget.selectedIconForeground": "#202020", "editorHoverWidget.background": "#FAFAFD", @@ -226,6 +226,7 @@ "notificationsWarningIcon.foreground": "#B69500", "notificationsErrorIcon.foreground": "#ad0707", "notificationsInfoIcon.foreground": "#0069CC", + "problemsWarningIcon.foreground": "#895503", "activityWarningBadge.foreground": "#202020", "activityWarningBadge.background": "#F2C94C", "activityErrorBadge.foreground": "#FFFFFF", @@ -241,7 +242,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#F1F1F3", + "quickInputList.hoverBackground": "#00000014", "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#FFFFFF", @@ -282,9 +283,9 @@ "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", "inlineChat.border": "#00000000", - "minimapSlider.background": "#64646480", - "minimapSlider.hoverBackground": "#64646490", - "minimapSlider.activeBackground": "#646464A0", + "minimapSlider.background": "#646464C0", + "minimapSlider.hoverBackground": "#646464D0", + "minimapSlider.activeBackground": "#646464E0", "agents.background": "#FAFAFD", "agentsPanel.background": "#FFFFFF", @@ -295,7 +296,7 @@ "agentsChatInput.foreground": "#202020", "agentsChatInput.border": "#D8D8D8", "agentsChatInput.focusBorder": "#0069CCFF", - "agentsChatInput.placeholderForeground": "#999999", + "agentsChatInput.placeholderForeground": "#767676", "agentsNewSessionButton.background": "#00000000", "agentsNewSessionButton.foreground": "#202020", "agentsNewSessionButton.border": "#D8D8D8", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index ad62829877c..97ac84afd85 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -293,7 +293,7 @@ import { assertNoRpc, closeAllEditors } from '../utils'; throw new Error(`Timed out waiting for trust-harness title to update (target=${target}, last title=${tab.title})`); } - test('file:// inside a trusted workspace folder loads', async function () { + test.skip('file:// inside a trusted workspace folder loads', async function () { this.timeout(30_000); const folders = workspace.workspaceFolders!; @@ -303,7 +303,11 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.ok(title.startsWith('status:200'), `Expected status 200 for trusted file, got: ${title}`); }); - test('file:// outside any trusted root is blocked with 403', async function () { + // Skipped: the test runner always launches with `--disable-workspace-trust`, + // which makes the browser view trust all `file://` requests (see + // `trustAllFiles` in `IBrowserViewWindowConfiguration`). Re-enable once the + // test infrastructure supports running with Workspace Trust enabled. + test.skip('file:// outside any trusted root is blocked with 403', async function () { this.timeout(30_000); const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vscode-browser-trust-')); diff --git a/package-lock.json b/package-lock.json index 4871b2aa4c3..ac77d5005a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -77,7 +77,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", "@playwright/test": "^1.61.1", @@ -185,23 +185,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz", - "integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -210,9 +210,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz", - "integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -224,9 +224,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz", - "integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -238,13 +238,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz", - "integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -252,13 +255,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz", - "integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -266,13 +272,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz", - "integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -280,13 +289,16 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz", - "integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -294,9 +306,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz", - "integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -308,9 +320,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz", - "integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], @@ -3885,9 +3897,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", - "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz", + "integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 41823d59dca..424897c63e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.128.0", - "distro": "1ec113de3e26ba3133915ecee3b613f0b7740ede", + "distro": "a78eb8284ce7699e371b36071b579f557406f153", "author": { "name": "Microsoft Corporation" }, @@ -114,7 +114,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -161,7 +161,7 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", "@playwright/test": "^1.61.1", diff --git a/remote/.npmrc b/remote/.npmrc index 813ce0f480e..36e023c6e74 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -5,4 +5,3 @@ runtime="node" build_from_source="true" legacy-peer-deps="true" timeout=180000 -min-release-age="1" diff --git a/remote/package-lock.json b/remote/package-lock.json index ccac9bb7911..70ea3dd80f2 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -18,7 +18,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -658,9 +658,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", - "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz", + "integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index 62a1732da6a..a869563e77a 100644 --- a/remote/package.json +++ b/remote/package.json @@ -13,7 +13,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", diff --git a/src/vs/base/browser/animationSync.ts b/src/vs/base/browser/animationSync.ts new file mode 100644 index 00000000000..e962a5595ae --- /dev/null +++ b/src/vs/base/browser/animationSync.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ISynchronizeAnimationsOptions { + /** + * Also synchronize animations running on descendant elements (e.g. the dots + * of a spinner whose animations live on child nodes). Defaults to `false`. + */ + readonly subtree?: boolean; + + /** + * When provided, further narrows synchronization to CSS animations whose + * `animation-name` is in this set. Non-keyframe animations (e.g. transitions) + * are always skipped regardless of this option. + */ + readonly animationNames?: ReadonlySet; +} + +/** + * Phase-aligns looping CSS animations so that every animation of the same + * duration displays the same frame at the same time, regardless of when each + * one started. + * + * All CSS animations share the document's timeline, so anchoring each + * animation's `startTime` to the same origin (`0`) forces their `currentTime` + * to equal the timeline time — making identical animations run in lock-step. + * Per-element `animation-delay` offsets are preserved (they are part of each + * animation's own timing), so intentional cascades (e.g. spinner dots) still + * work while the group as a whole stays globally in phase. + * + * Unlike adjusting `animation-delay`, this re-seeks animations that are already + * running (Chromium does not reliably re-seek a running animation when its + * `animation-delay` changes). Call it whenever an animation (re)starts or + * resumes after being paused offscreen — e.g. from an `animationstart` handler + * or when an element scrolls back into view. + * + * @param element The element whose (and optionally whose descendants') CSS + * animations should be synchronized. + * @param options See {@link ISynchronizeAnimationsOptions}. + */ +export function synchronizeCSSAnimations(element: HTMLElement, options?: ISynchronizeAnimationsOptions): void { + if (typeof element.getAnimations !== 'function') { + return; // Web Animations API not available; leave animations as-is. + } + for (const animation of element.getAnimations({ subtree: options?.subtree })) { + // Only CSS keyframe animations carry an `animationName`; skip transitions + // and other Web Animations so this helper strictly aligns CSS animations. + const animationName = (animation as CSSAnimation).animationName; + if (animationName === undefined) { + continue; + } + if (options?.animationNames && !options.animationNames.has(animationName)) { + continue; + } + // Anchor to a shared origin so all animations of the same duration display + // the same frame. Guard against the rare state where startTime is not yet + // settable (e.g. an animation still in its pending/ready phase). + try { + animation.startTime = 0; + } catch { + // ignore + } + } +} diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 97313c3db4d..03793402374 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -36,6 +36,9 @@ export interface MarkdownRenderOptions { readonly actionHandler?: MarkdownActionHandler; + /** Rewrites parsed Markdown link and image destinations before sanitization. */ + readonly transformUri?: (href: string, kind: 'link' | 'image') => string; + readonly fillInIncompleteTokens?: boolean; readonly sanitizerConfig?: MarkdownSanitizerConfig; @@ -85,26 +88,28 @@ function getLinkTitle(href: string): string { return ''; } -const defaultMarkedRenderers = Object.freeze({ - image: ({ href, title, text }: marked.Tokens.Image): string => { - let dimensions: string[] = []; - let attributes: string[] = []; - if (href) { - ({ href, dimensions } = parseHrefAndDimensions(href)); - attributes.push(`src="${escapeDoubleQuotes(href)}"`); - } - if (text) { - attributes.push(`alt="${escapeDoubleQuotes(text)}"`); - } - if (title) { - attributes.push(`title="${escapeDoubleQuotes(title)}"`); - } - if (dimensions.length) { - attributes = attributes.concat(dimensions); - } - return ''; - }, +function renderImage({ href, title, text }: marked.Tokens.Image, transformUri?: (href: string) => string): string { + let dimensions: string[] = []; + let attributes: string[] = []; + if (href) { + ({ href, dimensions } = parseHrefAndDimensions(href)); + href = transformUri?.(href) ?? href; + attributes.push(`src="${escapeDoubleQuotes(href)}"`); + } + if (text) { + attributes.push(`alt="${escapeDoubleQuotes(text)}"`); + } + if (title) { + attributes.push(`title="${escapeDoubleQuotes(title)}"`); + } + if (dimensions.length) { + attributes = attributes.concat(dimensions); + } + return ''; +} +const defaultMarkedRenderers = Object.freeze({ + image: renderImage, paragraph(this: marked.Renderer, { tokens }: marked.Tokens.Paragraph): string { return `

${this.parser.parseInline(tokens)}

`; }, @@ -389,8 +394,11 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { const renderer = new marked.Renderer(options.markedOptions); - renderer.image = defaultMarkedRenderers.image; - renderer.link = defaultMarkedRenderers.link; + renderer.image = token => renderImage(token, href => options.transformUri?.(href, 'image') ?? href); + renderer.link = token => defaultMarkedRenderers.link.call(renderer, { + ...token, + href: options.transformUri?.(token.href, 'link') ?? token.href, + }); renderer.paragraph = defaultMarkedRenderers.paragraph; if (markdown.supportAlertSyntax) { @@ -816,27 +824,11 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par if (subtoken.type === 'text') { const lines = subtoken.raw.split('\n'); const lastLine = lines[lines.length - 1]; - if (lastLine.includes('`')) { - return completeCodespan(token); - } - else if (lastLine.includes('**')) { - return completeDoublestar(token); - } - - else if (lastLine.match(/\*\w/)) { - return completeStar(token); - } - - else if (lastLine.match(/(^|\s)__\w/)) { - return completeDoubleUnderscore(token); - } - - else if (lastLine.match(/(^|\s)_\w/)) { - return completeUnderscore(token); - } - - else if ( + // An incomplete link target must be completed before emphasis/codespan. The link is the + // innermost unfinished construct, so any emphasis marker (e.g. the `**` in `**[text](htt`) + // belongs to an enclosing span. Completing the emphasis first would leave the link broken. + if ( // Text with start of link target hasLinkTextAndStartOfLinkTarget(lastLine) || // This token doesn't have the link text, eg if it contains other markdown constructs that are in other subtokens. @@ -860,6 +852,26 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par return completeLinkTarget(token); } + else if (lastLine.includes('`')) { + return completeCodespan(token); + } + + else if (lastLine.includes('**')) { + return completeDoublestar(token); + } + + else if (lastLine.match(/\*\w/)) { + return completeStar(token); + } + + else if (lastLine.match(/(^|\s)__\w/)) { + return completeDoubleUnderscore(token); + } + + else if (lastLine.match(/(^|\s)_\w/)) { + return completeUnderscore(token); + } + // Contains the start of link text, and no following tokens contain the link target else if (lastLine.match(/(^|\s)\[\w*[^\]]*$/)) { return completeLinkText(token); @@ -871,7 +883,9 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par } function hasLinkTextAndStartOfLinkTarget(str: string): boolean { - return !!str.match(/(^|\s)\[.*\]\(\w*/); + // The `[` may be preceded by start-of-line, whitespace, or an emphasis/strikethrough marker + // (e.g. `**[text](htt`) so that links nested inside bold/italic/strikethrough are detected. + return !!str.match(/(^|\s|\*|_|~)\[.*\]\(\w*/); } function hasStartOfLinkTargetAndNoLinkText(str: string): boolean { diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index e2c68a4214e..4417777cb85 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,13 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + /** + * Temporary escape hatch for dialogs that embed widgets whose popups mount + * at window root (outside the dialog DOM). Needed because the focus trap + * would otherwise immediately reclaim focus from context views and pickers. + * See https://github.com/microsoft/vscode/issues/323920 for removal plan. + */ + readonly isExternalFocusAllowed?: (relatedTarget: HTMLElement) => boolean; readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; @@ -484,6 +491,11 @@ export class Dialog extends Disposable { this._register(addDisposableListener(this.element, 'focusout', e => { if (!!e.relatedTarget && !!this.element) { if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) { + // Temporary: let focus escape for body-level popups. + // See https://github.com/microsoft/vscode/issues/323920 + if (this.options.isExternalFocusAllowed?.(e.relatedTarget as HTMLElement)) { + return; + } this.focusToReturn = e.relatedTarget as HTMLElement; if (e.target) { diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts index 81c247cc596..14399960b8a 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getWindow, h, onDidUnregisterWindow } from '../../dom.js'; +import { synchronizeCSSAnimations } from '../../animationSync.js'; import { CodeWindow } from '../../window.js'; import { IDisposable } from '../../../common/lifecycle.js'; import './pixelSpinner.css'; @@ -57,6 +58,15 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner const PAUSED_CLASS = 'monaco-pixel-spinner-paused'; +// Keyframes names used by the spinner variants (see pixelSpinner.css). The sync +// is scoped to these so it never disturbs unrelated animations/transitions +// (e.g. the icon cross-fade) that may run on the same subtree. +const SPINNER_ANIMATION_NAMES = new Set([ + 'monaco-pixel-spinner-dot-cycle', + 'monaco-pixel-spinner-dot-cycle-long', + 'monaco-pixel-spinner-dot-cycle-short', + 'monaco-pixel-spinner-ring-pulse', +]); const observersByWindow = new Map(); let unregisterWindowListener: IDisposable | undefined; @@ -67,6 +77,11 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi let observer = observersByWindow.get(targetWindow); if (!observer) { observer = new targetWindow.IntersectionObserver(entries => { + // Two passes so all style writes happen before any style read: the + // pause-class toggles below dirty style, and `getAnimations()` in the + // sync pass flushes it. Interleaving them would force a style recalc + // per entry instead of one for the whole batch. + const toResync: HTMLElement[] = []; for (const entry of entries) { const target = entry.target as HTMLElement; if (!target.isConnected) { @@ -74,6 +89,16 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi continue; } target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting); + if (entry.isIntersecting) { + toResync.push(target); + } + } + // Re-sync resumed spinners to the shared timeline: while paused + // offscreen the animation froze and its startTime drifted from + // spinners that kept running. Anchor it back (now that it is running + // again) so all visible spinners display the same frame. + for (const target of toResync) { + synchronizeCSSAnimations(target, { subtree: true, animationNames: SPINNER_ANIMATION_NAMES }); } }); observersByWindow.set(targetWindow, observer); @@ -101,4 +126,3 @@ function trackSpinner(root: HTMLElement): void { root.classList.add(PAUSED_CLASS); observer.observe(root); } - diff --git a/src/vs/base/common/uriTemplate.ts b/src/vs/base/common/uriTemplate.ts index 4734dc0d9f1..f425f810a28 100644 --- a/src/vs/base/common/uriTemplate.ts +++ b/src/vs/base/common/uriTemplate.ts @@ -298,7 +298,7 @@ function pctEncode(str: string): string { ) { out += str[i]; } else { - out += '%' + chr.toString(16).toUpperCase(); + out += '%' + chr.toString(16).toUpperCase().padStart(2, '0'); } } return out; diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index 3ac03201b32..eec544d728f 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -52,6 +52,34 @@ suite('MarkdownRenderer', () => { assert.ok(anchor, 'expected to be preserved when scheme is allowed'); assert.strictEqual(anchor!.dataset.href, 'vscode-agent-host://my-host/path/to/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0'); }); + + test('Transforms parsed link targets without changing labels, titles, or code', () => { + const markdown = { value: '`[same](file:///same)` [a[b].ts](file:///same "file:///same") ![image](file:///same|width=10,height=20)' }; + const result = store.add(renderMarkdown(markdown, { + transformUri: href => href === 'file:///same' ? 'https://example.com/a.ts' : href, + })).element; + const anchor = result.querySelector('a'); + assert.deepStrictEqual( + { + anchorCount: result.querySelectorAll('a').length, + text: anchor?.textContent, + href: anchor?.dataset.href, + title: anchor?.title, + image: result.querySelector('img')?.src, + imageWidth: result.querySelector('img')?.getAttribute('width'), + imageHeight: result.querySelector('img')?.getAttribute('height'), + }, + { + anchorCount: 1, + text: 'a[b].ts', + href: 'https://example.com/a.ts', + title: 'file:///same', + image: 'https://example.com/a.ts', + imageWidth: '10', + imageHeight: '20', + }, + ); + }); }); suite('Images', () => { @@ -818,6 +846,26 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); + test('list with bold incomplete link target', () => { + const incomplete = `- list item one +- **[link](http://microsoft`; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + + test('ordered list with bold incomplete link target', () => { + const incomplete = `1. list item one +2. **[link](http://microsoft`; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + test('list with incomplete subitem', () => { const incomplete = `1. list item one - `; @@ -1063,7 +1111,7 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); - test.skip('incomplete link in list', () => { + test('incomplete link in list', () => { const incomplete = '- [text'; const tokens = marked.marked.lexer(incomplete); const newTokens = fillInIncompleteTokens(tokens); @@ -1072,6 +1120,24 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); + test('incomplete link target inside bold', () => { + const incomplete = '**[text](http://microsoft'; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + + test('incomplete link target with arg inside bold', () => { + const incomplete = '**[text](http://microsoft.com "more text '; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + '")**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + test('square brace between letters', () => { const incomplete = 'a[b'; const tokens = marked.marked.lexer(incomplete); diff --git a/src/vs/base/test/common/uriTemplate.test.ts b/src/vs/base/test/common/uriTemplate.test.ts index 0165f0aa4a4..fbe0ad8d1d7 100644 --- a/src/vs/base/test/common/uriTemplate.test.ts +++ b/src/vs/base/test/common/uriTemplate.test.ts @@ -103,6 +103,14 @@ suite('UriTemplate', () => { testResolution('{hello}', variables, 'Hello%20World%21'); }); + test('control characters are percent-encoded with two hex digits', () => { + // Code points below 0x10 must be zero-padded (e.g. %09, not %9) so the + // output is a valid percent-encoding that decodeURIComponent accepts. + testResolution('{x}', { x: 'a\tb' }, 'a%09b'); + testResolution('{x}', { x: '\n' }, '%0A'); + testResolution('{x}', { x: '\r' }, '%0D'); + }); + test('Level 2 - Reserved expansion', () => { // Test cases from RFC 6570 Section 1.2 const variables = { diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index b97d0efc3e6..3823d3ff535 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1178,7 +1178,7 @@ export class CodeApplication extends Disposable { // `chat.agentHost.enabled` resolves to `true` there (honoring experiment // overrides + policy + web), which the main process cannot observe since // experiment overrides are never persisted to `settings.json`. - const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); + const agentHostStarter = new ElectronAgentHostStarter({ machineId, sqmId, devDeviceId }, this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); // External terminal diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index bd6af43721e..1f6bb2ce525 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -218,6 +218,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< const instantiationService = this._register(this._instantiationService.createChild(new ServiceCollection([IContextKeyService, this._contextKeyService]))); this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.actions, MenuId.MultiDiffEditorFileToolbar, { actionRunner: this._register(new ActionRunnerWithContext(() => (this._viewModel.get()?.modifiedUri ?? this._viewModel.get()?.originalUri))), + highlightToggledItems: true, menuOptions: { shouldForwardArgs: true, }, diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 01cf1197415..5ea935f1f73 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -62,6 +62,18 @@ export class MultiDiffEditorViewModel extends Disposable { }); } + public collapse(item: DocumentDiffItemViewModel): void { + transaction(tx => { + item.collapsed.set(true, tx); + }); + } + + public expand(item: DocumentDiffItemViewModel): void { + transaction(tx => { + item.collapsed.set(false, tx); + }); + } + public get contextKeys(): Record | undefined { return this.model.contextKeys; } diff --git a/src/vs/editor/contrib/hover/browser/contentHoverController.ts b/src/vs/editor/contrib/hover/browser/contentHoverController.ts index ba0e7d0b161..afaa209d933 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverController.ts @@ -151,7 +151,7 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _isMouseOnContentHoverWidget(mouseEvent: IPartialEditorMouseEvent): boolean { - if (!this._contentWidget) { + if (!this._contentWidget || !this._contentWidget.getDomNode().isConnected) { return false; } return isMousePositionWithinElement(this._contentWidget.getDomNode(), mouseEvent.event.posx, mouseEvent.event.posy); diff --git a/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts b/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts index 0b1493d16b7..623b9b39388 100644 --- a/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts +++ b/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as strings from '../../../../base/common/strings.js'; import { EditOperation, ISingleEditOperation } from '../../../common/core/editOperation.js'; import { Position } from '../../../common/core/position.js'; import { Selection } from '../../../common/core/selection.js'; @@ -40,13 +39,6 @@ export class InsertFinalNewLineCommand implements ICommand { */ export function insertFinalNewLine(model: ITextModel): ISingleEditOperation | undefined { const lineCount = model.getLineCount(); - const lastLine = model.getLineContent(lineCount); - const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1; - - if (!lineCount || lastLineIsEmptyOrWhitespace) { - return; - } - return EditOperation.insert( new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL() diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index e6a32fc09f2..9461288c881 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -49,6 +49,7 @@ export const enum AccessibleViewProviderId { SessionsChat = 'sessionsChat', SessionsChanges = 'sessionsChanges', Survey = 'survey', + Automations = 'automations', } export const enum AccessibleViewType { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 8e60718f9ea..fc496387e57 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -100,6 +100,11 @@ export class MenuId { static readonly EmptyEditorGroupContext = new MenuId('EmptyEditorGroupContext'); static readonly EditorGroupWatermarkToolbar = new MenuId('EditorGroupWatermarkToolbar'); static readonly EditorTabsBarContext = new MenuId('EditorTabsBarContext'); + /** + * Menu whose actions populate the editor tab bar's "+" (Add Tab) dropdown. + * The button is rendered by core and is shown only when this menu has actions. + */ + static readonly EditorTabsBarAddTab = new MenuId('EditorTabsBarAddTab'); static readonly EditorTabsBarShowTabsSubmenu = new MenuId('EditorTabsBarShowTabsSubmenu'); static readonly EditorTabsBarShowTabsZenModeSubmenu = new MenuId('EditorTabsBarShowTabsZenModeSubmenu'); static readonly EditorActionsPositionSubmenu = new MenuId('EditorActionsPositionSubmenu'); @@ -265,6 +270,7 @@ export class MenuId { static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); + static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); diff --git a/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts b/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts new file mode 100644 index 00000000000..87ec2920249 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IRequestService } from '../../request/common/request.js'; + +/** + * IPC channel name used for in-process agent-host → renderer reverse proxy + * resolution RPCs. The renderer registers a server channel under this name on + * its `MessagePortClient`; the agent host reaches it via + * `server.getChannel(name, c => c.ctx === clientId)` on its + * `UtilityProcessServer`. + * + * Mirrors {@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL} for the reverse BYOK bridge. + */ +export const AGENT_HOST_CLIENT_PROXY_CHANNEL = 'agentHostClientProxy'; + +/** + * Node end of the proxy-resolution bridge: `resolveProxy()` ships the target + * URL to the renderer and resolves with the *raw* result of VS Code's + * `IRequestService.resolveProxy` (the Electron session PAC-style string, e.g. + * `PROXY host:port` / `DIRECT`). The node side feeds this into + * `@vscode/proxy-agent`'s `resolveProxyURL` to derive the final proxy URL. + */ +export interface IAgentHostClientProxyConnection { + resolveProxy(url: string): Promise; +} + +/** + * Wraps an {@link IChannel} (obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an {@link IAgentHostClientProxyConnection}. + */ +export function createAgentHostClientProxyConnection(channel: IChannel): IAgentHostClientProxyConnection { + return { + resolveProxy: (url) => channel.call('resolveProxy', { url }) as Promise, + }; +} + +/** + * Server-side channel for in-process reverse proxy-resolution RPCs from the + * local agent host. Thin adapter — forwards `resolveProxy` calls to the + * renderer's {@link IRequestService}, which resolves the proxy through the + * Electron session (system proxy settings, PAC scripts, etc.). The raw result + * is returned verbatim; the node side derives the proxy URL from it. + */ +export class AgentHostClientProxyChannel implements IServerChannel { + + constructor( + @IRequestService private readonly _requestService: IRequestService, + ) { } + + listen(_ctx: unknown, event: string): Event { + throw new Error(`No event '${event}' on AgentHostClientProxyChannel`); + } + + async call(_ctx: unknown, command: string, arg?: unknown): Promise { + switch (command) { + case 'resolveProxy': { + const { url } = arg as { url: string }; + const proxy = await this._requestService.resolveProxy(url); + return proxy as T; + } + } + throw new Error(`Unknown command '${command}' on AgentHostClientProxyChannel`); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 8d6f1622c41..2832ebf3366 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -16,6 +16,19 @@ import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js'; */ export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch'; +/** + * Resolves the Branch Changes base-branch **name** from its two sources, in + * precedence order: the agent-persisted {@link META_DIFF_BASE_BRANCH} metadata + * value, then the session git state's detected base branch. Returns `undefined` + * when neither is available (callers then anchor the diff at `HEAD`). + * + * Shared by {@link IAgentHostChangesetService} and the review service so both + * pick the same base branch. + */ +export function resolveDiffBaseBranchName(persistedBaseBranch: string | undefined, sessionGitStateBaseBranch: string | undefined): string | undefined { + return persistedBaseBranch ?? sessionGitStateBaseBranch; +} + /** * The well-known SHA-1 of git's empty tree, used as a fallback when a * repository has no commits (no `HEAD` to read into the temp index). @@ -161,6 +174,18 @@ export interface IAgentHostGitService { */ computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise; + /** + * Resolves the commit-ish the **Branch Changes** baseline is measured from: + * the merge-base of `HEAD` and `baseBranch` (preferring the + * `origin/` remote-tracking ref when it exists), falling back to + * `HEAD`, then to the empty-tree object for a repo with no commits. Returns + * `undefined` only when {@link workingDirectory} is not a git work tree. + * + * Shared by {@link computeSessionFileDiffs} (which anchors the Branch Changes + * diff here) and the review service, so both agree on the exact baseline. + */ + resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise; + /** * Reads a single git blob via `git show :` from * the given working directory. Returns `undefined` when the blob does @@ -202,6 +227,28 @@ export interface IAgentHostGitService { */ revParse(repositoryRoot: URI, expression: string): Promise; + /** + * Builds a new tree from `baseTreeOid` in which the single repo-relative + * `path` is replaced by its content (blob + mode) from `sourceTreeOid`, or + * removed when the path is absent in `sourceTreeOid`. All other paths are + * copied verbatim from `baseTreeOid`. Uses a throwaway `GIT_INDEX_FILE` so + * the user's real index is untouched. Returns the new tree OID, or + * `undefined` on git failure. + * + * File-level building block for review (see `IAgentHostReviewService`): to + * mark a file reviewed, overlay it from the working-tree snapshot tree; to + * unmark, overlay it from the baseline tree. + */ + overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise; + + /** + * Returns the repo-relative paths that differ between two tree-ish (commit + * or tree) objects via `git diff --name-only --no-renames -z`. Rename + * detection is off so a rename shows as delete(old) + add(new). Returns + * `undefined` on git failure (e.g. not a git work tree). + */ + diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise; + /** * Computes per-file diffs between two refs (typically two consecutive * checkpoint refs) by shelling out to diff --git a/src/vs/platform/agentHost/common/agentHostReviewService.ts b/src/vs/platform/agentHost/common/agentHostReviewService.ts new file mode 100644 index 00000000000..fecdb33ae62 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostReviewService.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import type { URI as ProtocolURI } from './state/sessionState.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IAgentHostReviewService = createDecorator('agentHostReviewService'); + +/** + * Returns the canonical name for a session's synthetic **reviewed** ref. + * Lives under the same `refs/agents//…` namespace as checkpoint refs so + * the two coexist safely and never surface to the user as branches/tags. + */ +export function buildReviewedRefName(sanitizedSessionId: string): string { + return `refs/agents/${sanitizedSessionId}/reviewed`; +} + +/** + * Tracks which files in a session's **Branch Changes** the user has reviewed, + * as a session-private synthetic git ref (`refs/agents//reviewed`) whose + * tree snapshots the reviewed content. A file is reviewed when its content in + * the reviewed tree matches the current working tree; re-editing a reviewed + * file therefore auto-unreviews it. + * + * All operations are keyed on the Branch Changes baseline (the merge-base of + * `HEAD` and the session's base branch). The `baseBranch` argument is the + * already-resolved base-branch **name** (see `resolveDiffBaseBranchName`), + * shared with the changeset service so both agree on the baseline. + * + * Operations are no-ops when the working directory is not inside a git + * repository; a future milestone will add a non-git fallback (see the + * DB-backed reviewed-file store on `ISessionDatabase`). + */ +export interface IAgentHostReviewService { + readonly _serviceBrand: undefined; + + /** + * Marks a single file reviewed at its current working-tree content by + * overlaying that content into the reviewed tree and advancing the + * reviewed ref. No-op when the file is already reviewed at that content. + */ + markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise; + + /** + * Marks a single file as unreviewed by resetting its entry in the + * reviewed tree back to the baseline content and advancing the reviewed + * ref. No-op when the file is not currently reviewed. + */ + markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise; + + /** + * Returns the set of reviewed repo-relative paths within the current Branch + * Changes: the changed files whose reviewed-tree content matches the + * working tree. Empty when nothing is reviewed or the directory is not a + * git work tree. + */ + getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise>; + + /** + * Copies the reviewed ref from `sourceSessionUri` to `targetSessionUri` so a + * forked session starts with the parent's review progress. Points the + * target's reviewed ref at the same commit as the source's (git objects are + * shared within the repository). No-op when the source has no reviewed ref or + * the directory is not a git work tree. + */ + copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise; +} + +/** + * A no-op {@link IAgentHostReviewService} used as the default for the optional + * `_reviewService` parameter on `AgentService` so existing test callsites keep + * compiling without forced fixture updates. + */ +export const NULL_REVIEW_SERVICE: IAgentHostReviewService = { + _serviceBrand: undefined, + markFileReviewed: async () => { }, + markFileUnreviewed: async () => { }, + getReviewedPaths: async () => new Set(), + copyReviewedRef: async () => { }, +}; diff --git a/src/vs/platform/agentHost/common/agentHostSessionType.ts b/src/vs/platform/agentHost/common/agentHostSessionType.ts index 8788abcd3da..c85f425305b 100644 --- a/src/vs/platform/agentHost/common/agentHostSessionType.ts +++ b/src/vs/platform/agentHost/common/agentHostSessionType.ts @@ -57,6 +57,22 @@ function isRemoteAgentHostSessionTypeForAuthority(sessionType: string, connectio return !!connectionAuthority && sessionType.startsWith(remoteAgentHostSessionTypeAuthorityPrefix(connectionAuthority)); } +/** + * Extracts the harness/provider suffix from a remote agent host session type. + * + * Remote session types are formatted as `remote-{authority}-{provider}`. The + * authority may contain `-`, but provider names do not, so the harness is the + * final `-`-delimited segment. Returns `undefined` for non-remote session types. + */ +export function parseRemoteAgentHostHarness(sessionType: string): string | undefined { + if (!isRemoteAgentHostSessionType(sessionType)) { + return undefined; + } + const lastDash = sessionType.lastIndexOf('-'); + const harness = sessionType.slice(lastDash + 1); + return harness || undefined; +} + /** * Extracts the connection authority from a remote agent host session type when the provider is known. */ diff --git a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts new file mode 100644 index 00000000000..d55d5e33af6 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Environment variables used to forward the host's resolved telemetry + * identifiers into the agent host process. + * + * The agent host runs in its own utility process and would otherwise compute + * its own `machineId`/`devDeviceId` live from the MAC address / device-id store + * on every launch. That can diverge from the workbench's persisted, state-backed + * identifiers (e.g. when `state.json` was seeded by imaging/migration, or when + * the "first valid MAC" changes), breaking per-user joins across event sources. + * + * To keep the identifiers consistent, the local starter + * (`ElectronAgentHostStarter`, which runs in the main process where these are + * already resolved) forwards them via these env vars, and + * `createAgentHostTelemetryService` prefers them over recomputing. + */ +export const AgentHostMachineIdEnvKey = 'VSCODE_AGENT_HOST_MACHINE_ID'; +export const AgentHostSqmIdEnvKey = 'VSCODE_AGENT_HOST_SQM_ID'; +export const AgentHostDevDeviceIdEnvKey = 'VSCODE_AGENT_HOST_DEV_DEVICE_ID'; + +export interface IAgentHostForwardedTelemetryIds { + readonly machineId: string; + readonly sqmId: string; + readonly devDeviceId: string; +} + +/** + * Builds the env var bag that forwards the resolved telemetry identifiers to + * the agent host process. Empty identifiers are omitted so the host falls back + * to computing them itself. + */ +export function buildAgentHostTelemetryIdEnv(ids: IAgentHostForwardedTelemetryIds): Record { + const env: Record = {}; + if (ids.machineId) { + env[AgentHostMachineIdEnvKey] = ids.machineId; + } + if (ids.sqmId) { + env[AgentHostSqmIdEnvKey] = ids.sqmId; + } + if (ids.devDeviceId) { + env[AgentHostDevDeviceIdEnvKey] = ids.devDeviceId; + } + return env; +} diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 11b89076a33..8ee8016e821 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -19,7 +19,7 @@ import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; +import type { ActionEnvelope, AuthRequiredParams, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; @@ -1481,6 +1481,15 @@ export interface IAgent { */ readonly onDidCustomizationsChange?: Event; + /** + * Fires when this agent needs the client to (re-)authenticate a + * protected resource — for example after a runtime transport-mode flip + * makes a previously-unneeded credential required. The host stamps the + * root channel and forwards it verbatim as an `auth/required` + * notification; clients respond via {@link authenticate}. + */ + readonly onDidRequireAuth?: Event>; + /** * Returns the host-owned customizations this agent currently exposes. * @@ -1509,11 +1518,16 @@ export interface IAgent { handleAuthenticationToken?(params: AuthenticateParams): Promise; /** - * Truncate a session's history. If `turnId` is provided, keeps turns up to + * Truncate a chat's history. If `turnId` is provided, keeps turns up to * and including that turn. If omitted, all turns are removed. + * + * `chat` identifies which chat to truncate: the session's default chat + * (addressed by the session's default chat URI) or a peer (non-default) + * chat, which has its own backing. + * * Optional — not all providers support truncation. */ - truncateSession?(session: URI, turnId?: string): Promise; + truncateSession?(session: URI, turnId: string | undefined, chat: URI): Promise; /** * Notifies the provider that a session's archived state has changed. diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 5795e915a62..80e182e8c89 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -294,7 +294,18 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig */ export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessionGitState): Changeset[] { if (!gitState) { - return []; + return [{ + label: sessionChangesetLabel(), + description: sessionChangesetDescription(), + uriTemplate: buildSessionChangesetUri(sessionUri), + changeKind: ChangesetKind.Session + }, + { + label: thisTurnChangesetLabel(), + description: thisTurnChangesetDescription(), + uriTemplate: buildTurnChangesetUriTemplate(sessionUri), + changeKind: ChangesetKind.Turn + }] satisfies Changeset[]; } return [ @@ -330,5 +341,5 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessio uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), changeKind: ChangesetKind.Compare } - ]; + ] satisfies Changeset[]; } diff --git a/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts b/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts new file mode 100644 index 00000000000..a072fc84bb8 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Mutable } from '../../../../base/common/types.js'; +import { ChangesetFile } from '../state/sessionState.js'; + +interface IChangesetFileMeta { + readonly reviewed?: boolean; +} + +export function readChangesetFileMeta(source: ChangesetFile): IChangesetFileMeta | undefined { + const meta = source._meta; + if (!meta) { + return undefined; + } + + const result: Mutable = {}; + if (typeof meta['reviewed'] === 'boolean') { result.reviewed = meta['reviewed']; } + + return result; +} diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 4800495bc63..33bc7e3b597 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -60,8 +60,45 @@ export interface IFileEditContent { afterContent?: Uint8Array; } +// ---- Reviewed-file types ------------------------------------------------ + +/** + * A record of a file having been reviewed by the user at a specific content + * nonce. Returned by {@link ISessionDatabase.getReviewedFiles} and + * {@link ISessionDatabase.getReviewedFilesForUri}. + */ +export interface IReviewedFileRecord { + /** The reviewed file. */ + uri: URI; + /** Content version/hash captured at review time. */ + nonce: string; +} + // ---- Session database --------------------------------------------------- +/** + * A host-injected ("local") turn: a completed protocol `Turn` the agent SDK + * never saw — e.g. the `/rename` acknowledgement or a `!command` terminal run. + * These are persisted separately from SDK turns so they survive reload, and are + * interleaved back into the SDK-derived turns on restore. + */ +export interface ILocalTurnRecord { + /** The local turn's id (matches the payload `Turn.id`). */ + turnId: string; + /** The chat this local turn belongs to (its channel URI string). */ + chatUri: string; + /** + * Id of the preceding concrete (SDK-backed) turn this local turn is + * anchored after, or `undefined` when it precedes any real turn. + */ + anchorTurnId: string | undefined; + /** Monotonic ordering among local turns (used to interleave on restore). */ + seq: number; + /** JSON-serialized protocol `Turn`. */ + payload: string; +} + + /** * A disposable handle to a per-session SQLite database backed by * `@vscode/sqlite3`. @@ -150,6 +187,25 @@ export interface ISessionDatabase extends IDisposable { */ deleteAllTurns(): Promise; + // ---- Local (host-injected) turns ------------------------------------- + + /** + * Persist a host-injected local turn (e.g. `/rename` or `!command`). + * Replaces any existing record with the same `turnId`. + */ + insertLocalTurn(record: ILocalTurnRecord): Promise; + + /** + * Retrieve all persisted local turns in this session, in `seq` order. + * Callers filter by {@link ILocalTurnRecord.chatUri} for a given chat. + */ + getLocalTurns(): Promise; + + /** + * Delete the local turns with the given ids. Ids not present are ignored. + */ + deleteLocalTurns(turnIds: readonly string[]): Promise; + /** * Store a file-edit snapshot (metadata + content) for a tool invocation * within a turn. @@ -220,6 +276,36 @@ export interface ISessionDatabase extends IDisposable { */ remapTurnIds(mapping: ReadonlyMap): Promise; + // ---- Reviewed files -------------------------------------------------- + + /** + * Mark a file (identified by URI + content nonce) as reviewed by the user. + * Idempotent — re-marking the same `(uri, nonce)` pair is a no-op. + */ + markFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Remove the reviewed-file entry for the given URI + content nonce. + * No-op if no such entry exists. + */ + unmarkFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Return every reviewed-file entry in this session, in insertion order. + */ + getReviewedFiles(): Promise; + + /** + * Return all reviewed-file entries for a specific URI (one per reviewed + * content nonce), in insertion order. + */ + getReviewedFilesForUri(uri: URI): Promise; + + /** + * Return whether the given file has been reviewed at the given content nonce. + */ + isFileReviewed(uri: URI, nonce: string): Promise; + /** * Creates a safe, consistent copy of the database at the given path * using SQLite's `VACUUM INTO` command. diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 04dbd2e4eb4..536f7a19961 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -30,6 +30,7 @@ import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; @@ -439,9 +440,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos /** * Register the reverse-RPC server channels every in-process renderer exposes to * the agent host's {@link UtilityProcessServer}: the filesystem resource bridge - * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}) and the BYOK language-model - * bridge ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches - * these via `server.getChannel(name, c => c.ctx === clientId)`. + * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}), the proxy-resolution bridge + * ({@link AGENT_HOST_CLIENT_PROXY_CHANNEL}), and the BYOK language-model bridge + * ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches these via + * `server.getChannel(name, c => c.ctx === clientId)`. */ export function registerAgentHostClientChannels( client: IChannelServer, @@ -454,6 +456,10 @@ export function registerAgentHostClientChannels( // registers an authority on its AgentHostClientFileSystemProvider that calls // back through this channel. client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, instantiationService.createInstance(AgentHostClientResourceChannel, ahpLogger)); + // Serve proxy-resolution reverse-RPCs from the renderer's request service so + // the agent host resolves proxies through VS Code's Electron session (system + // settings / PAC scripts) rather than guessing from environment variables. + client.registerChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL, instantiationService.createInstance(AgentHostClientProxyChannel)); // Serve BYOK language-model reverse-RPCs from the renderer LM API, gated // behind `chat.agentHost.byokModels.enabled`. When disabled, the node-side // proxy + registry are also skipped, so the channel would never be called. diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index bb711f6d895..f82298d7679 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,6 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; +import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js'; import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; @@ -44,6 +45,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt private _otelPolicyFromRenderer: IAgentHostOTelSettings | undefined = undefined; constructor( + private readonly _telemetryIds: IAgentHostForwardedTelemetryIds, @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, @@ -134,6 +136,11 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt args.push('--disable-telemetry'); } + // Forward the host's resolved telemetry identifiers so the agent host + // reuses the same persisted machineId/sqmId/devDeviceId instead of + // recomputing them live (which can diverge). See `agentHostTelemetryEnv`. + const telemetryIdEnv = buildAgentHostTelemetryIdEnv(this._telemetryIds); + this.utilityProcess.start({ type: 'agentHost', name: 'agent-host', @@ -148,6 +155,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_VERBOSE_LOGGING: 'true', ...sdkEnv, ...otelEnv, + ...telemetryIdEnv, } }); diff --git a/src/vs/platform/agentHost/node/agentHostBangCommand.ts b/src/vs/platform/agentHost/node/agentHostBangCommand.ts new file mode 100644 index 00000000000..df6522eaf4f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostBangCommand.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The leading character that marks a chat message as a terminal command. + */ +export const BANG_COMMAND_PREFIX = '!'; + +/** + * Parses a leading `!` at the very start of `prompt`. + * + * Like {@link parseRenameCommand}, the marker must be at position 0 (no leading + * whitespace). A lone `!` or `!` followed only by whitespace is not treated as + * a bang command — the caller should forward such messages normally. + * + * Returns the trimmed command string when the prompt is a bang command, or + * `undefined` when it is not. + */ +export function parseBangCommand(prompt: string): string | undefined { + if (!prompt.startsWith(BANG_COMMAND_PREFIX)) { + return undefined; + } + const command = prompt.slice(BANG_COMMAND_PREFIX.length).trim(); + return command.length > 0 ? command : undefined; +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index ca5e2ef33b7..931379be5d7 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -33,7 +33,7 @@ import { } from '../common/state/sessionState.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../common/agentHostGitService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js'; @@ -41,6 +41,8 @@ import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js'; import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { relativePath } from '../../../base/common/resources.js'; function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI { return kind === 'branch' @@ -163,6 +165,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, ) { super(); this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); @@ -808,7 +811,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } - this._publishChangesetDiffs(session, changesetUri, diffs); + const reviewed = kind === ChangesetKind.Branch + ? await this._computeReviewedInfo(session, ref.object) + : undefined; + this._publishChangesetDiffs(session, changesetUri, diffs, reviewed); // Persist the file list so a subsequent `listSessions` / // `restoreSession` can reseed the changeset before the first @@ -881,7 +887,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * (fileSet, fileRemoved) and moves the changeset to `ready` once the * fresh file list has been applied. */ - private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void { + private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet }): void { // Get the available operations for this changeset. This call assumes that at this point // the git state of the session is up-to-date as it is being used to determine the available // operations. Long term this should be replaced with a more robust mechanism. @@ -893,7 +899,15 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC if (!id) { continue; } - files.push({ id, edit }); + if (reviewed) { + // Surface per-file review status for the Branch changeset. The + // file id is a `file:` URI under the repository root, so the + // repo-relative path keys into the reviewed-paths set. + const relPath = relativePath(reviewed.repoRoot, URI.parse(id)); + files.push({ id, edit, _meta: { reviewed: relPath ? reviewed.paths.has(relPath) : false } }); + } else { + files.push({ id, edit }); + } } this._stateManager.dispatchServerAction(changesetUri, { @@ -1032,12 +1046,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // Branch - const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); - const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName; - const baseBranch = persistedBaseBranch ?? gitStateBaseBranch; - if (!persistedBaseBranch && gitStateBaseBranch) { - this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`); - } + const baseBranch = await this._resolveBranchBaseBranch(session, db); try { return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { @@ -1050,6 +1059,49 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + /** + * Resolves the Branch Changes base branch, reused by the diff computation + * and the review-status lookup so both are keyed on the same baseline. + */ + private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise { + const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); + const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName; + if (!persistedBaseBranch && gitStateBaseBranch) { + this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`); + } + return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch); + } + + /** + * Computes the reviewed-paths overlay for the Branch changeset: the + * repository root (used to key file ids to repo-relative paths) and the set + * of reviewed repo-relative paths. Returns `undefined` when the session has + * no git working directory (review status is then simply omitted). + */ + private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet } | undefined> { + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + if (!workingDirectory) { + return undefined; + } + + let workingDirectoryUri: URI; + try { + workingDirectoryUri = URI.parse(workingDirectory); + } catch { + return undefined; + } + + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repoRoot) { + return undefined; + } + + const baseBranch = await this._resolveBranchBaseBranch(session, db); + const paths = await this._reviewService.getReviewedPaths(session, workingDirectoryUri, baseBranch); + + return { repoRoot, paths }; + } + /** * Persists a session metadata key/value pair to the session database. * Counterpart in `agentSideEffects.ts` (`AgentSideEffects._persistSessionFlag`): diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index db63ec242fe..5fcd9798a52 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -216,24 +216,8 @@ export class AgentHostGitService implements IAgentHostGitService { return undefined; } - // Resolve the merge-base commit. With a base branch, prefer the - // corresponding origin/ remote-tracking ref when it exists so - // branch changes match a PR-style comparison even if the local base - // branch is stale. Without a usable base, fall back to HEAD itself, - // which surfaces uncommitted work but no committed-on-branch work - - // the best we can do without context. For empty repos with no HEAD, - // fall back to the well-known empty-tree object. - let mergeBaseCommit: string | undefined; - if (options.baseBranch) { - const baseBranch = await this._resolveRemoteTrackingBranch(repositoryRoot, options.baseBranch) ?? options.baseBranch; - mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', baseBranch]))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = EMPTY_TREE_OBJECT; - } + // Resolve the merge-base commit the Branch Changes diff is anchored on. + const mergeBaseCommit = await this._resolveBranchMergeBaseCommit(repositoryRoot, options.baseBranch); // Detect whether the working tree has any untracked files. If so we // have to use the temp-index trick so the untracked content is @@ -260,6 +244,38 @@ export class AgentHostGitService implements IAgentHostGitService { return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit); } + async resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + + return this._resolveBranchMergeBaseCommit(repositoryRoot, baseBranch); + } + + /** + * Resolves the merge-base commit-ish the Branch Changes baseline is anchored + * on. With a base branch, prefers the corresponding `origin/` + * remote-tracking ref when it exists so branch changes match a PR-style + * comparison even if the local base branch is stale. Without a usable base, + * falls back to `HEAD` (surfaces uncommitted work but no committed-on-branch + * work). For empty repos with no `HEAD`, falls back to the empty-tree object. + * Always resolves to a commit-ish (never `undefined`) once the repository + * root is known. + */ + private async _resolveBranchMergeBaseCommit(repositoryRoot: URI, baseBranch?: string): Promise { + let mergeBaseCommit: string | undefined; + if (baseBranch) { + const resolvedBase = await this._resolveRemoteTrackingBranch(repositoryRoot, baseBranch) ?? baseBranch; + mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', resolvedBase]))?.trim(); + } + if (!mergeBaseCommit) { + mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim(); + } + + return mergeBaseCommit ?? EMPTY_TREE_OBJECT; + } + private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string, changedPaths: readonly string[]): Promise { // Build a throwaway index so we can stage the changed working tree // paths (including untracked files) without disturbing the user's real @@ -405,6 +421,58 @@ export class AgentHostGitService implements IAgentHostGitService { return out?.trim() || undefined; } + async overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise { + // Build a throwaway index seeded from `baseTreeOid`, replace/remove the + // single `path` using `sourceTreeOid`, and write the result back out as + // a new tree. The user's real index is never touched (mirrors the + // temp-index technique used by `captureWorkingTreeAsTree`). + const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-review-overlay-${generateUuid()}`); + await this._fileService.createFolder(tempDir); + const indexFile = URI.joinPath(tempDir, 'index').fsPath; + const env: Record = { GIT_INDEX_FILE: indexFile, COMMAND_HOOK_LOCK: '1' }; + + try { + const readTreeOut = await this._runGit(repositoryRoot, ['read-tree', baseTreeOid], { env, throwOnError: false }); + if (readTreeOut === undefined) { + return undefined; + } + + // Resolve the source blob (mode + oid) for `path`. `-z` avoids + // path quoting; an empty result means the path is absent in the + // source tree, so the overlay removes it from the base. + const lsTreeOut = await this._runGit(repositoryRoot, ['ls-tree', '-z', sourceTreeOid, '--', path], { env }); + const entry = parseSingleLsTreeEntry(lsTreeOut); + if (entry) { + const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`], { env, throwOnError: false }); + if (updateIndexOut === undefined) { + return undefined; + } + } else { + // `--force-remove` tolerates the path already being absent from + // the index, so removing an untracked/added path is a no-op. + const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--force-remove', '--', path], { env, throwOnError: false }); + if (updateIndexOut === undefined) { + return undefined; + } + } + + const writeTreeOut = await this._runGit(repositoryRoot, ['write-tree'], { env }); + return writeTreeOut?.trim(); + } finally { + try { + await this._fileService.del(tempDir, { recursive: true, useTrash: false }); + } catch { /* best-effort */ } + } + } + + async diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise { + const out = await this._runGit(repositoryRoot, ['diff', '--name-only', '--no-renames', '-z', fromTreeish, toTreeish, '--']); + if (out === undefined) { + return undefined; + } + return out.split('\x00').filter(Boolean); + } + async computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise { const repositoryRoot = await this.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { @@ -626,6 +694,30 @@ export function parseChangedPaths(output: string | undefined, includeStatus: (st return result; } +/** + * Parses NUL-terminated `git ls-tree -z -- ` output for a single + * path and returns its `{ mode, oid }`, or `undefined` when the path is absent + * from the tree (empty output). Each entry has the form + * ` SP SP TAB NUL`; we only need the mode and oid. + * + * Exported for tests. + */ +export function parseSingleLsTreeEntry(output: string | undefined): { mode: string; oid: string } | undefined { + if (!output) { + return undefined; + } + const entry = output.split('\x00')[0]; + if (!entry) { + return undefined; + } + const tabIndex = entry.indexOf('\t'); + const meta = (tabIndex === -1 ? entry : entry.substring(0, tabIndex)).split(' '); + if (meta.length < 3) { + return undefined; + } + return { mode: meta[0], oid: meta[2] }; +} + /** * Parses combined `--raw --numstat -z` output produced by * {@link IAgentHostGitService.computeSessionFileDiffs} and converts each diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 8f5fe9bb332..9292f1c962d 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -91,8 +91,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { + const sessionState = this._stateManager.getSessionState(sessionKey); + if (sessionState?.lifecycle === SessionLifecycle.Creating) { + return; + } + if (!workingDirectory) { - const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.workingDirectory; + const workingDirectoryStr = sessionState?.workingDirectory; if (workingDirectoryStr) { workingDirectory = URI.parse(workingDirectoryStr); } @@ -107,21 +112,19 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi this._logService.trace(`[AgentHostGitStateService][refreshSessionGitState] Refreshing git state for ${sessionKey}, ${workingDirectory?.fsPath}`); const gitState = await this._gitService.getSessionGitState(workingDirectory); - if (!gitState) { - return; - } + if (gitState) { + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + if (!objectEquals(readSessionGitState(currentMeta), gitState)) { + // Update the session's git state + await this._setSessionGitState(sessionKey, gitState); - const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; - if (!objectEquals(readSessionGitState(currentMeta), gitState)) { - // Update the session's git state - await this._setSessionGitState(sessionKey, gitState); - - // Update the session's GitHub state - if (gitState.githubOwner && gitState.githubRepo) { - await this.setSessionGitHubState(sessionKey, { - owner: gitState.githubOwner, - repo: gitState.githubRepo - } satisfies ISessionGitHubState); + // Update the session's GitHub state + if (gitState.githubOwner && gitState.githubRepo) { + await this.setSessionGitHubState(sessionKey, { + owner: gitState.githubOwner, + repo: gitState.githubRepo + } satisfies ISessionGitHubState); + } } } diff --git a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts new file mode 100644 index 00000000000..7e2c5d1bbb3 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IReference } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ILocalTurnRecord, ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; +import type { Turn } from '../common/state/sessionState.js'; + +/** + * Tracks host-injected ("local") turns — completed protocol turns the agent SDK + * never saw, such as the `/rename` acknowledgement or a `!command` terminal run. + * + * These turns exist only in the agent host: they are never forwarded to the + * agent SDK, so they are absent from the SDK transcript that + * {@link AgentService} replays on restore. This registry persists them (so they + * survive reload) and remembers, for each, the id of the preceding concrete + * (SDK-backed) turn — the *anchor* — so that fork/truncate operations targeting + * a local turn can be redirected to the concrete SDK message before it. + * + * Everything is scoped to a **chat** (its channel URI): a session's default + * chat and each of its peer chats are handled identically. Persistence lives in + * the owning session's database (one per session, shared across its chats), + * discriminated by {@link ILocalTurnRecord.chatUri}. + */ +export class AgentHostLocalTurns { + + /** chat URI → (localTurnId → { anchorTurnId, seq }). */ + private readonly _byChat = new Map>(); + /** session URI → highest `seq` assigned so far (seq is session-global for stable ordering). */ + private readonly _seqBySession = new Map(); + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _logService: ILogService, + ) { } + + /** Whether `turnId` is a known host-injected local turn in `chat`. */ + isLocal(chat: string, turnId: string): boolean { + return this._byChat.get(chat)?.has(turnId) ?? false; + } + + /** All known local turn ids for `chat`. */ + getLocalTurnIds(chat: string): string[] { + const map = this._byChat.get(chat); + return map ? [...map.keys()] : []; + } + + /** + * Resolves `turnId` to the concrete (SDK-backed) turn a fork/truncate should + * operate on within `chat`. For a local turn this is its anchor (the + * preceding real turn, or `undefined` when it precedes any real turn); for a + * concrete turn it is the turn itself. + */ + resolveConcreteTurnId(chat: string, turnId: string): string | undefined { + const entry = this._byChat.get(chat)?.get(turnId); + return entry ? entry.anchorTurnId : turnId; + } + + /** + * Persist a local turn and remember it in memory. `anchorTurnId` is the id + * of the preceding concrete turn in `chat` (or `undefined` when there is + * none). `session` identifies the database to persist into. + */ + record(session: string, chat: string, turn: Turn, anchorTurnId: string | undefined): void { + const seq = (this._seqBySession.get(session) ?? 0) + 1; + this._noteInMemory(session, chat, turn.id, anchorTurnId, seq); + const record: ILocalTurnRecord = { turnId: turn.id, chatUri: chat, anchorTurnId, seq, payload: JSON.stringify(turn) }; + let ref: IReference; + try { + ref = this._sessionDataService.openDatabase(URI.parse(session)); + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to open database to persist local turn ${turn.id}`, err); + return; + } + ref.object.insertLocalTurn(record).catch(err => { + this._logService.warn(`[AgentHostLocalTurns] Failed to persist local turn ${turn.id}`, err); + }).finally(() => ref.dispose()); + } + + /** + * Loads persisted local turns for `session`, populating the in-memory index + * (keyed by each record's chat), and returns the records for `chat` in + * `seq` order so the caller can interleave them into that chat's SDK-derived + * turns during restore. + */ + async loadForChat(session: string, chat: string): Promise { + const records = await this._load(session); + return records.filter(r => r.chatUri === chat); + } + + /** Note a local turn in memory only (used by fork seeding). */ + noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void { + this._noteInMemory(session, chat, turnId, anchorTurnId, seq); + } + + /** Delete the given local turns from memory and the session database. */ + deleteLocals(session: string, turnIds: readonly string[]): void { + if (turnIds.length === 0) { + return; + } + const idSet = new Set(turnIds); + for (const map of this._byChat.values()) { + for (const id of idSet) { + map.delete(id); + } + } + let ref: IReference; + try { + ref = this._sessionDataService.openDatabase(URI.parse(session)); + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to open database to delete local turns for ${session}`, err); + return; + } + ref.object.deleteLocalTurns(turnIds).catch(err => { + this._logService.warn(`[AgentHostLocalTurns] Failed to delete local turns for ${session}`, err); + }).finally(() => ref.dispose()); + } + + /** Drop all in-memory state for a chat. */ + forgetChat(chat: string): void { + this._byChat.delete(chat); + } + + private async _load(session: string): Promise { + const ref = this._sessionDataService.tryOpenDatabase?.(URI.parse(session)); + if (!ref) { + return []; + } + try { + const db = await ref; + if (!db) { + return []; + } + try { + const records = await db.object.getLocalTurns(); + for (const r of records) { + this._noteInMemory(session, r.chatUri, r.turnId, r.anchorTurnId, r.seq); + } + return records; + } finally { + db.dispose(); + } + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to load local turns for ${session}`, err); + return []; + } + } + + private _noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void { + let map = this._byChat.get(chat); + if (!map) { + map = new Map(); + this._byChat.set(chat, map); + } + map.set(turnId, { anchorTurnId, seq }); + this._seqBySession.set(session, Math.max(this._seqBySession.get(session) ?? 0, seq)); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index ab3f0461cd8..aebdcbaef96 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -31,6 +31,7 @@ import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js'; import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; @@ -68,6 +69,7 @@ import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFile import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL, createAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService } from './agentHostGitService.js'; @@ -139,6 +141,7 @@ async function startAgentHost(): Promise { // after the block) can forward agent-SDK download progress to clients. let sdkDownloadProgress: Event | undefined; let byokLmBridgeRegistry: ByokLmBridgeRegistry; + let proxyResolver: AgentHostProxyResolver | undefined; // Gate BYOK *use* behind the opt-in `chat.agentHost.byokModels.enabled` // setting, forwarded from the renderer as an env var. The proxy and bridge // registry are always constructed below (so the session launcher can inject @@ -195,11 +198,13 @@ async function startAgentHost(): Promise { // stays empty and the proxy never binds when the feature is off. byokLmBridgeRegistry = new ByokLmBridgeRegistry(); diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); + proxyResolver = instantiationService.createInstance(AgentHostProxyResolver); + diServices.set(IAgentHostProxyResolver, proxyResolver); const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined); diServices.set(IAgentService, agentService); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); diServices.set(IAgentPluginManager, pluginManager); @@ -296,6 +301,8 @@ async function startAgentHost(): Promise { const getChannel = (channelName: string) => server.getChannel(channelName, c => c.ctx === clientId); const fsConnection = createAgentHostClientResourceConnection(getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL)); connectionStore.add(clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + const proxyConnection = createAgentHostClientProxyConnection(getChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL)); + connectionStore.add(proxyResolver.register(clientId, proxyConnection)); // BYOK bridge is gated: only wire it when the feature is enabled, so // the registry stays empty (and the launcher synthesizes no BYOK // providers/models) when `chat.agentHost.byokModels.enabled` is off. diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts new file mode 100644 index 00000000000..eb1fb436057 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { LogLevel as ProxyLogLevel, ProxyAgentParams, ProxySupportSetting, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent'; +import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService, LogLevel } from '../../log/common/log.js'; +import { systemCertificatesNodeDefault } from '../../request/common/request.js'; +import { IAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; + +export const IAgentHostProxyResolver = createDecorator('agentHostProxyResolver'); + +/** + * Node-side registry of renderer {@link IAgentHostClientProxyConnection}s keyed + * by client id. Populated by the agent host's connection lifecycle (one entry + * per connected renderer) and consumed by {@link CopilotAgent} to resolve the + * CAPI proxy through VS Code's Electron session before spawning the Copilot SDK. + * + * Proxy configuration is a property of the machine, not of a particular window, + * so any connected renderer can serve the lookup; the resolver calls the first + * available connection and falls through to the next on failure. + */ +export interface IAgentHostProxyResolver { + readonly _serviceBrand: undefined; + + /** Register a renderer connection. Disposing the result removes it. */ + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable; + + /** + * Resolve the proxy URL for `url` (e.g. `http://host:port`), or `undefined` + * for a direct connection. Reuses `@vscode/proxy-agent`'s `resolveProxyURL` + * so the same precedence as the rest of VS Code applies: `http.noProxy` → + * `http.proxy` setting → `HTTP(S)_PROXY` env vars → the host proxy resolution + * that runs in VS Code (Electron session) via the reverse channel. + */ + resolveProxy(url: string): Promise; +} + +export class AgentHostProxyResolver implements IAgentHostProxyResolver { + + declare readonly _serviceBrand: undefined; + + private readonly _connections = new Map(); + private _resolveProxyURL: ((url: string) => Promise) | undefined; + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { } + + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + this._connections.set(clientId, connection); + return toDisposable(() => { + if (this._connections.get(clientId) === connection) { + this._connections.delete(clientId); + } + }); + } + + resolveProxy(url: string): Promise { + return this._getResolveProxyURL()(url); + } + + private _getResolveProxyURL(): (url: string) => Promise { + if (!this._resolveProxyURL) { + // Mirror `workbench/api/node/proxyResolver.ts`. + const config = (key: string): T | undefined => this._configurationService.getValue(key); + const systemCertificatesV2 = () => config('http.experimental.systemCertificatesV2') ?? false; + const systemCertificates = () => !!config('http.systemCertificates'); + const params: ProxyAgentParams = { + // The host proxy resolution runs in VS Code: reverse-call a connected + // renderer, whose IRequestService.resolveProxy hits the Electron + // session (system settings / PAC scripts). + resolveProxy: (url) => this._hostResolveProxy(url), + getProxyURL: () => config('http.proxy'), + getProxySupport: () => config('http.proxySupport') || 'off', + getNoProxyConfig: () => config('http.noProxy') || [], + isAdditionalFetchSupportEnabled: () => config('http.fetchAdditionalSupport') ?? true, + isWebSocketPatchEnabled: () => config('http.webSocketAdditionalSupport') ?? true, + addCertificatesV1: () => !systemCertificatesV2() && systemCertificates(), + addCertificatesV2: () => systemCertificatesV2() && systemCertificates(), + loadSystemCertificatesFromNode: () => config('http.systemCertificatesNode') ?? systemCertificatesNodeDefault, + loadAdditionalCertificates: async () => loadSystemCertificates({ + loadSystemCertificatesFromNode: () => config('http.systemCertificatesNode') ?? systemCertificatesNodeDefault, + log: this._logService, + }), + log: this._logService, + getLogLevel: () => { + switch (this._logService.getLevel()) { + case LogLevel.Trace: return ProxyLogLevel.Trace; + case LogLevel.Debug: return ProxyLogLevel.Debug; + case LogLevel.Info: return ProxyLogLevel.Info; + case LogLevel.Warning: return ProxyLogLevel.Warning; + case LogLevel.Error: return ProxyLogLevel.Error; + case LogLevel.Off: return ProxyLogLevel.Off; + default: return ProxyLogLevel.Info; + } + }, + proxyResolveTelemetry: () => { }, + // Only the local agent host wires the reverse proxy channel + // and we want to look up the client's proxy settings only + // when the agent host is local (i.e., on the same machine as + // the client). + isUseHostProxyEnabled: () => this._connections.size > 0, + getNetworkInterfaceCheckInterval: () => (config('http.experimental.networkInterfaceCheckInterval') ?? 300) * 1000, + env: process.env, + }; + this._resolveProxyURL = createProxyResolver(params).resolveProxyURL; + } + return this._resolveProxyURL; + } + + private async _hostResolveProxy(url: string): Promise { + for (const connection of this._connections.values()) { + try { + return await connection.resolveProxy(url); + } catch { + // This renderer could not serve the lookup; try the next one. + } + } + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts new file mode 100644 index 00000000000..71a41f529d5 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { ICommonProperties } from '../../telemetry/common/telemetry.js'; + +/** + * Public GitHub Copilot telemetry ingestion keys. These are instrumentation keys, not + * secrets; the iKey selects the destination hydro table: + * - standard -> `copilot_v0_copilot_event` + * - enhanced -> `copilot_v0_restricted_copilot_event` + */ +const GH_STANDARD_IKEY = '7d7048df-6dd0-4048-bb23-b716c1461f8f'; +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +/** + * Fallback Copilot telemetry endpoint (the dotcom value of the CAPI token's + * `endpoints.telemetry`, with the `/telemetry` path the Copilot CLI/runtime appends). + * Used until {@link IAgentHostRestrictedTelemetry.setRestrictedTelemetryEndpoint} supplies + * the user's discovered endpoint (dotcom, GHE, or proxy). Accepts unauthenticated POSTs. + */ +const GH_TELEMETRY_URL = 'https://copilot-telemetry.githubusercontent.com/telemetry'; + +/** Event names are namespaced by client category; the CTS name filter requires this. */ +const NAMESPACE = 'copilot-chat'; + +export type TelemetryProps = Record; +export type TelemetryMeasurements = Record; + +/** The subset of the global `fetch` used to POST envelopes; injectable so tests avoid live network calls. */ +type FetchFn = typeof globalThis.fetch; + +/** + * App Insights caps a single property value at ~8192 chars. Long values are split across + * numbered keys (`key`, `key_02`, `key_03`, …) so the Copilot Telemetry Service reassembles + * them, mirroring the Copilot extension's `multiplexProperties` so events look identical on the + * wire and downstream. + */ +const MAX_PROPERTY_LENGTH = 8192; +const MAX_CONCATENATED_PROPERTIES = 50; + +export function multiplexProperties(properties: TelemetryProps): TelemetryProps { + const newProperties: TelemetryProps = { ...properties }; + for (const key in properties) { + const value = properties[key]; + let remaining = value?.length ?? 0; + if (remaining > MAX_PROPERTY_LENGTH) { + let lastStartIndex = 0; + let count = 0; + while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) { + count += 1; + let propertyName = key; + if (count > 1) { + propertyName = key + '_' + (count < 10 ? '0' : '') + count; + } + let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH; + if (remaining < MAX_PROPERTY_LENGTH) { + offsetIndex = lastStartIndex + remaining; + } + newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex); + remaining -= MAX_PROPERTY_LENGTH; + lastStartIndex += MAX_PROPERTY_LENGTH; + } + } + } + return newProperties; +} + +/** + * The restricted telemetry surface the agent host exposes, mirroring the Copilot extension's + * `ITelemetryService` restricted methods so agent-host code can emit the same GH/MSFT events. + */ +export interface IAgentHostRestrictedTelemetry { + /** GH standard (non-restricted) telemetry -> `copilot_v0_copilot_event`. */ + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** GH enhanced/restricted telemetry (prompts, tools, etc.) -> `copilot_v0_restricted_copilot_event`. */ + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** MSFT-internal telemetry -> Aria/Collector++ (internal-only table). No-op without an internal key. */ + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */ + setCopilotTrackingId(trackingId: string | undefined): void; + /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; + /** Enables enhanced GH telemetry once the token opts in (`rt=1`); off by default and on flip/logout. */ + setRestrictedTelemetryEnabled(enabled: boolean): void; +} + +/** + * Emits GitHub Copilot restricted/enhanced telemetry from the agent-host process by POSTing + * Application-Insights envelopes to the Copilot telemetry endpoint (the same wire format the + * Copilot extension uses). Fire-and-forget; failures are logged, never thrown. + */ +export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedTelemetry { + + private readonly _commonProps: TelemetryProps; + + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Off by + * default so the sole writer to the restricted table never emits for public users — a hard + * safety boundary that holds even if the enclosing service's gate is bypassed. Mirrors the + * Copilot extension, which only creates the restricted reporter for opted-in users. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + commonProperties: ICommonProperties, + private readonly _logService: ILogService, + private _endpointUrl: string = GH_TELEMETRY_URL, + private readonly _internalSink?: (eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements) => void, + private readonly _fetchFn: FetchFn = globalThis.fetch, + ) { + // Map the resolved common properties onto the GH property names the hydro schema reads. + this._commonProps = { + client_machineid: asString(commonProperties['common.machineId']), + client_deviceid: asString(commonProperties['common.devDeviceId']), + client_sessionid: asString(commonProperties['sessionID']), + common_os: asString(commonProperties['common.nodePlatform']) ?? process.platform, + editor_version: asString(commonProperties['version']), + }; + } + + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + this._post(GH_STANDARD_IKEY, eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Hard safety boundary: enhanced/restricted telemetry is the pipeline that may carry prompt + // and tool content, so the only writer to the restricted table refuses to emit unless the + // user's token opted in (`rt=1`). This holds even if a caller reaches the sender without the + // service-level `rt`/telemetry-level gate. + if (!this._restrictedTelemetryEnabled) { + return; + } + this._post(GH_ENHANCED_IKEY, eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Internal MSFT telemetry lands in the Aria/Collector++ pipeline via a dedicated key, + // which is not present in the agent-host product config. Route to the optional sink when + // wired; otherwise trace so the event is at least visible in the agent-host log. + if (this._internalSink) { + this._internalSink(eventName, properties, measurements); + return; + } + this._logService.trace(`[ahp-restricted] internal MSFT event (not sent, no internal key): ${eventName}`); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + // `copilot_trackingId` is the Copilot token's `tid` claim: a stable per-user id (one user + // per agent-host process). The Copilot Telemetry Service reads it into the + // `copilot_tracking_id` column, matching the Copilot extension. + this._commonProps.copilot_trackingId = trackingId || undefined; + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + // The user's telemetry host comes from the CAPI `endpoints.telemetry` discovery; fall back + // to the dotcom default when it is unknown so events are never sent to an empty URL. + this._endpointUrl = endpointUrl || GH_TELEMETRY_URL; + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + } + + private _post(iKey: string, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + const name = eventName.includes('/') ? eventName : `${NAMESPACE}/${eventName}`; + const envelope = { + ver: 1, + name: `Microsoft.ApplicationInsights.${iKey.replace(/-/g, '')}.Event`, + time: new Date().toISOString(), + sampleRate: 100, + seq: '', + iKey, + tags: { 'ai.operation.id': generateUuid() }, + data: { + baseType: 'EventData', + baseData: { + name, + // `unique_id` is a fresh per-event id (its hydro column is read by the Copilot + // Telemetry Service from the snake_case `unique_id` property, NOT `uniqueId`), + // mirroring the Copilot extension so each emitted event stays individually + // addressable. Placed first so explicit properties still win on collision. + properties: { unique_id: generateUuid(), ...this._commonProps, ...properties }, + measurements: measurements ?? {}, + }, + }, + }; + + this._logService.trace(`[ahp-restricted] emit ${name} (iKey ${iKey.slice(0, 8)})`); + + if (typeof this._fetchFn !== 'function') { + this._logService.warn('[ahp-restricted] global fetch unavailable; telemetry not sent'); + return; + } + + // Fire-and-forget: post the event and move on. Delivery/robustness is intentionally kept + // simple here — failures are logged, not retried (a retry loop would only mask local + // telemetry-blocking resolvers, which do not exist in production). + this._fetchFn(this._endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-json-stream' }, + body: JSON.stringify(envelope), + }).then(res => { + if (!res.ok) { + this._logService.warn(`[ahp-restricted] ${name} rejected: HTTP ${res.status}`); + } + }).catch(err => { + this._logService.warn(`[ahp-restricted] ${name} POST failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } +} + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : value === undefined ? undefined : String(value); +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts new file mode 100644 index 00000000000..68f655c3d9a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { basename } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; +import { META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams, type InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { readSessionGitState, type SessionState } from '../common/state/sessionState.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; + +/** + * Handles the `mark-as-reviewed` and `mark-as-unreviewed` resource-scoped + * changeset operations for the **Branch Changes** changeset. A single instance + * handles one direction — marking a file as reviewed or clearing that mark — + * selected by the `_reviewed` flag. + * + * The reviewed state is owned by {@link IAgentHostReviewService}, which tracks + * it as a session-private synthetic git ref. This handler resolves the session's + * working directory + base branch and delegates to that service. + */ +export class AgentHostReviewFileOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_MARK_AS_REVIEWED = 'mark-as-reviewed'; + public static readonly OPERATION_MARK_AS_UNREVIEWED = 'mark-as-unreviewed'; + + constructor( + private readonly _reviewed: boolean, + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, + @IAgentHostChangesetService private readonly _changesetService: IAgentHostChangesetService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { } + + private get _operationId(): string { + return this._reviewed + ? AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED + : AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED; + } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed || parsed.kind !== ChangesetKind.Branch) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a branch changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + + const sessionUri = parsed.sessionUri; + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + if (params.target?.kind !== ChangesetOperationTargetKind.Resource) { + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + `Operation '${this._operationId}' requires a resource target.`); + } + + const workingDirectoryStr = sessionState.workingDirectory; + if (!workingDirectoryStr) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + + const workingDirectory = URI.parse(workingDirectoryStr); + const resource = URI.parse(params.target.resource); + const baseBranch = await this._resolveBaseBranch(sessionUri, sessionState); + + try { + if (this._reviewed) { + this._logService.info(`[AgentHostReviewFileOperationHandler] Marking '${resource.fsPath}' as reviewed for session ${sessionUri}`); + await this._reviewService.markFileReviewed(sessionUri, workingDirectory, baseBranch, resource); + this._changesetService.refreshBranchChangeset(sessionUri); + + return { message: { markdown: localize('agentHost.changeset.reviewFile.marked', "Marked `{0}` as reviewed.", basename(resource)) } }; + } + + this._logService.info(`[AgentHostReviewFileOperationHandler] Removing reviewed mark for '${resource.fsPath}' in session ${sessionUri}`); + await this._reviewService.markFileUnreviewed(sessionUri, workingDirectory, baseBranch, resource); + this._changesetService.refreshBranchChangeset(sessionUri); + + return { message: { markdown: localize('agentHost.changeset.reviewFile.unmarked', "Removed the reviewed mark from `{0}`.", basename(resource)) } }; + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + `Failed to update reviewed state: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** + * Resolves the Branch Changes base branch the same way the changeset service + * does, so review status is keyed on the same baseline the diff uses. + */ + private async _resolveBaseBranch(sessionUri: string, sessionState: SessionState): Promise { + const databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionUri)); + try { + const persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH); + const gitStateBaseBranch = readSessionGitState(sessionState._meta)?.baseBranchName; + return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch); + } finally { + databaseRef.dispose(); + } + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.reviewFile.cancelled', "Review file operation was cancelled.")); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts new file mode 100644 index 00000000000..7d10fa7c1bd --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ChangesetKind } from '../common/changesetUri.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostReviewFileOperationHandler } from './agentHostReviewFileOperationHandler.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Contributes the `mark-as-reviewed` / `mark-as-unreviewed` resource-scoped + * operations for the **Branch Changes** changeset, backed by + * {@link AgentHostReviewFileOperationHandler}. + */ +export class AgentHostReviewOperationContribution extends Disposable implements IChangesetOperationContribution { + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const markHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, true, getSessionState); + const unmarkHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, false, getSessionState); + store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, markHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, unmarkHandler)); + return store; + } + + getOperations({ changesetKind }: IChangesetOperationContext): ChangesetOperation[] { + if (changesetKind !== ChangesetKind.Branch) { + return []; + } + + return [ + { + id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + label: localize('agentHost.changeset.markAsReviewed', "Mark as Reviewed"), + icon: 'check', + group: 'review', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }, + { + id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, + label: localize('agentHost.changeset.markAsUnreviewed', "Mark as Unreviewed"), + icon: 'check', + group: 'review', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }, + ] satisfies ChangesetOperation[]; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewService.ts b/src/vs/platform/agentHost/node/agentHostReviewService.ts new file mode 100644 index 00000000000..1715aebffed --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewService.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SequencerByKey } from '../../../base/common/async.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { relativePath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession } from '../common/agentService.js'; +import type { URI as ProtocolURI } from '../common/state/sessionState.js'; +import { EMPTY_TREE_OBJECT, IAgentHostGitService } from '../common/agentHostGitService.js'; +import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Resolved git context shared by the review operations: the repository root, + * the Branch Changes baseline tree, and the current reviewed ref/tree. + */ +interface IReviewContext { + readonly repoRoot: URI; + /** Tree object of the baseline. */ + readonly baselineTree: string; + /** Name of the session's reviewed ref. */ + readonly reviewedRef: string; + /** Current reviewed commit, or `undefined` when the ref does not exist yet. */ + readonly reviewedCommit: string | undefined; + /** Current reviewed tree; equals `baselineTree` when the ref does not exist. */ + readonly reviewedTree: string; +} + +export class AgentHostReviewService extends Disposable implements IAgentHostReviewService { + declare readonly _serviceBrand: undefined; + + /** + * Serializes mark/unmark/read per session so back-to-back mutations don't + * race on the reviewed ref rebuild and reads observe a consistent ref. + */ + private readonly _sequencer = new SequencerByKey(); + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // When a session's data directory is about to be deleted, delete the + // reviewed ref we created for it. The working directory needed to + // resolve the repository root is supplied by the event (resolved from + // live session state) so we don't persist our own copy. + this._register(this._sessionDataService.onWillDeleteSessionData(e => { + e.waitUntil(this.disposeSessionData(e.session.toString())); + })); + } + + markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true)); + } + + markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, false)); + } + + getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise> { + return this._sequencer.queue(session, () => this._getReviewedPaths(session, workingDirectory, baseBranch)); + } + + copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise { + return this._sequencer.queue(targetSession, () => this._copyReviewedRef(sourceSession, targetSession, workingDirectory)); + } + + private async _copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise { + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repoRoot) { + return; + } + + const sourceRef = buildReviewedRefName(this._sanitizedSessionId(sourceSession)); + const sourceCommit = await this._gitService.revParse(repoRoot, sourceRef); + if (!sourceCommit) { + return; + } + + const targetRef = buildReviewedRefName(this._sanitizedSessionId(targetSession)); + await this._gitService.updateRef(repoRoot, targetRef, sourceCommit); + this._logService.trace(`[AgentHostReview][_copyReviewedRef] Copied reviewed ref ${sourceRef} -> ${targetRef} for fork`); + } + + private async _setReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI, reviewed: boolean): Promise { + const context = await this._resolveContext(session, workingDirectory, baseBranch); + if (!context) { + return; + } + + const path = relativePath(context.repoRoot, resource); + if (!path) { + this._logService.warn(`[AgentHostReview][_setReviewed] '${resource.toString()}' is not under the repository root '${context.repoRoot.toString()}'; skipping`); + return; + } + + // To mark a file reviewed, overlay its current working-tree content into + // the reviewed tree; to unmark, reset it to the baseline content. + let source: string | undefined; + if (reviewed) { + source = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + } else { + source = context.baselineTree; + } + if (!source) { + return; + } + + const newTree = await this._gitService.overlayPathIntoTree(context.repoRoot, context.reviewedTree, path, source); + if (!newTree) { + return; + } + if (newTree === context.reviewedTree) { + // No change (already reviewed / already unreviewed). + // Don't grow the reviewed ref chain with a no-op + // commit. + return; + } + + // The reviewed ref is a session-private chain disconnected from the + // real git history (mirroring the checkpoint baseline): the first + // commit is a parentless root, and subsequent commits chain onto the + // prior reviewed commit. + const message = `review: ${reviewed ? 'mark' : 'unmark'} ${path}`; + const commit = await this._gitService.commitTree(context.repoRoot, newTree, context.reviewedCommit, message); + if (!commit) { + return; + } + + await this._gitService.updateRef(context.repoRoot, context.reviewedRef, commit); + + this._logService.trace(`[AgentHostReview][_setReviewed] ${message} for ${session.toString()} -> ${context.reviewedRef}@${commit}`); + } + + private async _getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise> { + const context = await this._resolveContext(session, workingDirectory, baseBranch); + if (!context?.reviewedCommit) { + // No reviewed ref yet means + // nothing has been reviewed. + return new Set(); + } + + const workingTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + if (!workingTree) { + return new Set(); + } + + // Changed = files that differ between the baseline and the working tree + // (the Branch Changes universe). Unreviewed = files that still differ + // between the reviewed tree and the working tree. Reviewed is the + // difference: changed files whose reviewed content already matches the + // working tree. + const [changed, unreviewed] = await Promise.all([ + this._gitService.diffTreePaths(context.repoRoot, context.baselineTree, workingTree), + this._gitService.diffTreePaths(context.repoRoot, context.reviewedTree, workingTree), + ]); + if (!changed) { + return new Set(); + } + + const unreviewedSet = new Set(unreviewed ?? []); + return new Set(changed.filter(path => !unreviewedSet.has(path))); + } + + private async _resolveContext(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise { + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repoRoot) { + return undefined; + } + + const baselineCommit = await this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch); + if (!baselineCommit) { + return undefined; + } + + const baselineTree = baselineCommit !== EMPTY_TREE_OBJECT + ? await this._gitService.revParse(repoRoot, `${baselineCommit}^{tree}`) + : EMPTY_TREE_OBJECT; + if (!baselineTree) { + return undefined; + } + + const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session)); + const reviewedCommit = await this._gitService.revParse(repoRoot, reviewedRef); + const reviewedTree = reviewedCommit + ? await this._gitService.revParse(repoRoot, `${reviewedCommit}^{tree}`) ?? baselineTree + : baselineTree; + + return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree }; + } + + async disposeSessionData(session: ProtocolURI): Promise { + await this._sequencer.queue(session, () => this._disposeSessionData(session)); + } + + private async _disposeSessionData(session: ProtocolURI): Promise { + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + if (!workingDirectory) { + // No working directory means we can't resolve the repository root + // (session was never git-backed, or its working directory is gone). + return; + } + + const repoRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory)); + if (!repoRoot) { + return; + } + + try { + const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session)); + await this._gitService.deleteRefs(repoRoot, [reviewedRef]); + + this._logService.trace(`[AgentHostReview][_disposeSessionData] Deleted reviewed ref for ${session}`); + } catch (err) { + this._logService.warn(`[AgentHostReview][_disposeSessionData] Failed to dispose reviewed ref for ${session}`, err); + } + } + + private _sanitizedSessionId(session: ProtocolURI): string { + return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 14961ed37cd..0d2dda0281d 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -35,6 +35,7 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; import { IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; @@ -247,7 +248,7 @@ async function main(): Promise { diServices.set(IAgentHostCheckpointService, checkpointService); // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); + const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined); disposables.add(agentService); diServices.set(IAgentService, agentService); @@ -293,6 +294,7 @@ async function main(): Promise { // to satisfy CopilotAgent / CopilotSessionLauncher DI. diServices.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry()); diServices.set(IByokLmProxyService, new NullByokLmProxyService()); + diServices.set(IAgentHostProxyResolver, instantiationService.createInstance(AgentHostProxyResolver)); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index bb70e2e40e9..79548f0f2f8 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type ProgressParams } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -1414,4 +1414,20 @@ export class AgentHostStateManager extends Disposable { ...progress, }); } + + /** + * Emit an `auth/required` notification on the root channel, asking the + * client to obtain a fresh token and push it via `authenticate`. Rides the + * same {@link onDidEmitNotification} path as {@link emitProgress}, so both + * local (IPC proxy) and remote (WebSocket) renderers receive it. Used for + * host-level auth requirements (e.g. an agent whose transport flip makes a + * credential newly required) rather than a per-session one. + */ + emitAuthRequired(params: Omit): void { + this._onDidEmitNotification.fire({ + type: 'auth/required', + channel: ROOT_STATE_URI, + ...params, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index d875bd5918f..a4440f83781 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -6,9 +6,10 @@ import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../telemetry/common/languageModelToolTelemetry.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agentService.js'; -import type { MessageAttachment } from '../common/state/protocol/state.js'; -import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import type { MessageAttachment, SessionInputRequestKind, ToolDefinition } from '../common/state/protocol/state.js'; +import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; +import { multiplexProperties, type IAgentHostRestrictedTelemetry } from './agentHostRestrictedTelemetry.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -81,10 +82,84 @@ export interface IAgentHostToolInvokedReport { invocationTimeMs: number; } +export interface IAgentHostToolCallStalledEvent { + provider: string; + agentSessionId: string; + isSubagentSession: boolean; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + stalledTimeMs: number; +} + +export type IAgentHostToolCallStalledClassification = { + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; + isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' }; + blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call is waiting for confirmation or client execution.' }; + toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the stalled tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool is provided by the agent host, an MCP server, or a client.' }; + stalledTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds that the tool call has remained blocked.' }; + owner: 'roblourens'; + comment: 'Tracks agent host tool calls that remain blocked beyond the stall threshold.'; +}; + +export interface IAgentHostToolCallStalledReport { + provider: string; + session: string; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + stalledTimeMs: number; +} + +export interface IAgentHostStalledToolCallCompletedEvent { + provider: string; + agentSessionId: string; + isSubagentSession: boolean; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + totalTimeMs: number; + timeAfterStallMs: number; +} + +export type IAgentHostStalledToolCallCompletedClassification = { + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; + isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' }; + blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call had stalled waiting for confirmation or client execution.' }; + toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the completed tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the completed tool is provided by the agent host, an MCP server, or a client.' }; + result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool call eventually completed successfully, with an error, or through user cancellation.' }; + totalTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from tool call start to completion.' }; + timeAfterStallMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from the stall report to tool call completion.' }; + owner: 'roblourens'; + comment: 'Tracks agent host tool calls that complete after previously exceeding the stall threshold.'; +}; + +export interface IAgentHostStalledToolCallCompletedReport { + provider: string; + session: string; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + totalTimeMs: number; + timeAfterStallMs: number; +} + export class AgentHostTelemetryReporter { constructor(private readonly _telemetryService: ITelemetryService) { } + /** The restricted GH/MSFT telemetry surface, present when the agent-host telemetry service is wired. */ + private get _restricted(): IAgentHostRestrictedTelemetry | undefined { + const ts = this._telemetryService as Partial; + return typeof ts.sendEnhancedGHTelemetryEvent === 'function' ? ts as IAgentHostRestrictedTelemetry : undefined; + } + userMessageSent(provider: string, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void { const attachmentCount = attachments?.length ?? 0; const activeClients = sessionState?.activeClients ?? []; @@ -104,6 +179,32 @@ export class AgentHostTelemetryReporter { }); } + /** + * Mirrors the Copilot extension's enhanced GH `request.options.tools` event for the agent-host + * flow. The extension emits it per LLM request from its model fetcher; the agent host observes + * the equivalent boundary when an `assistant.message` arrives (one per model call). The + * extension populates `headerRequestId` with the client-minted `x-request-id`, which the SDK + * does not surface on success; we keep the same field name (so science queries are undisturbed) + * but fill it with the model call's `x-copilot-service-request-id`, the per-call id the SDK does + * expose. `messagesJson` is the raw tool definitions offered for the call, multiplexed across + * ~8192-char chunks like the extension, so it lands identically downstream. + * + * @param session Session URI string; its id becomes `conversationId`. + * @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to the extension's `headerRequestId`. No-ops when absent (e.g. providers that don't surface it). + * @param tools The tool definitions offered to the model for this call. + */ + assistantMessageReceived(session: string, serviceRequestId: string | undefined, tools: readonly ToolDefinition[]): void { + const restricted = this._restricted; + if (!restricted || !serviceRequestId || tools.length === 0) { + return; + } + restricted.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + headerRequestId: serviceRequestId, + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + })); + } + turnCompleted(report: IAgentHostTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnCompleted', { @@ -132,4 +233,32 @@ export class AgentHostTelemetryReporter { provider: report.provider, }); } + + toolCallStalled(report: IAgentHostToolCallStalledReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('agentHost.toolCallStalled', { + provider: report.provider, + agentSessionId: AgentSession.id(session), + isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + blockerKind: report.blockerKind, + toolId: report.toolId, + toolSourceKind: report.toolSourceKind, + stalledTimeMs: report.stalledTimeMs, + }); + } + + stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('agentHost.stalledToolCallCompleted', { + provider: report.provider, + agentSessionId: AgentSession.id(session), + isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + blockerKind: report.blockerKind, + toolId: report.toolId, + toolSourceKind: report.toolSourceKind, + result: report.result, + totalTimeMs: report.totalTimeMs, + timeAfterStallMs: report.timeAfterStallMs, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 6cc0a3f9bf6..429e52a8fd8 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -21,6 +21,8 @@ import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppende import { TelemetryService } from '../../telemetry/common/telemetryService.js'; import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js'; +import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey } from '../common/agentHostTelemetryEnv.js'; +import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js'; export interface IAgentHostTelemetryServiceOptions { readonly environmentService: INativeEnvironmentService; @@ -32,7 +34,7 @@ export interface IAgentHostTelemetryServiceOptions { readonly disableTelemetry?: boolean; } -export interface IAgentHostTelemetryService extends ITelemetryService { +export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { updateTelemetryLevel(telemetryLevel: TelemetryLevel): void; } @@ -41,7 +43,17 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private _telemetryLevel = TelemetryLevel.USAGE; - constructor(private readonly _delegate: ITelemetryService) { + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults + * to `false` so nothing restricted is sent until an authenticated token confirms the opt-in, + * keeping public users off the enhanced pipeline the way the Copilot extension does. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + private readonly _delegate: ITelemetryService, + private readonly _restricted?: IAgentHostRestrictedTelemetry, + ) { super(); if (isDisposable(_delegate)) { this._register(_delegate); @@ -108,6 +120,42 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT this._delegate.publicLogError2(eventName, data); } + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendGHTelemetryEvent(eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE || !this._restrictedTelemetryEnabled) { + return; + } + this._restricted?.sendEnhancedGHTelemetryEvent(eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendInternalMSFTTelemetryEvent(eventName, properties, measurements); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + this._restricted?.setCopilotTrackingId(trackingId); + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this._restricted?.setRestrictedTelemetryEndpoint(endpointUrl); + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + // Mirror onto the sender so the restricted-table writer enforces the same `rt` gate + // independently (defense in depth), matching the extension's opted-in-only reporter. + this._restricted?.setRestrictedTelemetryEnabled(enabled); + } + setExperimentProperty(name: string, value: string): void { this._delegate.setExperimentProperty(name, value); } @@ -130,7 +178,7 @@ export function updateAgentHostTelemetryLevelFromConfig(telemetryService: ITelem telemetryService.updateTelemetryLevel(telemetryLevelValue); } -function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { +export function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { return typeof (telemetryService as IAgentHostTelemetryService).updateTelemetryLevel === 'function'; } @@ -153,18 +201,26 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet appenders.push(collectorAppender); } + // Prefer the host-forwarded identifiers (see `agentHostTelemetryEnv`) so the + // agent host reports the same persisted machineId/sqmId/devDeviceId as the + // workbench. Fall back to computing them live when not provided (e.g. the + // remote/server agent host, which does not forward them). const [machineId, sqmId, devDeviceId] = await Promise.all([ - getMachineId(error => logService.error(error)), - getSqmMachineId(error => logService.error(error)), - getDevDeviceId(error => logService.error(error)), + process.env[AgentHostMachineIdEnvKey] || getMachineId(error => logService.error(error)), + process.env[AgentHostSqmIdEnvKey] || getSqmMachineId(error => logService.error(error)), + process.env[AgentHostDevDeviceIdEnvKey] || getDevDeviceId(error => logService.error(error)), ]); + const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date); + const telemetryService = new TelemetryService({ appenders, sendErrorTelemetry: true, - commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date), + commonProperties, piiPaths: getPiiPathsFromEnvironment(environmentService), }, configurationService, productService); - return disposables.add(new AgentHostTelemetryService(telemetryService)); + const restricted = new AgentHostRestrictedTelemetrySender(commonProperties, logService); + + return disposables.add(new AgentHostTelemetryService(telemetryService, restricted)); } diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts index 49af2f6ddb8..7511ac9afbe 100644 --- a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -3,12 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { disposableTimeout } from '../../../base/common/async.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; +import type { SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js'; import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; export type ToolInvokedResult = 'success' | 'error' | 'userCancelled'; +const TOOL_CALL_STALL_THRESHOLD_MS = 5 * 60 * 1000; + +type ToolCallBlockerRequest = SessionToolConfirmationRequest | SessionToolClientExecutionRequest; + /** * Maps a completed tool call's result to the telemetry result bucket. Mirrors * the derivation previously done inline in `CopilotAgentSession`: a denied, @@ -57,25 +64,34 @@ interface IToolCallTiming { readonly toolSourceKind: string; } +interface IStalledToolCall { + readonly blockerKind: ToolCallBlockerRequest['kind']; + readonly completionStopWatch: StopWatch; +} + /** - * Tracks per-tool-call timing for agent host sessions and reports a - * `languageModelToolInvoked` event via the provided - * {@link AgentHostTelemetryReporter} when a tool call completes. + * Tracks completed and stalled tool calls for agent host sessions. * * Lifecycle per tool call: * 1. {@link toolCallStarted} — begins a stopwatch and records the tool's * name and source kind (only the start action carries these) * 2. {@link toolCallCompleted} — emits the telemetry event and clears state + * 3. {@link toolCallBlocked} / {@link toolCallUnblocked} — emits once when a + * confirmation or client execution remains unresolved past the threshold * * In-flight tool calls that never complete (e.g. the turn is cancelled mid * tool call) are dropped via {@link clearSession} / {@link clear} so the * tracking map cannot leak. */ -export class AgentHostToolCallTracker { +export class AgentHostToolCallTracker extends Disposable { private readonly _toolCalls = new Map(); + private readonly _toolCallStallTimers = this._register(new DisposableMap()); + private readonly _stalledToolCalls = new Map(); - constructor(private readonly _reporter: AgentHostTelemetryReporter) { } + constructor(private readonly _reporter: AgentHostTelemetryReporter) { + super(); + } toolCallStarted(provider: string, session: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void { this._toolCalls.set(this._key(session, toolCallId), { @@ -97,15 +113,58 @@ export class AgentHostToolCallTracker { return; } this._toolCalls.delete(key); + const resultBucket = deriveToolInvokedResult(result); + const totalTimeMs = timing.stopWatch.elapsed(); this._reporter.toolInvoked({ provider: timing.provider, session: timing.session, toolId: timing.toolId, toolSourceKind: timing.toolSourceKind, - result: deriveToolInvokedResult(result), - invocationTimeMs: timing.stopWatch.elapsed(), + result: resultBucket, + invocationTimeMs: totalTimeMs, }); + + const stalled = this._stalledToolCalls.get(key); + if (stalled) { + this._stalledToolCalls.delete(key); + this._reporter.stalledToolCallCompleted({ + provider: timing.provider, + session: timing.session, + blockerKind: stalled.blockerKind, + toolId: timing.toolId, + toolSourceKind: timing.toolSourceKind, + result: resultBucket, + totalTimeMs, + timeAfterStallMs: stalled.completionStopWatch.elapsed(), + }); + } + } + + toolCallBlocked(provider: string, session: string, request: ToolCallBlockerRequest): void { + const key = this._key(session, request.id); + const toolCallKey = this._key(session, request.toolCall.toolCallId); + if (this._toolCallStallTimers.has(key) || this._stalledToolCalls.has(toolCallKey)) { + return; + } + + const stopWatch = StopWatch.create(true); + this._toolCallStallTimers.set(key, disposableTimeout(() => { + const stalledTimeMs = stopWatch.elapsed(); + this._stalledToolCalls.set(toolCallKey, { blockerKind: request.kind, completionStopWatch: StopWatch.create(true) }); + this._reporter.toolCallStalled({ + provider, + session, + blockerKind: request.kind, + toolId: request.toolCall.toolName, + toolSourceKind: toolSourceKindFromContributor(request.toolCall.contributor), + stalledTimeMs, + }); + }, TOOL_CALL_STALL_THRESHOLD_MS)); + } + + toolCallUnblocked(session: string, requestId: string): void { + this._toolCallStallTimers.deleteAndDispose(this._key(session, requestId)); } /** @@ -120,10 +179,22 @@ export class AgentHostToolCallTracker { this._toolCalls.delete(key); } } + for (const key of this._toolCallStallTimers.keys()) { + if (key.startsWith(prefix)) { + this._toolCallStallTimers.deleteAndDispose(key); + } + } + for (const key of this._stalledToolCalls.keys()) { + if (key.startsWith(prefix)) { + this._stalledToolCalls.delete(key); + } + } } clear(): void { this._toolCalls.clear(); + this._toolCallStallTimers.clearAndDisposeAll(); + this._stalledToolCalls.clear(); } private _key(session: string, toolCallId: string): string { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 117a90240fb..3adeb2ad423 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -32,17 +32,19 @@ import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../comm import { ISessionGitHubState, ISessionGitState, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; +import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { serverToolGroups } from './shared/serverToolGroups.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; @@ -66,8 +68,10 @@ import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_G import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostReviewOperationContribution } from './agentHostReviewOperationProvider.js'; import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; +import { AgentHostReviewService } from './agentHostReviewService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -194,6 +198,8 @@ export class AgentService extends Disposable implements IAgentService { private readonly _gitStateService: IAgentHostGitStateService; /** Manages PTY-backed terminals for the agent host protocol. */ private readonly _terminalManager: AgentHostTerminalManager; + /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */ + private readonly _localTurns: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; private readonly _configurationService: IAgentConfigurationService; @@ -330,6 +336,10 @@ export class AgentService extends Disposable implements IAgentService { this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService, this._stateManager)); services.set(IAgentHostChangesetOperationService, this._changesetOperationService); + // The changes review service is responsible for managing review/unreview state for changeset changes. + const reviewService = this._register(instantiationService.createInstance(AgentHostReviewService, this._stateManager)); + services.set(IAgentHostReviewService, reviewService); + // The changeset service is responsible for computing, publishing, and persisting changesets. this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); services.set(IAgentHostChangesetService, this._changesets); @@ -344,6 +354,7 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostReviewOperationContribution, this._stateManager))); this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); // Built-in generic provider: completes files in the session's workspace folder. @@ -360,9 +371,20 @@ export class AgentService extends Disposable implements IAgentService { ), )); + // Terminal management — the terminal manager listens to the state + // manager's action stream and dispatches PTY output back through it. + // Created before AgentSideEffects and registered in the local scope so + // AgentSideEffects can consume it via DI (for inline `!command` + // execution). + this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager)); + services.set(IAgentHostTerminalManager, this._terminalManager); + + this._localTurns = new AgentHostLocalTurns(this._sessionDataService, this._logService); + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, + localTurns: this._localTurns, agents: this._agents, copilotApiService: effectiveCopilotApiService, getGitHubCopilotToken: () => { @@ -381,10 +403,6 @@ export class AgentService extends Disposable implements IAgentService { }, })); - // Terminal management — the terminal manager listens to the state - // manager's action stream and dispatches PTY output back through it. - this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager)); - // Server-side tools, executed in-process against each session's own // state. Tool groups are contributed here at startup (feedback today) and // handed to providers that support them during registration (see @@ -581,6 +599,13 @@ export class AgentService extends Disposable implements IAgentService { summary: liveSummary.title || s.summary, status: liveSummary.status, activity: liveSummary.activity, + modifiedTime: Date.parse(liveSummary.modifiedAt), + project: liveSummary.project + ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName } + : s.project, + workingDirectory: typeof liveSummary.workingDirectory === 'string' + ? URI.parse(liveSummary.workingDirectory) + : s.workingDirectory, changes: liveSummary.changes ?? s.changes, changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, ...(_meta !== undefined ? { _meta } : {}), @@ -659,9 +684,15 @@ export class AgentService extends Disposable implements IAgentService { for (const t of sourceTurns) { turnIdMapping.set(t.id, generateUuid()); } + // The SDK fork boundary must be a concrete (SDK-backed) turn. + // When the client forked at a host-injected local turn + // (`/rename` / `!command`), redirect the agent to the preceding + // concrete turn while still seeding the local turns up to the + // fork point into the new session's protocol state below. + const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(buildDefaultChatUri(config.fork.session).toString(), config.fork.turnId); config = { ...config, - fork: { ...config.fork, turnIdMapping }, + fork: { ...config.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) }, }; } } @@ -727,10 +758,18 @@ export class AgentService extends Disposable implements IAgentService { // the source session's turns so the client sees the forked history. if (config?.fork) { const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); + const sourceChatUri = buildDefaultChatUri(config.fork.session).toString(); + const newChatUri = buildDefaultChatUri(session).toString(); let sourceTurns: Turn[] = []; if (sourceState && config.fork.turnIdMapping) { - sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1) - .map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() })); + const originalSlice = sourceState.turns.slice(0, config.fork.turnIndex + 1); + const mapping = config.fork.turnIdMapping; + sourceTurns = originalSlice.map(t => ({ ...t, id: mapping.get(t.id) ?? generateUuid() })); + // Re-persist forked local turns (`/rename`, `!command`) under the + // new session's default chat. `record` (keyed by turn id) + // overwrites any rows a DB copy carried with the SOURCE chat URI, + // and seeds the in-memory index for same-process fork/truncate. + this._persistForkedLocalTurns(session.toString(), sourceChatUri, newChatUri, originalSlice, sourceTurns, mapping); } // Prefix the forked session's title so consumers (sidebar, chat @@ -825,8 +864,12 @@ export class AgentService extends Disposable implements IAgentService { let createOptions = options; if (options?.fork) { const sourceKey = options.fork.source.toString(); - const sourceState = this._stateManager.getChatState(sourceKey) - ?? this._stateManager.getDefaultChatState(sourceKey); + const peerState = this._stateManager.getChatState(sourceKey); + const sourceState = peerState ?? this._stateManager.getDefaultChatState(sourceKey); + // Canonical chat URI the source's local turns are keyed by: when the + // source was found as a peer chat it is `sourceKey`; otherwise it was + // addressed by session URI and its default chat URI is canonical. + const sourceChatUri = peerState ? sourceKey : buildDefaultChatUri(sourceKey); const sourceTurns = sourceState?.turns ?? []; const forkIndex = sourceTurns.findIndex(t => t.id === options.fork!.turnId); if (forkIndex < 0) { @@ -842,12 +885,22 @@ export class AgentService extends Disposable implements IAgentService { } forkedTurns = slice.map(t => ({ ...t, id: turnIdMapping.get(t.id) ?? generateUuid() })); + // Carry forked host-injected local turns (`/rename`, `!command`) + // into the new chat so they survive reload and anchor future + // fork/truncate. + this._persistForkedLocalTurns(sessionKey, sourceChatUri, chat.toString(), slice, forkedTurns, turnIdMapping); + const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sessionKey)?.title; forkedTitle = forkedSourceTitle ? (forkedSourceTitle.startsWith(forkedTitlePrefix) ? forkedSourceTitle : `${forkedTitlePrefix}${forkedSourceTitle}`) : localize('agentHost.forkedChatFallback', "Forked Chat"); - createOptions = { ...options, fork: { ...options.fork, turnIdMapping } }; + // The SDK fork boundary must be a concrete (SDK-backed) turn. When + // the client forked at a host-injected local turn, redirect the + // agent to the preceding concrete turn (the local turns are still + // seeded into the new chat's protocol state above). + const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(sourceChatUri, options.fork.turnId); + createOptions = { ...options, fork: { ...options.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) } }; } } @@ -925,6 +978,74 @@ export class AgentService extends Disposable implements IAgentService { return provider.chats.getMessages(chat); } + /** + * Merges persisted host-injected local turns (`/rename`, `!command`) for + * `chatUri` back into that chat's SDK-derived `turns`, positioned after + * their anchor turn (the concrete turn they were recorded after). Locals + * anchored before any real turn are prepended; locals whose anchor is absent + * from the SDK turns (e.g. truncated away) are dropped. Also seeds the + * in-memory local-turn index so fork/truncate resolve correctly before the + * next reload. + */ + private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise { + const records = await this._localTurns.loadForChat(sessionStr, chatUri); + if (records.length === 0) { + return [...turns]; + } + const knownIds = new Set(turns.map(t => t.id)); + const byAnchor = new Map(); + const head: Turn[] = []; + for (const record of records) { + let turn: Turn; + try { + turn = JSON.parse(record.payload) as Turn; + } catch { + continue; + } + if (record.anchorTurnId === undefined) { + head.push(turn); + } else if (knownIds.has(record.anchorTurnId)) { + const list = byAnchor.get(record.anchorTurnId) ?? []; + list.push(turn); + byAnchor.set(record.anchorTurnId, list); + } + // else: orphaned (anchor truncated away) → drop. + } + const merged: Turn[] = [...head]; + for (const turn of turns) { + merged.push(turn); + const locals = byAnchor.get(turn.id); + if (locals) { + merged.push(...locals); + } + } + return merged; + } + + /** + * Re-persists forked host-injected local turns (`/rename`, `!command`) into + * a newly forked chat so they survive reload and anchor future + * fork/truncate. `originalSlice[i]` and `forkedTurns[i]` are the source turn + * and its remapped copy (same length, 1:1); `mapping` is the old→new turn id + * map used to remap each local turn's anchor. `persistSession` owns the + * destination database; `sourceChatUri` / `newChatUri` key the source and + * destination local-turn indexes. + * + * Shared by the {@link createSession} (default-chat) and {@link createChat} + * (peer-chat) fork paths. + */ + private _persistForkedLocalTurns(persistSession: string, sourceChatUri: string, newChatUri: string, originalSlice: readonly Turn[], forkedTurns: readonly Turn[], mapping: ReadonlyMap): void { + for (let i = 0; i < originalSlice.length; i++) { + const original = originalSlice[i]; + if (!this._localTurns.isLocal(sourceChatUri, original.id)) { + continue; + } + const originalAnchor = this._localTurns.resolveConcreteTurnId(sourceChatUri, original.id); + const newAnchor = originalAnchor !== undefined ? mapping.get(originalAnchor) : undefined; + this._localTurns.record(persistSession, newChatUri, forkedTurns[i], newAnchor); + } + } + /** * Create (or fork) the peer chat `chat` within `session`. `chat` is * always a peer URI here (the default chat is created implicitly with @@ -1856,7 +1977,8 @@ export class AgentService extends Disposable implements IAgentService { this._getChatDraft(session, defaultChatUri), this._readPersistedChatTitle(session, defaultChatUri), ]); - this._stateManager.restoreSession(summary, [...turns], { draft: defaultDraft, defaultChatTitle }); + const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); + this._stateManager.restoreSession(summary, mergedTurns, { draft: defaultDraft, defaultChatTitle }); const promises: Promise[] = []; // Eagerly register subagent child sessions discovered in the event log @@ -2034,7 +2156,8 @@ export class AgentService extends Disposable implements IAgentService { this._readPersistedChatTitle(session, chatUri), this._getChatDraft(session, chatUri), ]); - return { chatUri, title, turns: [...turns], draft, providerData: entry.providerData }; + const mergedTurns = await this._interleaveLocalTurns(session.toString(), chatUri.toString(), turns); + return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData }; })); for (const item of restored) { if (!item) { @@ -2820,6 +2943,10 @@ export class AgentService extends Disposable implements IAgentService { const title = subagentContent?.title ?? 'Subagent'; const subagentNow = new Date().toISOString(); + // Local turns for a subagent chat are persisted in the parent session's + // database (its chat URI resolves to the parent session), keyed by the + // subagent chat URI. + const mergedChildTurns = await this._interleaveLocalTurns(parentSession.toString(), subagentUri, childTurns); this._stateManager.restoreSession( { resource: subagentUri, @@ -2830,7 +2957,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: subagentNow, ...(parentState?.project ? { project: parentState.project } : {}), }, - [...childTurns], + mergedChildTurns, ); this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 4f656342257..35876f5abf6 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -10,7 +10,6 @@ import { autorun, IObservable, IReader } from '../../../base/common/observable.j import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; @@ -47,16 +46,19 @@ import { type ToolResultContent, type Turn } from '../common/state/sessionState.js'; -import { parseRenameCommand } from './agentHostRenameCommand.js'; +import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostTurnTracker } from './agentHostTurnTracker.js'; +import { AgentHostLocalCommands } from './localCommands/localChatCommand.js'; +import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; import type { ICopilotApiService } from './shared/copilotApiService.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js'; +import { persistSessionMetadata } from './shared/persistSessionMetadata.js'; /** * Options for constructing an {@link AgentSideEffects} instance. @@ -68,6 +70,8 @@ export interface IAgentSideEffectsOptions { readonly agents: IObservable; /** Session data service for cleaning up per-session data on disposal. */ readonly sessionDataService: ISessionDataService; + /** Registry that persists host-injected `/rename` and `!command` turns. */ + readonly localTurns: AgentHostLocalTurns; /** Get the GitHub token used for Copilot utility title generation. */ readonly getGitHubCopilotToken?: () => string | undefined; /** CAPI service used for Copilot utility title generation. */ @@ -111,6 +115,9 @@ export class AgentSideEffects extends Disposable { private readonly _permissionManager: SessionPermissionManager; + /** Registry-driven dispatcher for host-handled `/rename` / `!command` etc. */ + private readonly _localCommands: AgentHostLocalCommands; + private readonly _subagentChats = new NKeyMap(); /** @@ -141,8 +148,17 @@ export class AgentSideEffects extends Disposable { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter); - this._toolCallTracker = new AgentHostToolCallTracker(this._telemetryReporter); + this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter)); this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); + this._localCommands = this._register(instantiationService.createInstance( + AgentHostLocalCommands, + this._stateManager, + this._options.localTurns, + // Draining the queue re-enters agent lookup / telemetry / sendMessage, + // which is this class's responsibility, so the dispatcher hands the + // turn back here once it has completed a host-handled command. + (turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel), + )); this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, { sessionDataService: this._options.sessionDataService, getGitHubCopilotToken: this._options.getGitHubCopilotToken, @@ -334,10 +350,17 @@ export class AgentSideEffects extends Disposable { return; } this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); + if (request.kind !== SessionInputRequestKind.ChatInput) { + const agent = this._options.getAgent(sessionUri); + if (agent) { + this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request); + } + } } private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void { const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + this._toolCallTracker.toolCallUnblocked(chatUri, id); if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) { return; } @@ -348,7 +371,7 @@ export class AgentSideEffects extends Disposable { const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) { if (request.chat === chatUri) { - this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id: request.id }); + this._removeSessionInputNeeded(chatUri, request.id); } } } @@ -394,6 +417,9 @@ export class AgentSideEffects extends Disposable { this._publishSessionCustomizationsForAgent(agent); })); } + if (agent.onDidRequireAuth) { + disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e))); + } return disposables; } @@ -938,12 +964,10 @@ export class AgentSideEffects extends Disposable { // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. - // `/rename [title]` is a generic, agent-agnostic slash command: - // it is intercepted here and redirected to a title change rather - // than forwarded to the agent SDK. Mirrors the per-agent text-side - // dispatch (`parseLeadingSlashCommand` in CopilotAgentSession), but - // applies to every session type. - if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text)) { + // Generic, agent-agnostic host commands (`/rename`, `!command`, + // …) are intercepted here and handled by the local-command + // dispatcher rather than forwarded to the agent SDK. + if (this._localCommands.tryHandle({ turnChannel: channel, turnId: action.turnId, text: action.message.text })) { break; } @@ -1054,9 +1078,23 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`); } const agent = this._options.getAgent(sessionChannel); - agent?.truncateSession?.(URI.parse(sessionChannel), action.turnId).catch(err => { + // When the truncation boundary is a host-injected local turn + // (`/rename` / `!command`), redirect the SDK truncation to the + // preceding concrete turn so the agent keeps everything up to + // the real message before it. + const sdkTurnId = action.turnId !== undefined + ? this._options.localTurns.resolveConcreteTurnId(chatChannel, action.turnId) + : action.turnId; + // Route to the chat being truncated: the default chat (addressed + // by the session) or a peer chat with its own backing. + agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => { this._logService.error('[AgentSideEffects] truncateSession failed', err); }); + // Drop persisted local turns that no longer survive in the + // (already-truncated) chat state. + const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id)); + const removed = this._options.localTurns.getLocalTurnIds(chatChannel).filter(id => !survivingIds.has(id)); + this._options.localTurns.deleteLocals(sessionChannel, removed); this._changesets.onSessionTruncated(sessionChannel); break; } @@ -1139,85 +1177,13 @@ export class AgentSideEffects extends Disposable { this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle); } - /** - * Handles the generic `/rename [title]` slash command. When `text` is a - * rename command it is redirected to a {@link ActionType.SessionTitleChanged} - * action (when a non-empty title is supplied) and the just-started turn is - * immediately completed, so the command is never forwarded to the agent SDK. - * - * @returns `true` when the message was a rename command and was handled here - * (the caller MUST NOT forward it to the agent), `false` otherwise. - */ - private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string): boolean { - const title = parseRenameCommand(text); - if (title === undefined) { - return false; - } - const isAdditional = (uri: ProtocolURI | undefined): uri is ProtocolURI => - !!uri && isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(channel) ? channel : undefined; - const sessionChannel = chatTarget ? parseRequiredSessionUriFromChatUri(chatTarget) : (isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel); - // The just-opened turn lives wherever the message was dispatched. - const turnTarget = chatTarget ?? channel; - if (title.length > 0) { - if (chatTarget) { - // Rename only this chat, independently of the session title. - this._stateManager.updateChatTitle(sessionChannel, chatTarget, title); - this._persistSessionFlag(sessionChannel, `customChatTitle:${chatTarget}`, title); - } else { - this._stateManager.dispatchServerAction(sessionChannel, { - type: ActionType.SessionTitleChanged, - title, - }); - // Server-dispatched actions bypass `handleAction`, so persist the - // new title here directly (the client-dispatched rename path relies - // on the `SessionTitleChanged` case in `handleAction` instead). - this._persistSessionFlag(sessionChannel, 'customTitle', title); - } - // Acknowledge the rename with a brief response so the turn has - // visible content in the transcript. - this._stateManager.dispatchServerAction(turnTarget, { - type: ActionType.ChatResponsePart, - turnId, - part: { - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: localize('agentHostRename.renamed', "Renamed: {0}", title), - }, - }); - } - // Close out the turn that the reducer opened for this message so the - // session returns to idle instead of waiting on an agent response. - this._stateManager.dispatchServerAction(turnTarget, { - type: ActionType.ChatTurnComplete, - turnId, - }); - // This turn was completed via a direct server dispatch rather than - // `_runTurnCompleteSideEffects`, so drain any messages queued behind - // the rename ourselves; otherwise they would stall until the next - // unrelated state change re-triggers consumption. - this._tryConsumeNextQueuedMessage(turnTarget); - return true; - } - /** * Persists a session metadata key/value pair to the session database. * Used for fields the host needs to remember across restarts (custom * title, isRead/isArchived flags, merged config values). - * - * Counterpart in `agentHostChangesetService.ts` (`AgentHostChangesetService._persistSessionFlag`): - * keep both copies in sync if the signature changes. Duplicated rather - * than lifted because the two consumers persist disjoint metadata - * (changeset diffs there vs. customTitle / isRead / isArchived / - * configValues here) and a shared util would only have two callers. */ private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - ref.object.setMetadata(key, value).catch(err => { - this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err); - }).finally(() => { - ref.dispose(); - }); + persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); } private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void { @@ -1298,9 +1264,10 @@ export class AgentSideEffects extends Disposable { queuedMessageId: msg.id, }); - // `/rename` is intercepted generically (see the ChatTurnStarted - // handler) and must not reach the agent SDK even when queued. - if (this._tryHandleRenameCommand(session, turnId, msg.message.text)) { + // Generic host commands (`/rename`, `!command`, …) are intercepted by + // the local-command dispatcher (see the ChatTurnStarted handler) and + // must not reach the agent SDK even when queued. + if (this._localCommands.tryHandle({ turnChannel: session, turnId, text: msg.message.text })) { return; } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d0895109946..fb7ee551d9a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -28,7 +28,7 @@ import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../comm import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, SubagentChatSignal } from '../../common/agentService.js'; import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js'; -import { ActionType } from '../../common/state/sessionActions.js'; +import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -219,6 +219,9 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _onDidCustomizationsChange = this._register(new Emitter()); readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; + private readonly _onDidRequireAuth = this._register(new Emitter>()); + readonly onDidRequireAuth = this._onDidRequireAuth.event; + private readonly _models = observableValue(this, []); readonly models: IObservable = this._models; @@ -459,6 +462,18 @@ export class ClaudeAgent extends Disposable implements IAgent { if (next !== this._transportMode) { this._transportMode = next; void this._refreshModels(); + // Flipping into proxy makes GitHub Copilot auth newly required. + // If no proxy handle was ever established, proactively ask the + // client to authenticate rather than waiting for the next command + // to fail with `AHP_AUTH_REQUIRED`. A handle persists across a + // proxy→native→proxy round-trip (cleared only on dispose), so this + // fires only when a credential is genuinely missing. + if (next === 'proxy' && !this._proxyHandle) { + this._onDidRequireAuth.fire({ + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + reason: AuthRequiredReason.Required, + }); + } } })); if (this._transportMode === 'native') { @@ -539,7 +554,7 @@ export class ClaudeAgent extends Disposable implements IAgent { return true; } const tokenChanged = this._githubToken !== token; - if (!tokenChanged) { + if (!tokenChanged && this._proxyHandle) { this._logService.info('[Claude] Auth token unchanged'); return true; } diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts index 6bd29af90d6..30767b1270a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts +++ b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts @@ -504,7 +504,7 @@ export function getClaudePastTenseMessage( case 'Agent': return localize('claude.toolComplete.task', "Ran subagent"); default: - return localize('claude.toolComplete.generic', "Used \"{0}\"", displayName); + return displayName; } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 07203f3f2c3..7d89b548249 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -27,9 +27,11 @@ import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; @@ -42,6 +44,7 @@ import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; +import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; @@ -58,12 +61,22 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; -import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; +import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; +const COPILOT_CAPI_URL = 'https://api.githubcopilot.com'; +/** + * Proxy env vars that indicate the environment already configures a proxy. + */ +const COPILOT_PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const; +/** + * Proxy env vars we set when injecting the resolved CAPI proxy. + */ +const COPILOT_PROXY_SET_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY'] as const; /** * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` @@ -378,6 +391,12 @@ export class CopilotAgent extends Disposable implements IAgent { private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; + /** + * Proxy URL injected into the running client's subprocess env (`undefined` + * when none was injected). Used to detect when a token change alters the + * token-discovered CAPI endpoint's proxy so we can restart the client. + */ + private _appliedProxy: string | undefined; private _githubToken: string | undefined; private _serverToolHost: IAgentServerToolHost | undefined; @@ -463,8 +482,12 @@ export class CopilotAgent extends Disposable implements IAgent { @ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, @IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IAgentHostProxyResolver private readonly _proxyResolver: IAgentHostProxyResolver, ) { super(); this._plugins = this._register(this._instantiationService.createInstance(PluginController)); @@ -632,6 +655,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._updateRestrictedTelemetry(token); this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); if (tokenChanged) { + await this._restartClientIfProxyChanged(); void this._refreshModels(); } return true; @@ -648,13 +672,48 @@ export class CopilotAgent extends Disposable implements IAgent { return handled; } - private _updateRestrictedTelemetry(token: string | undefined): void { - const rtEnabled = isRestrictedTelemetryEnabled(token); + private _updateRestrictedTelemetry(githubToken: string | undefined): void { + // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted + // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no + // `rt`/`tid` claims — those live in the Copilot session token, which the API service mints — + // so the real values are resolved asynchronously below. Mirrors how the Copilot extension + // reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token. + this._applyRestrictedTelemetry(false, undefined, undefined); + if (githubToken) { + void this._resolveRestrictedTelemetry(githubToken); + } + } + + private async _resolveRestrictedTelemetry(githubToken: string): Promise { + try { + const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken); + if (this._githubToken !== githubToken) { + return; // token changed while resolving; a newer call owns the state + } + this._applyRestrictedTelemetry( + ctx.restrictedTelemetryEnabled, + ctx.trackingId, + ctx.telemetryEndpoint ? `${ctx.telemetryEndpoint}/telemetry` : undefined, + ); + } catch (err) { + this._logService.debug(`[Copilot] Restricted telemetry resolution failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private _applyRestrictedTelemetry(rtEnabled: boolean, trackingId: string | undefined, telemetryEndpoint: string | undefined): void { if (rtEnabled !== this._restrictedTelemetryEnabled) { this._restrictedTelemetryEnabled = rtEnabled; - this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`); + this._logService.info(`[Copilot] Enhanced (restricted) telemetry ${rtEnabled ? 'enabled for this account' : 'disabled'}`); this._onDidChangeRestrictedTelemetry.fire(); } + // Push the token-derived telemetry policy/identity to the restricted sender: `rt` gates + // enhanced GH telemetry (kept off for public users), `tid` becomes `copilot_trackingId`, and + // the endpoint routes at the user's CAPI telemetry host (dotcom, GHE, or proxy). + if (isAgentHostTelemetryService(this._telemetryService)) { + this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled); + this._telemetryService.setCopilotTrackingId(trackingId); + this._telemetryService.setRestrictedTelemetryEndpoint(telemetryEndpoint); + } } private async _refreshModels(attempt = 0): Promise { @@ -839,6 +898,7 @@ export class CopilotAgent extends Disposable implements IAgent { env['COPILOT_CLI_RUN_AS_NODE'] = '1'; env['USE_BUILTIN_RIPGREP'] = 'false'; env['COPILOT_MCP_APPS'] = 'true'; + await this._configureProxyEnv(env); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can @@ -1427,6 +1487,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (sourceDbRef) { try { await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + // VACUUM INTO fails if the target already exists; clear + // any stale DB left by a previous (e.g. crashed) attempt. + await fs.rm(targetDbPath.fsPath, { force: true }); await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); } finally { sourceDbRef.dispose(); @@ -1446,6 +1509,16 @@ export class CopilotAgent extends Disposable implements IAgent { const session = agentSession.sessionUri; this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); + + // Copy the source session's reviewed ref so the fork starts with + // the parent's review progress (best-effort; a failure just means + // the fork starts unreviewed). + try { + await this._reviewService.copyReviewedRef(sessionConfig.fork!.session.toString(), session.toString(), workingDirectory); + } catch (err) { + this._logService.warn(`[Copilot] Failed to copy reviewed ref for fork: ${err instanceof Error ? err.message : String(err)}`); + } + const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true); if (sessionConfig.agent !== undefined) { @@ -2269,6 +2342,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (sourceDbRef) { try { await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + // VACUUM INTO fails if the target already exists; clear any + // stale DB left by a previous (e.g. crashed) attempt. + await fs.rm(targetDbPath.fsPath, { force: true }); await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); } finally { sourceDbRef.dispose(); @@ -2456,16 +2532,25 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async truncateSession(session: URI, turnId?: string): Promise { + async truncateSession(session: URI, turnId: string | undefined, chat: URI): Promise { const sessionId = AgentSession.id(session); if (this._provisionalSessions.has(sessionId)) { return; } + const isPeerChat = !isDefaultChatUri(chat); await this._sessionSequencer.queue(sessionId, async () => { - this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); + this._logService.info(`[Copilot:${sessionId}] Truncating ${isPeerChat ? `peer chat ${chat.toString()}` : 'session'}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); - // Ensure the session is loaded so we can use the SDK RPC - const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId); + // Resolve the entry whose history is being truncated: a peer chat has + // its own backing SDK session, so route to it rather than the default + // chat. `_resolveChatEntry` resumes/materializes the chat if needed. + const entry = isPeerChat + ? await this._resolveChatEntry(session, chat) + : (this._findAnySession(sessionId) ?? await this._resumeSession(sessionId)); + if (!entry) { + this._logService.info(`[Copilot:${sessionId}] No chat entry resolved for truncation; nothing to truncate`); + return; + } // Look up the SDK event ID for the truncation boundary. // The protocol semantics: turnId is the last turn to KEEP. @@ -2591,6 +2676,77 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ + private async _configureProxyEnv(env: Record): Promise { + const proxy = await this._resolveProxyForSdk(env); + this._appliedProxy = proxy; + if (proxy) { + for (const key of COPILOT_PROXY_SET_ENV_KEYS) { + env[key] = proxy; + } + this._logService.info('[Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK'); + } + } + + private async _resolveProxyForSdk(env: Record = process.env): Promise { + if (COPILOT_PROXY_ENV_KEYS.some(key => env[key])) { + this._logService.debug('[Copilot] Proxy env var already set; leaving Copilot SDK proxy configuration to the environment'); + return undefined; + } + + let capiUrl = env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL; + if (this._githubToken) { + try { + const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubToken); + if (discovered) { + capiUrl = discovered; + } + } catch (error) { + this._logService.debug(`[Copilot] CAPI endpoint discovery for proxy resolution failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + try { + return await this._proxyResolver.resolveProxy(capiUrl); + } catch (error) { + this._logService.warn(`[Copilot] Failed to resolve CAPI proxy for ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * When the GitHub token changes, the token-discovered CAPI endpoint (and so + * the resolved proxy) can change. The proxy is baked into the SDK subprocess + * env at client start, so if it would now differ we stop the running client + * here; the next `_ensureClient` re-resolves it against the new token. No-op + * when no client is running/starting or the proxy is unchanged. + */ + private async _restartClientIfProxyChanged(): Promise { + if (!this._client && !this._clientStarting) { + return; + } + const oldProxy = this._appliedProxy; + const newProxy = await this._resolveProxyForSdk(); + if (newProxy === oldProxy) { + return; + } + // Let any in-flight start finish so we stop a live client rather than + // racing it (the start would otherwise come up with the stale proxy). + if (this._clientStarting) { + try { + await this._clientStarting; + } catch { + // Start failed; nothing running to restart. + } + } + if (!this._client) { + return; + } + this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`); + this._sessions.clearAndDisposeAll(); + this._mcpNotificationSubs.clearAndDisposeAll(); + await this._stopClient(); + } + /** * Disposes every peer chat hosted on the owning session's entry and drops * their live backings from {@link _chatBackings}. The chat URI encodes its diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 71bc8be4086..86ed281ba4e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -24,6 +24,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; @@ -43,13 +44,14 @@ import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { ActiveClientToolSet } from '../activeClientState.js'; +import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; @@ -587,6 +589,9 @@ export class CopilotAgentSession extends Disposable { return this._mcpCustomizations.runtimeStates; } + /** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */ + private readonly _telemetryReporter: AgentHostTelemetryReporter; + constructor( options: ICopilotAgentSessionOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -595,6 +600,7 @@ export class CopilotAgentSession extends Disposable { @IFileService private readonly _fileService: IFileService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this.sessionId = options.rawSessionId; @@ -608,6 +614,7 @@ export class CopilotAgentSession extends Disposable { this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; this._platform = options.platform ?? process.platform; + this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); @@ -2466,7 +2473,7 @@ export class CopilotAgentSession extends Disposable { turnId: this._turnId, part: { kind: ResponsePartKind.SystemNotification, - content: notification.content, + content: notification.messageText, }, }); return; @@ -2532,6 +2539,14 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); + // Report the enhanced GH `request.options.tools` event for this model call — parity with + // the Copilot extension, which emits it per LLM request. `assistant.message` is the + // agent-host's per-model-call boundary; we correlate on its `x-copilot-service-request-id`. + // Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not + // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. + if (!e.agentId) { + this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), e.data.serviceRequestId, this._appliedSnapshot.tools); + } // The SDK fires a `message` event with the full assembled content after // streaming deltas. If deltas already created a markdown part for this // turn, the live state is up to date and we skip. Only emit a fresh @@ -2539,8 +2554,8 @@ export class CopilotAgentSession extends Disposable { // where the SDK delivered the full message at once). // // Other fields (toolRequests, reasoningText, encryptedContent) are - // only used for history reconstruction and live tool calls fire - // their own tool_start events, so we can safely drop them here. + // only used for history reconstruction and live tool calls fire their + // own tool_start events, so we can safely drop them here. if (!e.data.content) { return; } @@ -2651,6 +2666,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, toolName: e.data.toolName, displayName, + intention: getShellIntention(e.data.toolName, parameters), contributor, _meta: toToolCallMeta(meta), }, parentToolCallId); @@ -2689,11 +2705,8 @@ export class CopilotAgentSession extends Disposable { return; } - // For client tools, do NOT auto-ready — the tool handler will fire - // a separate tool_ready signal once the deferred is in place (or - // the permission flow fires it first). MCP tools have no such - // handler and are auto-readied below alongside built-in tools. - if (contributor?.kind === ToolCallContributorKind.Client) { + const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client && !isAgentCoordinationTool(e.data.toolName); + if (shouldWaitForClientToolReady) { return; } @@ -2845,6 +2858,10 @@ export class CopilotAgentSession extends Disposable { // clickable file link, matching the `view`-tool display style. this._register(wrapper.onSkillInvoked(e => { this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); + if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) { + return; + } + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); const synth = synthesizeSkillToolCall(e.data, e.id); this._emitAction({ type: ActionType.ChatToolCallStart, @@ -2852,14 +2869,14 @@ export class CopilotAgentSession extends Disposable { toolCallId: synth.toolCallId, toolName: synth.toolName, displayName: synth.displayName, - }); + }, parentToolCallId); this._emitAction({ type: ActionType.ChatToolCallReady, turnId: this._turnId, toolCallId: synth.toolCallId, invocationMessage: synth.invocationMessage, confirmed: ToolCallConfirmationReason.NotNeeded, - }); + }, parentToolCallId); this._emitAction({ type: ActionType.ChatToolCallComplete, turnId: this._turnId, @@ -2868,7 +2885,7 @@ export class CopilotAgentSession extends Disposable { success: true, pastTenseMessage: synth.pastTenseMessage, }, - }); + }, parentToolCallId); })); this._register(wrapper.onSubagentStarted(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 7741f953836..00e04a0b6ee 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -515,6 +515,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { clientName: 'vscode', enableMcpApps: true, enableFileHooks: true, + enableConfigDiscovery: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 94e40f56b46..9b409c4aa25 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -6,8 +6,6 @@ import type { Tool, ToolResultObject } from '@github/copilot-sdk'; import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; -import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; -import * as platform from '../../../../base/common/platform.js'; import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -22,22 +20,18 @@ import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js'; + +// Re-exported for consumers (and tests) that historically imported these +// shell helpers from this module. Their canonical home is the shared, +// agent-agnostic shellCommandExecution module. +export { isMultilineCommand, prefixForHistorySuppression, shellTypeForExecutable }; +export type { ShellType }; /** - * Maximum scrollback content (in bytes) returned to the model in tool results. + * Message returned to the model when a command switches to the terminal's + * alternate buffer (typically an interactive full-screen UI). */ -const MAX_OUTPUT_BYTES = 80_000; - -/** - * Default command timeout in milliseconds (120 seconds). - */ -const DEFAULT_TIMEOUT_MS = 120_000; - -/** - * The sentinel prefix used to detect command completion in terminal output. - * The full sentinel format is: `<<_EXIT_>>`. - */ -const SENTINEL_PREFIX = '<<>>"`; - } - return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; -} - -/** - * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` / - * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from - * being recorded in shell history. The shell integration scripts opt these - * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the - * terminal is created with `preventShellHistory: true`). PowerShell - * suppresses history through PSReadLine instead, so no prefix is needed. - */ -export function prefixForHistorySuppression(shellType: ShellType): string { - return shellType === 'powershell' ? '' : ' '; -} - -export function isMultilineCommand(command: string): boolean { - const normalized = command.replace(/\r\n|\r/g, '\n'); - return /(?>>'); - if (endIdx !== -1) { - const exitCodeStr = afterMarker.substring(0, endIdx).trim(); - if (/^-?\d+$/.test(exitCodeStr)) { - return { - found: true, - exitCode: parseInt(exitCodeStr, 10), - outputBeforeSentinel, - }; - } - } - // Ignore echoed sentinel command text (for example `$?`) and continue - // scanning for the latest complete numeric sentinel marker. - markerIndex = content.lastIndexOf(marker, markerIndex - 1); - } - - return { found: false, exitCode: -1, outputBeforeSentinel: content }; -} - -function prepareOutputForModel(rawOutput: string): string { - let text = removeAnsiEscapeCodes(rawOutput).trim(); - if (text.length > MAX_OUTPUT_BYTES) { - text = text.substring(text.length - MAX_OUTPUT_BYTES); - } - return text; -} - // --------------------------------------------------------------------------- // Tool implementations // --------------------------------------------------------------------------- @@ -412,25 +295,33 @@ function makeExecutionResult(toolResult: ToolResultObject, options?: { keepShell return { toolResult, keepShellBusy: options?.keepShellBusy }; } -function makeBackgroundExecutionResult(text: string): IShellExecutionResult { - return makeExecutionResult(makeSuccessResult(text), { keepShellBusy: true }); -} - -function makeAltBufferExecutionResult(): IShellExecutionResult { - return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); -} - -function registerAltBufferHandler( - shell: IManagedShell, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, - disposables: DisposableStore, - finish: (result: IShellExecutionResult) => void, -): void { - void terminalManager.createAltBufferPromise(shell.terminalUri, disposables).then(() => { - logService.info('[ShellTool] Command entered alternate buffer'); - finish(makeAltBufferExecutionResult()); - }); +/** + * Maps the neutral {@link IShellCommandResult} produced by the shared shell + * executor to the Copilot SDK {@link ToolResultObject} shape expected by the + * shell tools. + */ +function shellCommandResultToExecutionResult(result: IShellCommandResult, timeoutMs: number): IShellExecutionResult { + switch (result.status) { + case 'completed': { + const exitCode = result.exitCode ?? 0; + const text = `Exit code: ${exitCode}\n${result.output}`; + return makeExecutionResult(exitCode === 0 ? makeSuccessResult(text) : makeFailureResult(text)); + } + case 'shellExited': + return makeExecutionResult(makeFailureResult(`Shell exited with code ${result.exitCode}\n${result.output}`)); + case 'timeout': + return makeExecutionResult(makeFailureResult( + `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${result.output}`, + 'timeout', + )); + case 'background': + return makeExecutionResult( + makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'), + { keepShellBusy: true }, + ); + case 'altBuffer': + return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); + } } async function executeCommandInShell( @@ -440,9 +331,10 @@ async function executeCommandInShell( terminalManager: IAgentHostTerminalManager, logService: ILogService, ): Promise { - const result = terminalManager.supportsCommandDetection(shell.terminalUri) - ? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService) - : await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); + const result = shellCommandResultToExecutionResult( + await executeShellCommand(shell, command, timeoutMs, terminalManager, logService), + timeoutMs, + ); return { ...result, toolResult: { @@ -452,182 +344,6 @@ async function executeCommandInShell( }; } -/** - * Execute a command using shell integration (OSC 633) for completion detection. - * No sentinel echo is injected — the shell's own command-finished signal - * provides the exit code and cleanly delineated output. - */ -async function executeCommandWithShellIntegration( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { - const disposables = new DisposableStore(); - - const result = new Promise(resolve => { - let resolved = false; - const finish = (result: IShellExecutionResult) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - disposables.add(terminalManager.onCommandFinished(shell.terminalUri, event => { - const output = prepareOutputForModel(event.output); - const exitCode = event.exitCode ?? 0; - logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`); - if (exitCode === 0) { - finish(makeExecutionResult(makeSuccessResult(`Exit code: ${exitCode}\n${output}`))); - } else { - finish(makeExecutionResult(makeFailureResult(`Exit code: ${exitCode}\n${output}`))); - } - })); - - registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeExecutionResult(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - ))); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - - }); - - try { - await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { - shouldExecute: true, - bracketedPasteMode: shouldUseBracketedPasteMode(command), - }); - } catch (err) { - disposables.dispose(); - throw err; - } - - return result; -} - -/** - * Fallback: execute a command using a sentinel echo to detect completion. - * Used when shell integration is not available. - */ -async function executeCommandWithSentinel( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { - const sentinelId = makeSentinelId(); - const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType); - const disposables = new DisposableStore(); - - const contentBefore = terminalManager.getContent(shell.terminalUri) ?? ''; - const offsetBefore = contentBefore.length; - - const result = new Promise(resolve => { - let resolved = false; - const finish = (result: IShellExecutionResult) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - const checkForSentinel = () => { - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - // Clamp offset: the terminal manager trims content when it exceeds - // 100k chars (slices to last 80k). If trimming happened after we - // captured offsetBefore, scan from the start of the current buffer. - const clampedOffset = Math.min(offsetBefore, fullContent.length); - const newContent = fullContent.substring(clampedOffset); - const parsed = parseSentinel(newContent, sentinelId); - if (parsed.found) { - const output = prepareOutputForModel(parsed.outputBeforeSentinel); - logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`); - if (parsed.exitCode === 0) { - finish(makeExecutionResult(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`))); - } else { - finish(makeExecutionResult(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`))); - } - } - }; - - disposables.add(terminalManager.onData(shell.terminalUri, () => { - checkForSentinel(); - })); - - registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeExecutionResult(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - ))); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - - checkForSentinel(); - }); - - try { - await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { - shouldExecute: true, - bracketedPasteMode: shouldUseBracketedPasteMode(command), - }); - await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true }); - } catch (err) { - disposables.dispose(); - throw err; - } - - return result; -} - // --------------------------------------------------------------------------- // Public factory // --------------------------------------------------------------------------- @@ -705,7 +421,7 @@ export async function createShellTools( }, overridesBuiltInTool: true, handler: async (args, invocation) => { - const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; + const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS; const ref = await shellManager.getOrCreateShell( shellType, invocation.toolCallId, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts index 98e74e42da2..43adbcb4782 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -8,8 +8,6 @@ import { softAssertNever } from '../../../../base/common/assert.js'; import { localize } from '../../../../nls.js'; export interface ICopilotSystemNotification { - /** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */ - readonly content: string; /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ readonly messageText: string; /** Whether the runtime notification wakes the agent loop when it arrives while idle. */ @@ -30,7 +28,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste const description = kind.description; const shellId = kind.shellId; return { - content, messageText: description ? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description) : shellId @@ -41,7 +38,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste } case 'agent_completed': return { - content, messageText: kind.status === 'failed' ? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId) : localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId), @@ -49,19 +45,16 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste }; case 'agent_idle': return { - content, messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId), startsTurn: true, }; case 'new_inbox_message': return { - content, messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName), startsTurn: false, }; case 'instruction_discovered': return { - content, messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), startsTurn: false, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index fbc6cdb19e4..c43d590a84d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -193,6 +193,26 @@ interface ICopilotWebFetchToolArgs { url: string; } +/** + * Parameters shared by the agent-coordination tools (`read_agent`, + * `write_agent`). The Copilot CLI identifies the target agent by its + * human-readable `agent_id` (e.g. `math-helper`). + */ +interface ICopilotAgentToolArgs { + agent_id?: string; +} + +/** + * Reads a well-formed `agent_id` from untrusted tool parameters. Since these are + * parsed from JSON they may not match the expected shape, so the id is returned + * only when it is a non-empty string and is therefore safe to render as inline + * markdown code. + */ +function getAgentId(parameters: Record | undefined): string | undefined { + const agentId = (parameters as ICopilotAgentToolArgs | undefined)?.agent_id; + return typeof agentId === 'string' && agentId.length > 0 ? agentId : undefined; +} + /** * Parameters for the `apply_patch` / `git_apply_patch` tools. The patch text * itself lives in `input` using the V4A diff format (file headers like @@ -380,6 +400,18 @@ export function isHiddenTool(toolName: string): boolean { return HIDDEN_TOOL_NAMES.has(toolName); } +/** + * Returns true for the auto-approved agent-coordination tools (list/read/write + * agents). These are client-contributed tools that never go through the + * permission flow, so the agent host auto-readies them at start to surface a + * tailored invocation message instead of the generic fallback. + */ +export function isAgentCoordinationTool(toolName: string): boolean { + return toolName === CopilotToolName.ListAgents + || toolName === CopilotToolName.ReadAgent + || toolName === CopilotToolName.WriteAgent; +} + /** * Returns true when the tool is Copilot's internal Autopilot completion signal. */ @@ -441,6 +473,20 @@ export function isShellTool(toolName: string): boolean { return SHELL_TOOL_NAMES.has(toolName); } +/** + * Extracts the intention for a shell tool call from its `description` + * argument. The Copilot shell tools (`bash`/`powershell`) carry a short + * human-readable description of what the command does, which matches the + * model's intention summary. Non-shell tools have no such argument, so this + * returns `undefined` for them. + */ +export function getShellIntention(toolName: string, parameters: Record | undefined): string | undefined { + if (isShellTool(toolName) && typeof parameters?.description === 'string' && parameters.description.length > 0) { + return parameters.description; + } + return undefined; +} + // ============================================================================= // Display helpers // @@ -644,8 +690,17 @@ export function getInvocationMessage(toolName: string, displayName: string, para } case CopilotToolName.ExitPlanMode: return localize('toolInvoke.exitPlanMode', "Presenting plan"); + case CopilotToolName.Task: + return localize('toolInvoke.task', "Delegating task"); + // The agent-coordination tools (list/read/write agents) are fast, so + // they use a single message for both the running and completed states: + // the past-tense phrasing. See getPastTenseMessage. + case CopilotToolName.ListAgents: + case CopilotToolName.ReadAgent: + case CopilotToolName.WriteAgent: + return getPastTenseMessage(toolName, displayName, parameters, true); default: - return localize('toolInvoke.generic', "Using \"{0}\"", displayName); + return displayName; } } @@ -759,8 +814,26 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } case CopilotToolName.ExitPlanMode: return localize('toolComplete.exitPlanMode', "Exited plan mode"); + case CopilotToolName.Task: + return localize('toolComplete.task', "Delegated task"); + case CopilotToolName.ListAgents: + return localize('toolComplete.listAgents', "Listed agents"); + case CopilotToolName.ReadAgent: { + const agentId = getAgentId(parameters); + if (agentId) { + return md(localize('toolComplete.readAgent', "Read agent {0}", appendEscapedMarkdownInlineCode(agentId))); + } + return localize('toolComplete.readAgentGeneric', "Read agent"); + } + case CopilotToolName.WriteAgent: { + const agentId = getAgentId(parameters); + if (agentId) { + return md(localize('toolComplete.writeAgent', "Wrote to agent {0}", appendEscapedMarkdownInlineCode(agentId))); + } + return localize('toolComplete.writeAgentGeneric', "Wrote to agent"); + } default: - return localize('toolComplete.generic', "Used \"{0}\"", displayName); + return displayName; } } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 01326d7b641..6c618bded01 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -14,9 +14,10 @@ import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; +import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; function tryStringify(value: unknown): string | undefined { try { @@ -71,6 +72,8 @@ interface IToolStartInfo { readonly toolInput?: string; readonly toolKind?: 'terminal' | 'subagent' | 'search'; readonly language?: string; + /** Intention (why the command runs) for shell tools, from their `description` argument. */ + readonly intention?: string; readonly subagentAgentName?: string; readonly subagentDescription?: string; readonly parameters: Record | undefined; @@ -103,10 +106,10 @@ export interface IMapSessionEventsOptions { readonly agent?: AgentSelection; } -function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection }): ITurnBuilder { +function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection; origin?: MessageKind }): ITurnBuilder { const message: Message = { text, - origin: { kind: MessageKind.User }, + origin: { kind: options?.origin ?? MessageKind.User }, ...(options?.attachments?.length ? { attachments: options.attachments } : {}), ...(options?.model ? { model: options.model } : {}), ...(options?.agent ? { agent: options.agent } : {}), @@ -138,6 +141,7 @@ function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCa toolInput: getToolInputString(toolName, parameters, toolArgs), toolKind, language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, + intention: getShellIntention(toolName, parameters), subagentAgentName: subagentMeta?.agentName, subagentDescription: subagentMeta?.description, parameters, @@ -258,6 +262,7 @@ export async function mapSessionEvents( let parentBuilder: ITurnBuilder | undefined; let parentTurnState = TurnState.Cancelled; let parentTurnAborted = false; + let rootAssistantTurnActive = false; const flushParent = (): void => { if (!parentBuilder) { @@ -307,6 +312,16 @@ export async function mapSessionEvents( for (const e of events) { switch (e.type) { + case 'assistant.turn_start': + if (!e.agentId) { + rootAssistantTurnActive = true; + } + break; + case 'assistant.turn_end': + if (!e.agentId) { + rootAssistantTurnActive = false; + } + break; case 'session.model_change': { currentModel = { id: e.data.newModel }; break; @@ -399,6 +414,22 @@ export async function mapSessionEvents( } break; } + case 'system.notification': { + const notification = buildCopilotSystemNotification(e); + if (!notification) { + break; + } + if (rootAssistantTurnActive && parentBuilder) { + parentBuilder.responseParts.push({ + kind: ResponsePartKind.SystemNotification, + content: notification.messageText, + }); + } else if (notification.startsTurn) { + flushParent(); + parentBuilder = newTurnBuilder(e.id, notification.messageText, { origin: MessageKind.SystemNotification }); + } + break; + } case 'subagent.started': { const d = e.data; subagentInfoByToolCallId.set(d.toolCallId, { @@ -483,9 +514,12 @@ export async function mapSessionEvents( const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); if (parentToolCallId) { subagentTurnStates.set(parentToolCallId, TurnState.Cancelled); - } else if (parentBuilder) { - parentTurnState = TurnState.Cancelled; - parentTurnAborted = true; + } else { + rootAssistantTurnActive = false; + if (parentBuilder) { + parentTurnState = TurnState.Cancelled; + parentTurnAborted = true; + } } break; } @@ -680,6 +714,7 @@ function makeCompletedToolCallPart( toolCallId: d.toolCallId, toolName: info.toolName, displayName: info.displayName, + intention: info.intention, invocationMessage: info.invocationMessage, toolInput: info.toolInput, success: d.success, diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 068f36131dd..cb2b1931381 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -7,7 +7,7 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCustomizationConfig.js'; import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; -import { COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +import { appendSystemMessageContent, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; import { resolveToolInstructionsOverride } from './toolInstructions.js'; type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; @@ -148,7 +148,8 @@ export class AgentHostPromptRegistry { */ resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { const config = this._withUniversalSections(this._resolveModelConfig(model, context), context); - return this._withWorkspacelessScratch(config, context); + const withWorkspacelessScratch = this._withWorkspacelessScratch(config, context); + return appendSystemMessageContent(withWorkspacelessScratch, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS); } /** diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index d313daa35a9..fedac2880d7 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -12,6 +12,20 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from */ export const COPILOT_AGENT_HOST_IDENTITY = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.'; +/** Response-formatting contract for workspace links emitted by Agent Host models. */ +export const COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS = [ + '', + 'Always use Markdown links when referring to existing files, folders, or symbols in the workspace. This is very important for helping the user understand your responses.', + '- File: use the file name as the link text and the absolute filesystem path as the target, for example [foo.ts](/path/to/foo.ts).', + '- Folder: links to folders are also supported, with an absolute path to the folder as the target, for example [src/](/path/to/src).', + '- Symbol: link to symbols by using the containing file path with a 1-based line number as the target, for example [myMethod](/path/to/foo.ts:42).', + '- Use `/` path separators in link targets, including on Windows (`C:/path/to/foo.ts`).', + '- Use absolute filesystem paths rather than `file://` URIs.', + '- Do not provide line ranges.', + '- Use a markdown link format every time you refer to a file, folder, or symbol, not just the first time.', + '', +].join('\n'); + /** * Default system-message customization applied to every Copilot CLI agent-host * session that has no per-model override registered in the @@ -67,6 +81,15 @@ export function sectionOverrides(sections: Partial(); + + constructor(private readonly _context: ILocalChatCommandContext) { + super(); + this._register(toDisposable(() => { + for (const terminalUri of this._terminals) { + this._context.terminalManager.disposeTerminal(terminalUri); + } + this._terminals.clear(); + })); + } + + tryHandle(request: ILocalChatCommandRequest): (() => Promise) | undefined { + const command = parseBangCommand(request.text); + if (command === undefined) { + return undefined; + } + return () => this._run(request.turnChannel, request.turnId, command); + } + + private async _run(turnChannel: ProtocolURI, turnId: string, command: string): Promise { + const ctx = this._context; + const sessionChannel = isAhpChatChannel(turnChannel) ? parseRequiredSessionUriFromChatUri(turnChannel) : turnChannel; + const toolCallId = generateUuid(); + const terminalUri = `agenthost-terminal://bang/${generateUuid()}`; + const displayName = localize('agentHostBang.terminal', "Terminal"); + let terminalCreated = false; + try { + const workingDirStr = ctx.getState(sessionChannel)?.workingDirectory; + const cwd = workingDirStr ? URI.parse(workingDirStr).fsPath : undefined; + const shellPath = await ctx.terminalManager.getDefaultShell(); + const shellType = shellTypeForExecutable(shellPath); + + // Surface the command as a tool call and transition it straight to + // running — the user typed it explicitly, so no confirmation. + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallStart, + turnId, + toolCallId, + toolName: 'terminal', + displayName, + intention: command, + }); + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallReady, + turnId, + toolCallId, + invocationMessage: command, + toolInput: command, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const claim: TerminalSessionClaim = { + kind: TerminalClaimKind.Session, + session: sessionChannel, + turnId, + toolCallId, + }; + const params: CreateTerminalParams = { channel: terminalUri, claim, name: displayName, cwd }; + await ctx.terminalManager.createTerminal(params, { shell: shellPath, preventShellHistory: true, nonInteractive: true }); + terminalCreated = true; + this._terminals.add(terminalUri); + + // Reference the terminal so the client can stream live output while + // the command runs. + const terminalContent: ToolResultContent = { type: ToolResultContentType.Terminal, resource: terminalUri, title: displayName }; + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallContentChanged, + turnId, + toolCallId, + content: [terminalContent], + }); + + const result = await executeShellCommand({ terminalUri, shellType }, command, DEFAULT_SHELL_COMMAND_TIMEOUT_MS, ctx.terminalManager, ctx.logService); + const { success, pastTenseMessage } = this._summarizeResult(result); + const content: ToolResultContent[] = [terminalContent]; + if (result.output) { + content.push({ type: ToolResultContentType.Text, text: result.output }); + } + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallComplete, + turnId, + toolCallId, + result: { success, pastTenseMessage, content }, + }); + } catch (err) { + ctx.logService.error(`[BangLocalCommand] Command failed for session=${sessionChannel}: ${err instanceof Error ? err.message : String(err)}`, err); + if (terminalCreated) { + ctx.terminalManager.disposeTerminal(terminalUri); + this._terminals.delete(terminalUri); + } + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallComplete, + turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: localize('agentHostBang.failed', "Failed to run command"), + error: { message: err instanceof Error ? err.message : String(err) }, + }, + }); + } + } + + /** + * Maps a shell command result to a success flag and past-tense summary for + * the completed tool call. + */ + private _summarizeResult(result: IShellCommandResult): { success: boolean; pastTenseMessage: string } { + switch (result.status) { + case 'completed': { + const exitCode = result.exitCode ?? 0; + return exitCode === 0 + ? { success: true, pastTenseMessage: localize('agentHostBang.ran', "Ran command") } + : { success: false, pastTenseMessage: localize('agentHostBang.exited', "Command exited with code {0}", exitCode) }; + } + case 'timeout': + return { success: false, pastTenseMessage: localize('agentHostBang.timedOut', "Command timed out") }; + case 'shellExited': + return { success: false, pastTenseMessage: localize('agentHostBang.shellExited', "Shell exited unexpectedly") }; + case 'background': + return { success: true, pastTenseMessage: localize('agentHostBang.background', "Command is running in the background") }; + case 'altBuffer': + return { success: true, pastTenseMessage: localize('agentHostBang.interactive', "Command opened an interactive terminal") }; + } + } +} + +LocalChatCommandRegistry.register(BangLocalCommand); diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts new file mode 100644 index 00000000000..b4ae872ba44 --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ActionType, StateAction } from '../../common/state/sessionActions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallStatus, ToolResultContentType, type ISessionWithDefaultChat, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { AgentHostLocalTurns } from '../agentHostLocalTurns.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { AgentHostStateManager } from '../agentHostStateManager.js'; +import { persistSessionMetadata } from '../shared/persistSessionMetadata.js'; + +/** + * A just-started chat turn offered to the local-command dispatcher before it is + * forwarded to the agent SDK. + */ +export interface ILocalChatCommandRequest { + /** The chat channel the turn was started on (default or peer chat). */ + readonly turnChannel: ProtocolURI; + /** The turn identifier opened by the reducer for this message. */ + readonly turnId: string; + /** The raw user message text. */ + readonly text: string; +} + +/** + * The narrow set of agent-host capabilities a {@link ILocalChatCommand} may use + * to fulfil a request. Keeps commands decoupled from `AgentSideEffects` + * internals — they emit response content by dispatching server actions and read + * conversation state, plus the few extra capabilities specific commands need + * (terminal execution, chat rename/persist). + */ +export interface ILocalChatCommandContext { + readonly logService: ILogService; + readonly terminalManager: IAgentHostTerminalManager; + /** Dispatch a server-originated action on a channel. */ + dispatch(channel: ProtocolURI, action: StateAction): void; + /** Read the merged session/chat state for a session or chat channel. */ + getState(channel: ProtocolURI): ISessionWithDefaultChat | undefined; + /** Rename a single chat (independently of the session title). */ + updateChatTitle(session: ProtocolURI, chat: ProtocolURI, title: string): void; + /** Persist a session-metadata key/value pair (e.g. a custom title). */ + persistSessionFlag(session: ProtocolURI, key: string, value: string): void; +} + +/** + * A generic, agent-agnostic chat command handled entirely by the agent host + * (never forwarded to the agent SDK) — for example `/rename` or `!command`. + * + * A command decides synchronously whether it applies (so the caller knows + * immediately not to forward the message), then performs its work — emitting + * response parts/tool calls via {@link ILocalChatCommandContext}. The + * {@link AgentHostLocalCommands} dispatcher owns the common tail: completing the + * turn, optionally persisting it as a local turn (so it survives reload and + * anchors fork/truncate), and draining the message queue. + */ +export interface ILocalChatCommand extends IDisposable { + /** Stable identifier for logging/telemetry. */ + readonly name: string; + /** + * Whether the completed turn should be persisted as a host-injected local + * turn (survives reload; anchors fork/truncate to the preceding concrete + * turn). Most user-visible commands want `true`. + */ + readonly recordsLocalTurn: boolean; + /** + * Synchronously decide whether this command handles `request`. Returns a + * thunk that performs the (possibly async) work when it does, or `undefined` + * to decline so the dispatcher tries the next command (and ultimately + * forwards the message to the agent). + */ + tryHandle(request: ILocalChatCommandRequest): (() => Promise) | undefined; +} + +/** Constructs a {@link ILocalChatCommand} bound to a context. */ +export interface ILocalChatCommandCtor { + new(context: ILocalChatCommandContext): ILocalChatCommand; +} + +/** + * Global registry of {@link ILocalChatCommand} constructors. Command modules + * register themselves at load time; {@link AgentHostLocalCommands} instantiates + * all registered commands per session-effects instance with its context. + */ +class LocalChatCommandRegistryImpl { + private readonly _ctors: ILocalChatCommandCtor[] = []; + + register(ctor: ILocalChatCommandCtor): void { + this._ctors.push(ctor); + } + + createAll(context: ILocalChatCommandContext): ILocalChatCommand[] { + return this._ctors.map(ctor => new ctor(context)); + } +} + +export const LocalChatCommandRegistry = new LocalChatCommandRegistryImpl(); + +/** + * Dispatches just-started turns to the registered {@link ILocalChatCommand}s + * and owns everything a host-handled command needs end-to-end: it builds the + * {@link ILocalChatCommandContext} from the state manager and injected services, + * runs the first accepting command, then performs the common tail — completing + * the turn, persisting it as a local turn (so it survives reload and anchors + * fork/truncate), and asking the owner to drain the message queue. + */ +export class AgentHostLocalCommands extends Disposable { + + private readonly _commands: readonly ILocalChatCommand[]; + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _localTurns: AgentHostLocalTurns, + /** + * Invoked after a handled turn is completed so the owner can start the + * next queued message. Draining re-enters the agent-send pipeline, which + * is the owner's concern — not the dispatcher's. + */ + private readonly _notifyTurnConsumable: (turnChannel: ProtocolURI) => void, + @ILogService private readonly _logService: ILogService, + @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); + const context: ILocalChatCommandContext = { + logService: this._logService, + terminalManager: this._terminalManager, + dispatch: (channel, action) => this._stateManager.dispatchServerAction(channel, action), + getState: channel => this._stateManager.getSessionState(channel), + updateChatTitle: (session, chat, title) => this._stateManager.updateChatTitle(session, chat, title), + persistSessionFlag: (session, key, value) => persistSessionMetadata(this._sessionDataService, this._logService, session, key, value), + }; + this._commands = LocalChatCommandRegistry.createAll(context).map(command => this._register(command)); + } + + /** + * Offers `request` to each command. Returns `true` when one handled it (the + * caller MUST NOT forward the message to the agent), `false` otherwise. + */ + tryHandle(request: ILocalChatCommandRequest): boolean { + for (const command of this._commands) { + const work = command.tryHandle(request); + if (work) { + void this._run(command, work, request); + return true; + } + } + return false; + } + + private async _run(command: ILocalChatCommand, work: () => Promise, request: ILocalChatCommandRequest): Promise { + try { + await work(); + } catch (err) { + this._logService.error(`[AgentHostLocalCommands] Command '${command.name}' failed: ${err instanceof Error ? err.message : String(err)}`, err); + } finally { + // Common tail for every host-handled command: close out the turn the + // reducer opened, optionally persist it as a local turn (so it + // survives reload and anchors fork/truncate), then let the owner + // drain any messages queued behind it. + this._stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatTurnComplete, turnId: request.turnId }); + if (command.recordsLocalTurn) { + this._recordLocalTurn(request.turnChannel, request.turnId); + } + this._notifyTurnConsumable(request.turnChannel); + } + } + + /** + * Records the just-completed turn `turnId` as a host-injected local turn so + * it survives reload and fork/truncate can resolve it to the preceding + * concrete turn. Works uniformly for the default chat and any peer chat — + * the turn is keyed by its chat channel. Live terminal references are + * stripped from the payload (the PTY does not survive a reload). + */ + private _recordLocalTurn(turnChannel: ProtocolURI, turnId: string): void { + const chat = turnChannel; + const session = isAhpChatChannel(turnChannel) ? parseRequiredSessionUriFromChatUri(turnChannel) : turnChannel; + const turns = this._stateManager.getSessionState(turnChannel)?.turns; + if (!turns) { + return; + } + const index = turns.findIndex(t => t.id === turnId); + if (index < 0) { + return; + } + // Anchor = the nearest preceding turn in this chat that is not itself a + // local turn. + let anchorTurnId: string | undefined; + for (let i = index - 1; i >= 0; i--) { + if (!this._localTurns.isLocal(chat, turns[i].id)) { + anchorTurnId = turns[i].id; + break; + } + } + this._localTurns.record(session, chat, sanitizeLocalTurnForPersistence(turns[index]), anchorTurnId); + } +} + +/** + * Prepares a host-injected local turn for persistence by dropping live + * {@link ToolResultContentType.Terminal} references from its tool calls — the + * PTY does not survive a reload, so only the captured output (text) is kept. + */ +function sanitizeLocalTurnForPersistence(turn: Turn): Turn { + const responseParts = turn.responseParts.map(part => { + if (part.kind !== ResponsePartKind.ToolCall) { + return part; + } + const tc = part.toolCall; + // Only these tool-call states carry `content` (a live terminal ref lives here). + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { + return part; + } + if (!tc.content) { + return part; + } + const content = tc.content.filter(c => c.type !== ToolResultContentType.Terminal); + if (content.length === tc.content.length) { + return part; + } + return { ...part, toolCall: { ...tc, content } }; + }); + return { ...turn, responseParts }; +} diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts new file mode 100644 index 00000000000..d21f87ee1ec --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Importing this module registers all built-in local chat commands with the +// LocalChatCommandRegistry (via each command module's bottom-of-file +// `register(...)` side effect). Import it wherever the registry must be +// populated (e.g. AgentSideEffects) so adding a new command is just a new file +// plus an import here. +import './renameLocalCommand.js'; +import './bangLocalCommand.js'; diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts new file mode 100644 index 00000000000..91b156f987c --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri, ResponsePartKind, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { parseRenameCommand } from '../agentHostRenameCommand.js'; +import { ILocalChatCommand, ILocalChatCommandContext, ILocalChatCommandRequest, LocalChatCommandRegistry } from './localChatCommand.js'; + +/** + * The generic `/rename [title]` command: renames the session (or an individual + * peer chat) instead of forwarding the message to the agent SDK. Intercepted + * for every agent-host session type. + */ +export class RenameLocalCommand extends Disposable implements ILocalChatCommand { + + readonly name = 'rename'; + readonly recordsLocalTurn = true; + + constructor(private readonly _context: ILocalChatCommandContext) { + super(); + } + + tryHandle(request: ILocalChatCommandRequest): (() => Promise) | undefined { + const title = parseRenameCommand(request.text); + if (title === undefined) { + return undefined; + } + return async () => this._run(request.turnChannel, request.turnId, title); + } + + private _run(channel: ProtocolURI, turnId: string, title: string): void { + if (title.length === 0) { + // `/rename` with no title: nothing to change; the dispatcher still + // completes the turn. + return; + } + const isAdditional = (uri: ProtocolURI): boolean => isAhpChatChannel(uri) && !isDefaultChatUri(uri); + const chatTarget = isAdditional(channel) ? channel : undefined; + const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; + if (chatTarget) { + // Rename only this chat, independently of the session title. + this._context.updateChatTitle(sessionChannel, chatTarget, title); + this._context.persistSessionFlag(sessionChannel, `customChatTitle:${chatTarget}`, title); + } else { + this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); + // Server-dispatched actions bypass `handleAction`, so persist the + // new title here directly (the client-dispatched rename path relies + // on the `SessionTitleChanged` case in `handleAction` instead). + this._context.persistSessionFlag(sessionChannel, 'customTitle', title); + } + // Acknowledge the rename with a brief response so the turn has visible + // content in the transcript. + this._context.dispatch(channel, { + type: ActionType.ChatResponsePart, + turnId, + part: { + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: localize('agentHostRename.renamed', "Renamed: {0}", title), + }, + }); + } +} + +LocalChatCommandRegistry.register(RenameLocalCommand); diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 56a198cd9fe..6e96324a49a 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -71,7 +71,7 @@ export class SessionDataService implements ISessionDataService { } getSessionDataDir(session: URI): URI { - return this.getSessionDataDirById(AgentSession.id(session)); + return URI.joinPath(this._basePath, this._sanitizedSessionKey(session)); } getSessionDataDirById(sessionId: string): URI { @@ -80,7 +80,21 @@ export class SessionDataService implements ISessionDataService { } private _sanitizedSessionKey(session: URI): string { - return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + return this._dataKey(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } + + /** + * Derives the per-URI storage key. Chat channel URIs + * (`ahp-chat:///`) carry the chat id in the + * authority while encoding the SAME owning-session URI in the path, so + * keying only by the path (via {@link AgentSession.id}) would collapse + * every peer chat of a session onto one data directory and database. + * Prefixing with the authority gives each chat its own storage while + * leaving plain session URIs (no authority) unchanged. + */ + private _dataKey(uri: URI): string { + const id = AgentSession.id(uri); + return uri.authority ? `${uri.authority}-${id}` : id; } openDatabase(session: URI): IReference { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index ccce1b2189e..3198c371db8 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -6,9 +6,9 @@ import * as fs from 'fs'; import { SequencerByKey } from '../../../base/common/async.js'; import type { Database, RunResult } from '@vscode/sqlite3'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; -import type { URI } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import type { Message } from '../common/state/sessionState.js'; /** @@ -94,6 +94,24 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ draft TEXT NOT NULL )`, }, + { + version: 7, + sql: `CREATE TABLE IF NOT EXISTS reviewed_files ( + uri TEXT NOT NULL, + nonce TEXT NOT NULL, + PRIMARY KEY (uri, nonce) + )`, + }, + { + version: 8, + sql: `CREATE TABLE IF NOT EXISTS local_turns ( + turn_id TEXT PRIMARY KEY NOT NULL, + chat_uri TEXT NOT NULL, + anchor_turn_id TEXT, + seq INTEGER NOT NULL, + payload TEXT NOT NULL + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -410,6 +428,41 @@ export class SessionDatabase implements ISessionDatabase { }); } + // ---- Local (host-injected) turns ------------------------------------ + + insertLocalTurn(record: ILocalTurnRecord): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, + 'INSERT OR REPLACE INTO local_turns (turn_id, chat_uri, anchor_turn_id, seq, payload) VALUES (?, ?, ?, ?, ?)', + [record.turnId, record.chatUri, record.anchorTurnId ?? null, record.seq, record.payload], + ); + }); + } + + async getLocalTurns(): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT turn_id, chat_uri, anchor_turn_id, seq, payload FROM local_turns ORDER BY seq', []); + return rows.map(r => ({ + turnId: r.turn_id as string, + chatUri: r.chat_uri as string, + anchorTurnId: (r.anchor_turn_id as string | null) ?? undefined, + seq: r.seq as number, + payload: r.payload as string, + })); + } + + deleteLocalTurns(turnIds: readonly string[]): Promise { + return this._track(async () => { + if (turnIds.length === 0) { + return; + } + const db = await this._ensureDb(); + const placeholders = turnIds.map(() => '?').join(','); + await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]); + }); + } + // ---- File edits ----------------------------------------------------- storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { @@ -583,6 +636,40 @@ export class SessionDatabase implements ISessionDatabase { } } + // ---- Reviewed files ------------------------------------------------- + + markFileReviewed(uri: URI, nonce: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]); + }); + } + + unmarkFileReviewed(uri: URI, nonce: string): Promise { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]); + }); + } + + async getReviewedFiles(): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files ORDER BY rowid', []); + return rows.map(toReviewedFileRecord); + } + + async getReviewedFilesForUri(uri: URI): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files WHERE uri = ? ORDER BY rowid', [uri.toString()]); + return rows.map(toReviewedFileRecord); + } + + async isFileReviewed(uri: URI, nonce: string): Promise { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT 1 FROM reviewed_files WHERE uri = ? AND nonce = ? LIMIT 1', [uri.toString(), nonce]); + return !!row; + } + remapTurnIds(mapping: ReadonlyMap): Promise { return this._track(async () => { const db = await this._ensureDb(); @@ -608,6 +695,18 @@ export class SessionDatabase implements ISessionDatabase { await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); } + + if (oldIds.length > 0) { + const placeholders = oldIds.map(() => '?').join(','); + await dbRun(db, + `DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`, + oldIds, + ); + } + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]); + } await dbExec(db, 'COMMIT'); } catch (err) { await dbExec(db, 'ROLLBACK'); @@ -658,6 +757,13 @@ export class SessionDatabase implements ISessionDatabase { } } +function toReviewedFileRecord(row: Record): IReviewedFileRecord { + return { + uri: URI.parse(row.uri as string), + nonce: row.nonce as string, + }; +} + function toUint8Array(value: unknown): Uint8Array { if (value instanceof Buffer) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 667de656e5d..1d382def946 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -11,6 +11,7 @@ import { createDecorator } from '../../../instantiation/common/instantiation.js' import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; +import { parseCopilotTokenFields } from '../copilot/copilotTokenFields.js'; // #region Types @@ -92,6 +93,10 @@ interface ICopilotUserResponse { interface ICachedClient { readonly capiClient: CAPIClient; readonly expiresAt: number; + /** The CAPI `endpoints.telemetry` base URL discovered for this token, if any. */ + readonly telemetryEndpoint?: string; + /** The CAPI `endpoints.api` base URL discovered (or overridden) for this token, if any. */ + readonly apiEndpoint?: string; } /** @@ -380,6 +385,20 @@ export const ICopilotApiService = createDecorator('copilotAp * and is not part of the Anthropic-shaped CAPI surface. * - Malformed JSON in an SSE `data:` line is logged and skipped, not thrown. */ +/** + * Restricted/enhanced telemetry context derived from a user's minted CAPI Copilot session token, + * mirroring what the Copilot extension reads off its `CopilotToken` (`rt` opt-in, `tid` tracking id) + * plus the CAPI `endpoints.telemetry` host. + */ +export interface IRestrictedTelemetryContext { + /** Whether the token opts into enhanced/restricted telemetry (the `rt=1` claim). */ + readonly restrictedTelemetryEnabled: boolean; + /** The Copilot user tracking id (`tid` claim), or `undefined` when absent. */ + readonly trackingId: string | undefined; + /** The CAPI `endpoints.telemetry` base URL, resolved only when enabled; `undefined` otherwise. */ + readonly telemetryEndpoint: string | undefined; +} + export interface ICopilotApiService { readonly _serviceBrand: undefined; @@ -473,6 +492,25 @@ export interface ICopilotApiService { request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions, ): Promise; + + /** + * Resolve this user's restricted-telemetry context from the minted CAPI Copilot session token — + * the `rt` opt-in and `tid` tracking id — plus the CAPI `endpoints.telemetry` host. The GitHub + * token itself carries none of these claims; they live in the Copilot session token (minted via + * `RequestType.CopilotToken`), exactly as the Copilot extension reads them off its `CopilotToken`. + * The telemetry endpoint is resolved only when enabled, so public users incur no extra discovery. + */ + resolveRestrictedTelemetryContext(githubToken: string): Promise; + + /** + * Resolve the CAPI `endpoints.api` base URL discovered for this GitHub token + * (or the loopback test override), or `undefined` when discovery hasn't run + * or failed. The effective CAPI host varies by account (consumer + * `api.githubcopilot.com` vs. Enterprise / proxy), so callers that need the + * real host — e.g. to resolve the correct proxy — should prefer this over the + * hardcoded default. + */ + resolveApiEndpoint(githubToken: string): Promise; } export class CopilotApiService implements ICopilotApiService { @@ -799,16 +837,40 @@ export class CopilotApiService implements ICopilotApiService { * dispatched for token B. */ private _getClientForToken(githubToken: string): Promise { + return this._getEntryForToken(githubToken).then(entry => entry.capiClient); + } + + /** + * Resolve this user's restricted-telemetry context. Reads the `rt`/`tid` claims from the minted + * CAPI Copilot session token (the GitHub token has neither), and resolves the CAPI + * `endpoints.telemetry` host from the cached `/copilot_internal/user` discovery only when the + * user is opted in, so public users pay no extra discovery call. + */ + async resolveRestrictedTelemetryContext(githubToken: string): Promise { + const fields = parseCopilotTokenFields(await this._getCopilotToken(githubToken)); + const restrictedTelemetryEnabled = fields.get('rt') === '1'; + const trackingId = fields.get('tid'); + const telemetryEndpoint = restrictedTelemetryEnabled + ? (await this._getEntryForToken(githubToken)).telemetryEndpoint + : undefined; + return { restrictedTelemetryEnabled, trackingId, telemetryEndpoint }; + } + + async resolveApiEndpoint(githubToken: string): Promise { + return (await this._getEntryForToken(githubToken)).apiEndpoint; + } + + private _getEntryForToken(githubToken: string): Promise { const nowSeconds = Date.now() / 1000; const existing = this._clientsByToken.get(githubToken); if (existing) { return existing.then(entry => { if (entry.expiresAt - nowSeconds > CAPI_CONTEXT_REFRESH_BUFFER_SECONDS) { - return entry.capiClient; + return entry; } // Stale — evict and recurse to build a fresh entry. this._clientsByToken.delete(githubToken); - return this._getClientForToken(githubToken); + return this._getEntryForToken(githubToken); }).catch(err => { // A previous failed build leaked into the cache; evict and rebuild. this._clientsByToken.delete(githubToken); @@ -824,7 +886,7 @@ export class CopilotApiService implements ICopilotApiService { throw err; }); this._clientsByToken.set(githubToken, pending); - return pending.then(entry => entry.capiClient); + return pending; } private _invalidateClientForToken(githubToken: string): void { @@ -859,6 +921,7 @@ export class CopilotApiService implements ICopilotApiService { return { capiClient, expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, + apiEndpoint: overrideApi, }; } this._logService.warn(`[CopilotApiService] Ignoring non-loopback CAPI URL override ${overrideApi}; falling back to normal endpoint discovery`); @@ -890,6 +953,8 @@ export class CopilotApiService implements ICopilotApiService { return { capiClient, expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, + telemetryEndpoint: envelope.endpoints?.telemetry, + apiEndpoint: envelope.endpoints?.api, }; } diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index ab7b878c23a..a007ba100c7 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -12,6 +12,7 @@ import { createDecorator } from '../../../instantiation/common/instantiation.js' import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agentService.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './editSurvivalTracker.js'; /** @@ -191,13 +192,18 @@ class SessionEditSurvivalReporter extends Disposable { ? computeChunkedEditSurvival(this._params.beforeText, this._params.afterText, aiChunks, currentText) : computeWholeFileEditSurvival(this._params.beforeText, this._params.afterText, currentText); + // Sub-channel URIs (e.g. `ahp-chat:` for the default chat or + // subagent chats) encode the parent session URI; resolve them + // back so provider/id reflect the underlying harness rather than + // the chat scheme. See telemetry gap #6 in #8209. + const sessionUri = isAhpChatChannel(this._params.sessionUri) ? parseRequiredSessionUriFromChatUri(this._params.sessionUri) : this._params.sessionUri; this._telemetryService.publicLog2( 'agentHost.trackEditSurvival', { - provider: AgentSession.provider(this._params.sessionUri) ?? 'unknown', + provider: AgentSession.provider(sessionUri) ?? 'unknown', modelId: this._params.modelId ?? '', toolName: this._params.toolName ?? '', - agentSessionId: AgentSession.id(this._params.sessionUri), + agentSessionId: AgentSession.id(sessionUri), turnId: this._params.turnId, toolCallId: this._params.toolCallId, fileExtension: extname(this._params.filePath), diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts new file mode 100644 index 00000000000..3be196ad36a --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import type { ISessionDataService } from '../../common/sessionDataService.js'; + +/** + * Fire-and-forget persistence of a single session-metadata key/value pair to a + * session's database. Opens the database, writes the value, and disposes the + * handle; failures are logged, not thrown. + * + * Used for host-owned fields that must survive restart (custom titles, isRead / + * isArchived flags, merged config values, …). Shared so callers do not each + * re-implement the open/write/dispose dance. + */ +export function persistSessionMetadata(sessionDataService: ISessionDataService, logService: ILogService, session: string, key: string, value: string): void { + const ref = sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata(key, value).catch(err => { + logService.warn(`[AgentHost] Failed to persist session metadata '${key}'`, err); + }).finally(() => { + ref.dispose(); + }); +} diff --git a/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts b/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts new file mode 100644 index 00000000000..5394e190a86 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts @@ -0,0 +1,372 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import * as platform from '../../../../base/common/platform.js'; +import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILogService } from '../../../log/common/log.js'; +import { TerminalClaimKind } from '../../common/state/protocol/state.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; + +/** + * Maximum scrollback content (in bytes) returned to the model / caller in + * command results. + */ +export const SHELL_COMMAND_MAX_OUTPUT_BYTES = 80_000; + +/** + * Default command timeout in milliseconds (120 seconds). + */ +export const DEFAULT_SHELL_COMMAND_TIMEOUT_MS = 120_000; + +/** + * The sentinel prefix used to detect command completion in terminal output + * when shell integration is unavailable. The full sentinel format is: + * `<<_EXIT_>>`. + */ +const SENTINEL_PREFIX = '<<>>"`; + } + return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; +} + +function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { + const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`; + let markerIndex = content.lastIndexOf(marker); + while (markerIndex !== -1) { + const outputBeforeSentinel = content.substring(0, markerIndex); + const afterMarker = content.substring(markerIndex + marker.length); + const endIdx = afterMarker.indexOf('>>>'); + if (endIdx !== -1) { + const exitCodeStr = afterMarker.substring(0, endIdx).trim(); + if (/^-?\d+$/.test(exitCodeStr)) { + return { + found: true, + exitCode: parseInt(exitCodeStr, 10), + outputBeforeSentinel, + }; + } + } + // Ignore echoed sentinel command text (for example `$?`) and continue + // scanning for the latest complete numeric sentinel marker. + markerIndex = content.lastIndexOf(marker, markerIndex - 1); + } + + return { found: false, exitCode: -1, outputBeforeSentinel: content }; +} + +/** + * Strips ANSI escape codes and trims the terminal output to the last + * {@link SHELL_COMMAND_MAX_OUTPUT_BYTES} bytes so it is safe to surface to a + * model or the transcript. + */ +export function prepareOutputForModel(rawOutput: string): string { + let text = removeAnsiEscapeCodes(rawOutput).trim(); + if (text.length > SHELL_COMMAND_MAX_OUTPUT_BYTES) { + text = text.substring(text.length - SHELL_COMMAND_MAX_OUTPUT_BYTES); + } + return text; +} + +/** + * Terminal against which a shell command is executed. + */ +export interface IShellCommandTarget { + /** URI of the managed terminal the command runs in. */ + readonly terminalUri: string; + /** The kind of shell backing the terminal. */ + readonly shellType: ShellType; +} + +/** + * How a shell command execution finished. + * + * - `completed` — the command finished; {@link IShellCommandResult.exitCode} holds the exit code. + * - `timeout` — the command did not finish within the timeout; output is partial. + * - `background` — the terminal claim was narrowed (user chose to continue in background). + * - `altBuffer` — the command switched to the terminal's alternate buffer (interactive UI). + * - `shellExited` — the shell process exited unexpectedly. + */ +export type ShellCommandStatus = 'completed' | 'timeout' | 'background' | 'altBuffer' | 'shellExited'; + +/** + * Neutral, agent-agnostic result of executing a shell command. Callers map this + * to their own result shape (e.g. an SDK `ToolResultObject` or an AHP tool call + * completion). + */ +export interface IShellCommandResult { + /** How the command execution finished. */ + readonly status: ShellCommandStatus; + /** Exit code, when known (`completed` and `shellExited`). */ + readonly exitCode?: number; + /** Cleaned command output (empty for `background`/`altBuffer`). */ + readonly output: string; +} + +/** + * Execute a command on an already-created managed terminal, resolving once the + * command finishes, times out, backgrounds, enters the alternate buffer, or the + * shell exits. Uses shell integration (OSC 633) for completion detection when + * available and falls back to a sentinel echo otherwise. + * + * This is the shared shell-integration primitive used by both the Copilot SDK + * shell tools and the agent-host `!command` runner. + */ +export function executeShellCommand( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise { + return terminalManager.supportsCommandDetection(target.terminalUri) + ? executeCommandWithShellIntegration(target, command, timeoutMs, terminalManager, logService) + : executeCommandWithSentinel(target, command, timeoutMs, terminalManager, logService); +} + +function registerAltBufferHandler( + target: IShellCommandTarget, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, + disposables: DisposableStore, + finish: (result: IShellCommandResult) => void, +): void { + void terminalManager.createAltBufferPromise(target.terminalUri, disposables).then(() => { + logService.info('[ShellCommand] Command entered alternate buffer'); + finish({ status: 'altBuffer', output: '' }); + }); +} + +/** + * Execute a command using shell integration (OSC 633) for completion detection. + * No sentinel echo is injected — the shell's own command-finished signal + * provides the exit code and cleanly delineated output. + */ +async function executeCommandWithShellIntegration( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise { + const disposables = new DisposableStore(); + + const result = new Promise(resolve => { + let resolved = false; + const finish = (result: IShellCommandResult) => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(result); + }; + + disposables.add(terminalManager.onCommandFinished(target.terminalUri, event => { + const output = prepareOutputForModel(event.output); + const exitCode = event.exitCode ?? 0; + logService.info(`[ShellCommand] Command completed (shell integration) with exit code ${exitCode}`); + finish({ status: 'completed', exitCode, output }); + })); + + registerAltBufferHandler(target, terminalManager, logService, disposables, finish); + + disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => { + logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(fullContent) }); + })); + + disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => { + if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { + logService.info(`[ShellCommand] Continuing in background (claim narrowed)`); + finish({ status: 'background', output: '' }); + } + })); + + const timer = setTimeout(() => { + logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + finish({ status: 'timeout', output: prepareOutputForModel(fullContent) }); + }, timeoutMs); + disposables.add(toDisposable(() => clearTimeout(timer))); + + }); + + try { + await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, { + shouldExecute: true, + bracketedPasteMode: shouldUseBracketedPasteMode(command), + }); + } catch (err) { + disposables.dispose(); + throw err; + } + + return result; +} + +/** + * Fallback: execute a command using a sentinel echo to detect completion. + * Used when shell integration is not available. + */ +async function executeCommandWithSentinel( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise { + const sentinelId = makeSentinelId(); + const sentinelCmd = buildSentinelCommand(sentinelId, target.shellType); + const disposables = new DisposableStore(); + + const contentBefore = terminalManager.getContent(target.terminalUri) ?? ''; + const offsetBefore = contentBefore.length; + + const result = new Promise(resolve => { + let resolved = false; + const finish = (result: IShellCommandResult) => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(result); + }; + + const checkForSentinel = () => { + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + // Clamp offset: the terminal manager trims content when it exceeds + // 100k chars (slices to last 80k). If trimming happened after we + // captured offsetBefore, scan from the start of the current buffer. + const clampedOffset = Math.min(offsetBefore, fullContent.length); + const newContent = fullContent.substring(clampedOffset); + const parsed = parseSentinel(newContent, sentinelId); + if (parsed.found) { + const output = prepareOutputForModel(parsed.outputBeforeSentinel); + logService.info(`[ShellCommand] Command completed with exit code ${parsed.exitCode}`); + finish({ status: 'completed', exitCode: parsed.exitCode, output }); + } + }; + + disposables.add(terminalManager.onData(target.terminalUri, () => { + checkForSentinel(); + })); + + registerAltBufferHandler(target, terminalManager, logService, disposables, finish); + + disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => { + logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + const newContent = fullContent.substring(offsetBefore); + finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(newContent) }); + })); + + disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => { + if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { + logService.info(`[ShellCommand] Continuing in background (claim narrowed)`); + finish({ status: 'background', output: '' }); + } + })); + + const timer = setTimeout(() => { + logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + const newContent = fullContent.substring(offsetBefore); + finish({ status: 'timeout', output: prepareOutputForModel(newContent) }); + }, timeoutMs); + disposables.add(toDisposable(() => clearTimeout(timer))); + + checkForSentinel(); + }); + + try { + await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, { + shouldExecute: true, + bracketedPasteMode: shouldUseBracketedPasteMode(command), + }); + await terminalManager.sendText(target.terminalUri, sentinelCmd, { shouldExecute: true }); + } catch (err) { + disposables.dispose(); + throw err; + } + + return result; +} diff --git a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts index 9a1b308ab95..16c20ea8d49 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; +import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; suite('agentHostSessionType', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -51,4 +51,20 @@ suite('agentHostSessionType', () => { undefined, ]); }); + + test('parses harness from remote session type', () => { + assert.deepStrictEqual([ + parseRemoteAgentHostHarness('remote-foo-copilotcli'), + parseRemoteAgentHostHarness('remote-foo-bar-claude'), + parseRemoteAgentHostHarness('remote-10.0.0.1__8080-codex'), + parseRemoteAgentHostHarness('vscodeLocalChatSession'), + parseRemoteAgentHostHarness('remote-'), + ], [ + 'copilotcli', + 'claude', + 'codex', + undefined, + undefined, + ]); + }); }); diff --git a/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts b/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts new file mode 100644 index 00000000000..4950407ee5a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey, buildAgentHostTelemetryIdEnv } from '../../common/agentHostTelemetryEnv.js'; + +suite('agentHostTelemetryEnv', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('buildAgentHostTelemetryIdEnv forwards present ids and omits empty ones', () => { + assert.deepStrictEqual([ + buildAgentHostTelemetryIdEnv({ machineId: 'm1', sqmId: 's1', devDeviceId: 'd1' }), + buildAgentHostTelemetryIdEnv({ machineId: 'm1', sqmId: '', devDeviceId: 'd1' }), + buildAgentHostTelemetryIdEnv({ machineId: '', sqmId: '', devDeviceId: '' }), + ], [ + { [AgentHostMachineIdEnvKey]: 'm1', [AgentHostSqmIdEnvKey]: 's1', [AgentHostDevDeviceIdEnvKey]: 'd1' }, + { [AgentHostMachineIdEnvKey]: 'm1', [AgentHostDevDeviceIdEnvKey]: 'd1' }, + {}, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index cf27b6c919b..57e451a81cc 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -8,13 +8,15 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { Event } from '../../../../base/common/event.js'; import type { IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { Message } from '../../common/state/sessionState.js'; export class TestSessionDatabase implements ISessionDatabase { private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; private readonly _metadata = new Map(); private readonly _drafts = new Map(); + private readonly _reviewedFiles: IReviewedFileRecord[] = []; + private readonly _localTurns = new Map(); getAllFileEditsCalls = 0; getFileEditsByTurnCalls = 0; @@ -113,8 +115,46 @@ export class TestSessionDatabase implements ISessionDatabase { this._edits.length = 0; } + async insertLocalTurn(record: ILocalTurnRecord): Promise { + this._localTurns.set(record.turnId, record); + } + + async getLocalTurns(): Promise { + return [...this._localTurns.values()].sort((a, b) => a.seq - b.seq); + } + + async deleteLocalTurns(turnIds: readonly string[]): Promise { + for (const id of turnIds) { + this._localTurns.delete(id); + } + } async remapTurnIds(_mapping: ReadonlyMap): Promise { } + async markFileReviewed(uri: URI, nonce: string): Promise { + if (!this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce)) { + this._reviewedFiles.push({ uri, nonce }); + } + } + + async unmarkFileReviewed(uri: URI, nonce: string): Promise { + const index = this._reviewedFiles.findIndex(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + if (index >= 0) { + this._reviewedFiles.splice(index, 1); + } + } + + async getReviewedFiles(): Promise { + return [...this._reviewedFiles]; + } + + async getReviewedFilesForUri(uri: URI): Promise { + return this._reviewedFiles.filter(r => r.uri.toString() === uri.toString()); + } + + async isFileReviewed(uri: URI, nonce: string): Promise { + return this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + } + async setTurnCheckpointRef(_turnId: string, _ref: string): Promise { } async getTurnCheckpointRef(_turnId: string): Promise { return undefined; } @@ -213,16 +253,51 @@ export function createNoopGitService(): import('../../common/agentHostGitService push: async () => { }, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, showBlob: async () => undefined, captureWorkingTreeAsTree: async () => undefined, commitTree: async () => undefined, updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; } +/** + * Returns a no-op {@link IAgentHostChangesetService} for tests that need to + * inject the changeset service but don't exercise changeset computation. + * Individual methods can be reassigned by callers that want to spy on them. + */ +export function createNoopChangesetService(): import('../../common/agentHostChangesetService.js').IAgentHostChangesetService { + return { + _serviceBrand: undefined, + registerStaticChangesets: () => { }, + restoreStaticChangeset: () => { }, + parsePersistedStaticChangesets: () => ({}), + applyPersistedStaticChangesets: () => { }, + restorePersistedStaticChangesets: () => ({}), + persistChangesSummary: () => { }, + getListMetadataKeys: () => undefined, + computeListEntryChanges: () => undefined, + isStaticChangesetComputeActive: () => false, + refreshChangesetCatalog: () => { }, + refreshBranchChangeset: () => { }, + refreshSessionChangeset: () => { }, + onWorkingDirectoryAvailable: () => { }, + recomputeSubscribedChangesets: () => { }, + onSessionDisposed: () => { }, + computeTurnChangeset: async session => session, + computeCompareTurnsChangeset: async session => session, + computeUncommittedChangeset: async session => session, + onToolCallEditsApplied: () => { }, + onTurnComplete: () => { }, + onSessionTruncated: () => { }, + }; +} + function createReference(object: T): IReference { return { object, diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 5c4a3a2cf0a..d27958bfbdf 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL } from '../../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; import { registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; @@ -53,20 +54,20 @@ suite('registerAgentHostClientChannels', () => { test('registers both channels when BYOK is enabled and the handler is available', () => { const { server, registered } = fakeChannelServer(); registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, true); - assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_BYOK_LM_CHANNEL]); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL, AGENT_HOST_CLIENT_BYOK_LM_CHANNEL]); }); - test('registers only the resource channel and does NOT throw when the BYOK handler is missing', () => { + test('registers only the resource and proxy channels and does NOT throw when the BYOK handler is missing', () => { const { server, registered } = fakeChannelServer(); // Must not throw: the agent host connection has to come up even if a // window connects without the handler and so cannot serve BYOK itself. registerAgentHostClientChannels(server, fakeInstantiationService(true), new NullLogService(), undefined, true); - assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL]); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL]); }); - test('registers only the resource channel when BYOK is disabled', () => { + test('registers only the resource and proxy channels when BYOK is disabled', () => { const { server, registered } = fakeChannelServer(); registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, false); - assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL]); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL]); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts b/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts new file mode 100644 index 00000000000..48c723c3452 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseBangCommand } from '../../node/agentHostBangCommand.js'; + +suite('agentHostBangCommand', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses a leading ! command', () => { + assert.strictEqual(parseBangCommand('!echo hi'), 'echo hi'); + assert.strictEqual(parseBangCommand('!ls -la'), 'ls -la'); + assert.strictEqual(parseBangCommand('! npm test '), 'npm test'); + }); + + test('is undefined for non-bang messages', () => { + assert.strictEqual(parseBangCommand('echo hi'), undefined); + assert.strictEqual(parseBangCommand(' !echo hi'), undefined); + assert.strictEqual(parseBangCommand('run !echo'), undefined); + }); + + test('is undefined for a lone ! or whitespace-only command', () => { + assert.strictEqual(parseBangCommand('!'), undefined); + assert.strictEqual(parseBangCommand('! '), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index d865d073288..cb581ed2d29 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -18,6 +18,7 @@ import { AgentHostChangesetService } from '../../node/agentHostChangesetService. import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -92,6 +93,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), + NULL_REVIEW_SERVICE, )); }); @@ -276,7 +278,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); localStateManager.createSession({ resource: sessionUri.toString(), @@ -354,7 +356,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -390,7 +392,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -423,7 +425,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); localStateManager.createSession({ resource: sessionUri.toString(), @@ -483,7 +485,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -557,7 +559,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -606,6 +608,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), subscriptionService, + NULL_REVIEW_SERVICE, )); return { service, localStateManager, computes, subscriptions: subscriptionService.subscriptions }; } @@ -927,6 +930,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), subscriptionService, + NULL_REVIEW_SERVICE, )); } test('onTurnComplete schedules a per-turn recompute when someone is subscribed', async () => { @@ -1034,6 +1038,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildTurnChangesetUri(sessionUri.toString(), 'turn-1')), + NULL_REVIEW_SERVICE, )); localStateManager.createSession({ @@ -1114,6 +1119,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1147,6 +1153,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1177,6 +1184,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1208,6 +1216,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 4473c15e9b9..460db9820d0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -64,6 +64,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise { } async deleteRefs(): Promise { } async revParse(): Promise { return undefined; } + async resolveBranchBaselineCommit(): Promise { return undefined; } + async overlayPathIntoTree(): Promise { return undefined; } + async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } @@ -88,6 +91,8 @@ class TestCopilotApiService implements ICopilotApiService { } async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.calls.push({ token: githubToken, request, options }); if (this.error) { diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index e8f5f65d23f..27bf7e89a3f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -51,6 +51,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise { } async deleteRefs(): Promise { } async revParse(): Promise { return undefined; } + async resolveBranchBaselineCommit(): Promise { return undefined; } + async overlayPathIntoTree(): Promise { return undefined; } + async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 9612a2c6421..fc431c4329e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -580,3 +580,179 @@ suite('AgentHostGitService - restore (real git)', () => { await assert.rejects(() => svc!.restore(URI.file(dir), ['a.txt'])); }); }); + +suite('AgentHostGitService - overlayPathIntoTree (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + let tmpRoot: string | undefined; + let svc: AgentHostGitService | undefined; + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + + setup(() => { + tmpRoot = undefined; + svc = createGitService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + async function initRepoWithFiles(files: Record): Promise<{ dir: string; run: (...args: string[]) => Buffer }> { + tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-overlay-')); + const fs = await import('fs/promises'); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(join(tmpRoot!, name), content); + } + run('add', '.'); + run('commit', '-q', '-m', 'init'); + return { dir: tmpRoot!, run }; + } + + const headTree = (dir: string) => cp.execFileSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const lsTree = (dir: string, tree: string) => cp.execFileSync('git', ['ls-tree', '-r', '--name-only', tree], { cwd: dir, env, encoding: 'utf8' }).trim().split('\n').filter(Boolean); + const blobAt = (dir: string, tree: string, path: string) => cp.execFileSync('git', ['cat-file', 'blob', `${tree}:${path}`], { cwd: dir, env, encoding: 'utf8' }); + + (hasGit ? test : test.skip)('overlays a modified path from the source tree, leaving other paths untouched', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + const base = headTree(dir); + + // Working tree modifies a.txt only; capture it as the source tree. + await fs.writeFile(join(dir, 'a.txt'), 'a-v2\n'); + const source = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(source, 'expected a working-tree snapshot'); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base, 'a.txt', source!); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual( + { + files: lsTree(dir, result!), + aContent: blobAt(dir, result!, 'a.txt'), + bContent: blobAt(dir, result!, 'b.txt'), + }, + { + files: ['a.txt', 'b.txt'], + aContent: 'a-v2\n', // overlaid from the source tree + bContent: 'b-v1\n', // copied verbatim from the base tree + }); + }); + + (hasGit ? test : test.skip)('overlays an added path from the source tree', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n' }); + const base = headTree(dir); + + // Working tree adds an untracked file; capture it as the source tree. + await fs.writeFile(join(dir, 'fresh.txt'), 'fresh\n'); + const source = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(source, 'expected a working-tree snapshot'); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base, 'fresh.txt', source!); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual( + { files: lsTree(dir, result!), freshContent: blobAt(dir, result!, 'fresh.txt') }, + { files: ['a.txt', 'fresh.txt'], freshContent: 'fresh\n' }); + }); + + (hasGit ? test : test.skip)('removes a path absent from the source tree', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + + // Base = working tree that includes an untracked file; source = HEAD tree + // (which lacks it). Overlaying that path removes it from the base. + await fs.writeFile(join(dir, 'fresh.txt'), 'fresh\n'); + const base = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(base, 'expected a working-tree snapshot'); + const source = headTree(dir); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base!, 'fresh.txt', source); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual(lsTree(dir, result!), ['a.txt', 'b.txt']); + }); + + (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-overlay-')); + tmpRoot = dir; + const result = await svc!.overlayPathIntoTree(URI.file(dir), 'HEAD', 'a.txt', 'HEAD'); + assert.strictEqual(result, undefined); + }); +}); + +suite('AgentHostGitService - resolveBranchBaselineCommit (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + let tmpRoot: string | undefined; + let svc: AgentHostGitService | undefined; + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + + setup(() => { + tmpRoot = undefined; + svc = createGitService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + function initRepo(): (...args: string[]) => Buffer { + tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-baseline-')); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + return run; + } + + (hasGit ? test : test.skip)('returns the merge-base of HEAD and the base branch', async () => { + const fs = await import('fs/promises'); + const run = initRepo(); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'base\n'); + run('add', '.'); + run('commit', '-q', '-m', 'base'); + const baseCommit = run('rev-parse', 'HEAD').toString().trim(); + + // Diverge onto a feature branch with an extra commit. + run('checkout', '-q', '-b', 'feature'); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'feature\n'); + run('commit', '-q', '-am', 'feature'); + + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!), 'main'); + assert.strictEqual(result, baseCommit); + }); + + (hasGit ? test : test.skip)('falls back to HEAD when no base branch is given', async () => { + const fs = await import('fs/promises'); + const run = initRepo(); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'base\n'); + run('add', '.'); + run('commit', '-q', '-m', 'base'); + const headCommit = run('rev-parse', 'HEAD').toString().trim(); + + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!)); + assert.strictEqual(result, headCommit); + }); + + (hasGit ? test : test.skip)('falls back to the empty tree for a repo with no commits', async () => { + initRepo(); + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!)); + assert.strictEqual(result, '4b825dc642cb6eb9a060e54bf8d69288fbee4904'); + }); + + (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-baseline-')); + tmpRoot = dir; + const result = await svc!.resolveBranchBaselineCommit(URI.file(dir), 'main'); + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index f8c974e3582..de63295160c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,10 +5,10 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; +import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions } from '../../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; suite('AgentHostGitService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -389,4 +389,37 @@ suite('AgentHostGitService', () => { assert.ok(result.endsWith('…'), 'expected trailing ellipsis'); }); }); + + suite('resolveDiffBaseBranchName', () => { + test('prefers the persisted base branch, then git state, then undefined', () => { + assert.deepStrictEqual( + [ + resolveDiffBaseBranchName('persisted', 'gitState'), + resolveDiffBaseBranchName(undefined, 'gitState'), + resolveDiffBaseBranchName('persisted', undefined), + resolveDiffBaseBranchName(undefined, undefined), + ], + ['persisted', 'gitState', 'persisted', undefined], + ); + }); + }); + + suite('parseSingleLsTreeEntry', () => { + test('parses mode/oid and treats empty output as absent', () => { + assert.deepStrictEqual( + [ + parseSingleLsTreeEntry('100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\ta.txt\x00'), + parseSingleLsTreeEntry('100755 blob abc123\tdir/with space.txt\x00'), + parseSingleLsTreeEntry(''), + parseSingleLsTreeEntry(undefined), + ], + [ + { mode: '100644', oid: 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' }, + { mode: '100755', oid: 'abc123' }, + undefined, + undefined, + ], + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 5d12fdbbbe9..38540ffa6a7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -101,6 +101,32 @@ suite('AgentHostGitStateService', () => { }); }); + test('defers git state while a session is creating', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + h.stateManager.createSession({ + resource: SESSION, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectory: 'file:///original', + }, { emitNotification: false }); + h.setGitResult({ branchName: 'feature' }); + + await h.service.refreshSessionGitState(SESSION, URI.parse('file:///explicit')); + + assert.deepStrictEqual({ + gitCalls: h.gitCalls, + runEvents: h.runEvents, + }, { + gitCalls: [], + runEvents: [], + }); + }); + }); + test('resolves the working directory from the session summary when none is provided', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const h = createHarness(); @@ -176,22 +202,6 @@ suite('AgentHostGitStateService', () => { }); }); - test('git returning undefined leaves the session untouched and fires no events', async () => { - const h = createHarness(); - seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); - h.setGitResult(undefined); - - await h.service.refreshSessionGitState(SESSION, undefined); - - assert.deepStrictEqual({ - gitState: readSessionGitState(h.stateManager.getSessionState(SESSION)?._meta), - runEvents: h.runEvents, - }, { - gitState: undefined, - runEvents: [], - }); - }); - test('swallows git errors and fires no events', async () => { const h = createHarness(); seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); diff --git a/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts new file mode 100644 index 00000000000..596cb5307eb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { NullLogService } from '../../../log/common/log.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { MessageKind, TurnState, type Turn } from '../../common/state/sessionState.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { TestSessionDatabase, createSessionDataService } from '../common/sessionTestHelpers.js'; + +suite('AgentHostLocalTurns', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'mock:/session-1'; + + function turn(id: string): Turn { + return { id, message: { text: id, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete }; + } + + test('records, resolves anchors, persists, and deletes local turns', async () => { + const db = new TestSessionDatabase(); + const registry = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const chat = 'ahp-chat://default/xyz'; + + // Two locals: one anchored to a real turn, one anchored before any real turn. + registry.record(session, chat, turn('local-a'), 'real-1'); + registry.record(session, chat, turn('local-b'), undefined); + + assert.strictEqual(registry.isLocal(chat, 'local-a'), true); + assert.strictEqual(registry.isLocal(chat, 'real-1'), false); + // Local resolves to its concrete anchor; a concrete turn resolves to itself. + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'local-a'), 'real-1'); + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'local-b'), undefined); + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'real-1'), 'real-1'); + assert.deepStrictEqual(new Set(registry.getLocalTurnIds(chat)), new Set(['local-a', 'local-b'])); + + // Persisted to the database with the chat discriminator. + const persisted = await db.getLocalTurns(); + assert.deepStrictEqual(persisted.map(r => ({ turnId: r.turnId, chatUri: r.chatUri, anchorTurnId: r.anchorTurnId })), [ + { turnId: 'local-a', chatUri: chat, anchorTurnId: 'real-1' }, + { turnId: 'local-b', chatUri: chat, anchorTurnId: undefined }, + ]); + + // Delete removes from memory and the database. + registry.deleteLocals(session, ['local-a']); + assert.strictEqual(registry.isLocal(chat, 'local-a'), false); + assert.deepStrictEqual((await db.getLocalTurns()).map(r => r.turnId), ['local-b']); + }); + + test('load re-populates the in-memory index from the database, scoped per chat', async () => { + const db = new TestSessionDatabase(); + const chatA = 'ahp-chat://default/a'; + const chatB = 'ahp-chat://peer/b'; + await db.insertLocalTurn({ turnId: 'local-x', chatUri: chatA, anchorTurnId: 'real-9', seq: 3, payload: JSON.stringify(turn('local-x')) }); + await db.insertLocalTurn({ turnId: 'local-y', chatUri: chatB, anchorTurnId: undefined, seq: 4, payload: JSON.stringify(turn('local-y')) }); + + const registry = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const recordsA = await registry.loadForChat(session, chatA); + + // loadForChat returns only the requested chat's records... + assert.deepStrictEqual(recordsA.map(r => r.turnId), ['local-x']); + // ...but the in-memory index is populated for every chat in the session. + assert.strictEqual(registry.isLocal(chatA, 'local-x'), true); + assert.strictEqual(registry.resolveConcreteTurnId(chatA, 'local-x'), 'real-9'); + assert.strictEqual(registry.isLocal(chatB, 'local-y'), true); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index b50681d1813..bd16ea85aed 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import type { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; +import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import type { SchemaValues } from '../../common/agentHostSchema.js'; import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; -import { COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; @@ -31,14 +31,19 @@ suite('AgentHostPromptRegistry', () => { ensureNoDisposablesAreLeakedInTestSuite(); + const withFileLinkInstructions = (config: SystemMessageConfig): SystemMessageConfig => ({ + ...config, + content: config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` : COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, + }); + test('falls back to the default system message when no model is provided', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('falls back to the default when no contributor matches the model', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('a contributor can fully replace the system prompt (replace mode)', () => { @@ -65,7 +70,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), - { mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } } + withFileLinkInstructions({ mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } }) ); }); @@ -79,7 +84,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), - COPILOT_AGENT_HOST_SYSTEM_MESSAGE + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) ); }); @@ -110,11 +115,11 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [AgentHostConfigKey.Opus48Prompt]: true })), - { mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } } + withFileLinkInstructions({ mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } }) ); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context()), - COPILOT_AGENT_HOST_SYSTEM_MESSAGE + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) ); }); @@ -126,8 +131,8 @@ suite('AgentHostPromptRegistry', () => { } test('applies customize overrides only when enabled', () => { - assert.strictEqual(resolveOpus(undefined), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); - assert.strictEqual(resolveOpus(false), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(resolveOpus(undefined), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); + assert.deepStrictEqual(resolveOpus(false), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); assert.strictEqual(resolveOpus(true).mode, 'customize'); }); }); @@ -140,7 +145,7 @@ suite('AgentHostPromptRegistry', () => { { mode: 'customize', sections: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, - content: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, } ); }); @@ -149,7 +154,7 @@ suite('AgentHostPromptRegistry', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual( registry.resolveSystemMessageConfig(undefined, context({}, [], false)), - COPILOT_AGENT_HOST_SYSTEM_MESSAGE + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) ); }); @@ -166,7 +171,7 @@ suite('AgentHostPromptRegistry', () => { { mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } }, - content: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, } ); }); @@ -195,20 +200,20 @@ suite('AgentHostPromptRegistry', () => { test('is a no-op when the session exposes no matching tools', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('layers the browser tool_instructions onto the default config when browser tools are present', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'm' }, context({}, browserTools)), - { + withFileLinkInstructions({ mode: 'customize', sections: { identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, tool_instructions: { action: 'append', content: `\n${BROWSER_LINE}` }, }, - } + }) ); }); @@ -222,7 +227,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, browserTools)), - { mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } } + withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } }) ); }); @@ -236,7 +241,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, ['anyTool'])), - { mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } } + withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } }) ); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 8ff35f88976..4184ffa07d8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -35,6 +35,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.calls.push({ token: githubToken, request, options }); if (this.error) { @@ -89,6 +91,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise { } async deleteRefs(): Promise { } async revParse(): Promise { return undefined; } + async resolveBranchBaselineCommit(): Promise { return undefined; } + async overlayPathIntoTree(): Promise { return undefined; } + async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts new file mode 100644 index 00000000000..1d96162a4aa --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ICommonProperties } from '../../../telemetry/common/telemetry.js'; +import { AgentHostRestrictedTelemetrySender } from '../../node/agentHostRestrictedTelemetry.js'; + +/** The enhanced/restricted iKey (`copilot_v0_restricted_copilot_event`). */ +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +interface ICapturedPost { + url: string; + iKey: string; +} + +suite('AgentHostRestrictedTelemetrySender', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const commonProperties = {} as ICommonProperties; + + function createSender(): { sender: AgentHostRestrictedTelemetrySender; posts: ICapturedPost[] } { + const posts: ICapturedPost[] = []; + const fetchFn = (async (url: string | URL | Request, init?: RequestInit): Promise => { + const envelope = JSON.parse(String(init?.body)); + posts.push({ url: String(url), iKey: envelope.iKey }); + return { ok: true, status: 200 } as Response; + }) as typeof globalThis.fetch; + const sender = new AgentHostRestrictedTelemetrySender(commonProperties, new NullLogService(), 'https://default.example/telemetry', undefined, fetchFn); + return { sender, posts }; + } + + test('enhanced GH telemetry is dropped until the token opts in (rt=1), then routes to the enhanced iKey', () => { + const { sender, posts } = createSender(); + + // Public user (rt not opted in): the restricted sink must not emit, even with content. + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + assert.deepStrictEqual(posts, [], 'enhanced telemetry must not be sent without rt opt-in'); + + // Opt in, then flip back off: emits only while enabled, and to the enhanced iKey. + sender.setRestrictedTelemetryEnabled(true); + sender.setRestrictedTelemetryEndpoint('https://ghe.example'); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + sender.setRestrictedTelemetryEnabled(false); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + + assert.deepStrictEqual(posts, [{ url: 'https://ghe.example', iKey: GH_ENHANCED_IKEY }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts new file mode 100644 index 00000000000..61c694dda20 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { buildBranchChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; +import type { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { SessionStatus, } from '../../common/state/sessionState.js'; +import { AgentHostReviewFileOperationHandler } from '../../node/agentHostReviewFileOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { createNoopChangesetService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +interface IRecordedCall { + readonly session: string; + readonly workingDirectory: string; + readonly baseBranch: string | undefined; + readonly resource: string; +} + +class TestReviewService implements IAgentHostReviewService { + declare readonly _serviceBrand: undefined; + + readonly markCalls: IRecordedCall[] = []; + readonly unmarkCalls: IRecordedCall[] = []; + error: Error | undefined; + + async markFileReviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + this.markCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() }); + if (this.error) { throw this.error; } + } + + async markFileUnreviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + this.unmarkCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() }); + if (this.error) { throw this.error; } + } + + async getReviewedPaths(): Promise> { + return new Set(); + } + + async copyReviewedRef(): Promise { } +} + +function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] { + return { kind: ChangesetOperationTargetKind.Resource, resource: resource.toString() as unknown as InvokeChangesetOperationParams['channel'] }; +} + +function setup(disposables: Pick, opts?: { readonly reviewed?: boolean; readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean; readonly baseBranch?: string }): { handler: AgentHostReviewFileOperationHandler; reviewService: TestReviewService; refreshedSessions: string[]; session: URI } { + const reviewService = new TestReviewService(); + const database = new TestSessionDatabase(); + if (opts?.baseBranch) { + database.setMetadata(META_DIFF_BASE_BRANCH, opts.baseBranch); + } + const sessionDataService = createSessionDataService(database); + const refreshedSessions: string[] = []; + const changesetService = createNoopChangesetService(); + changesetService.refreshBranchChangeset = session => { refreshedSessions.push(session); }; + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = URI.parse('agent:/session'); + if (opts?.registerSession !== false) { + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(), + }); + } + const handler = new AgentHostReviewFileOperationHandler( + opts?.reviewed ?? true, + sessionKey => stateManager.getSessionState(sessionKey), + reviewService, + changesetService, + sessionDataService, + new NullLogService(), + ); + return { handler, reviewService, refreshedSessions, session }; +} + +suite('AgentHostReviewFileOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks the targeted file as reviewed, passing the resolved base branch', async () => { + const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: true, baseBranch: 'main' }); + const target = URI.file('/repo/src/file.ts'); + + const result = await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(target), + }, CancellationToken.None); + + assert.deepStrictEqual({ + markCalls: reviewService.markCalls, + unmarkCalls: reviewService.unmarkCalls, + refreshedSessions, + message: result.message, + }, { + markCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: 'main', resource: target.toString() }], + unmarkCalls: [], + refreshedSessions: [session.toString()], + message: { markdown: 'Marked `file.ts` as reviewed.' }, + }); + }); + + test('clears the reviewed mark for the targeted file', async () => { + const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: false }); + const target = URI.file('/repo/src/file.ts'); + + const result = await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, + target: makeResourceTarget(target), + }, CancellationToken.None); + + assert.deepStrictEqual({ + markCalls: reviewService.markCalls, + unmarkCalls: reviewService.unmarkCalls, + refreshedSessions, + message: result.message, + }, { + markCalls: [], + unmarkCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: undefined, resource: target.toString() }], + refreshedSessions: [session.toString()], + message: { markdown: 'Removed the reviewed mark from `file.ts`.' }, + }); + }); + + test('rejects channels that are not branch-changeset URIs', async () => { + const { handler, reviewService, session } = setup(disposables); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildSessionChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(URI.file('/repo/src/file.ts')), + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 }); + }); + + test('throws AHP_SESSION_NOT_FOUND when the session is unknown', async () => { + const { handler, reviewService } = setup(disposables, { registerSession: false }); + const session = URI.parse('agent:/missing'); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(URI.file('/repo/src/file.ts')), + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: AHP_SESSION_NOT_FOUND, marks: 0 }); + }); + + test('rejects invocations without a Resource target', async () => { + const { handler, reviewService, session } = setup(disposables); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: undefined, + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts new file mode 100644 index 00000000000..06d116f5db9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Integration tests for {@link AgentHostReviewService} that spawn real `git` + * against temporary on-disk repositories, exercising the reviewed-ref based + * review model end to end. Run via `scripts/test-integration.sh`. + */ + +import assert from 'assert'; +import * as cp from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; +import { AgentHostGitService } from '../../node/agentHostGitService.js'; +import { AgentHostReviewService } from '../../node/agentHostReviewService.js'; +import { buildReviewedRefName } from '../../common/agentHostReviewService.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; + +function rmDirWithRetry(path: string | undefined): void { + if (!path) { + return; + } + try { rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } catch { /* best-effort */ } +} + +suite.skip('AgentHostReviewService (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + const sessionUri = URI.parse('copilot:/review-test-session'); + + let tmpRoot: string | undefined; + let svc: AgentHostReviewService | undefined; + let db: TestSessionDatabase | undefined; + + function createService(store: Pick): AgentHostReviewService { + const logService = new NullLogService(); + const fileService = store.add(new FileService(logService)); + store.add(fileService.registerProvider(Schemas.file, store.add(new DiskFileSystemProvider(logService)))); + const env: Partial = { tmpDir: URI.file(tmpdir()) }; + const gitService = new AgentHostGitService(fileService, env as INativeEnvironmentService, logService); + db = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(db); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + return store.add(new AgentHostReviewService(stateManager, gitService, sessionDataService, logService,)); + } + + setup(() => { + tmpRoot = undefined; + svc = createService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + async function initRepo(files: Record): Promise { + const fs = await import('fs/promises'); + // Canonicalize the temp root so it matches the path `git rev-parse + // --show-toplevel` reports (macOS symlinks /var -> /private/var); the + // review service computes repo-relative paths against that root. + tmpRoot = await fs.realpath(mkdtempSync(join(tmpdir(), 'agent-host-review-'))); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(join(tmpRoot!, name), content); + } + run('add', '.'); + run('commit', '-q', '-m', 'init'); + return tmpRoot!; + } + + const wd = () => URI.file(tmpRoot!); + const reviewedPaths = async () => [...await svc!.getReviewedPaths(sessionUri.toString(), wd(), undefined)].sort(); + const chainLength = () => { + const ref = buildReviewedRefName('review-test-session'); + // Count only the review commits layered on top of the baseline (HEAD), + // excluding the baseline's own history. + try { return Number(cp.execFileSync('git', ['rev-list', '--count', `HEAD..${ref}`], { cwd: tmpRoot!, env, encoding: 'utf8' }).trim()); } catch { return 0; } + }; + + (hasGit ? test : test.skip)('marks and unmarks a modified file (against HEAD baseline)', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + + const beforeMark = await reviewedPaths(); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterMark = await reviewedPaths(); + await svc!.markFileUnreviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterUnmark = await reviewedPaths(); + + assert.deepStrictEqual( + { beforeMark, afterMark, afterUnmark }, + { beforeMark: [], afterMark: ['a.txt'], afterUnmark: [] }); + }); + + (hasGit ? test : test.skip)('auto-unreviews a file that is edited again after being reviewed', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const reviewed = await reviewedPaths(); + + // Agent edits the file again -> its working content no longer matches + // the reviewed tree, so it flips back to unreviewed. + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v3\n'); + const afterReedit = await reviewedPaths(); + + assert.deepStrictEqual({ reviewed, afterReedit }, { reviewed: ['a.txt'], afterReedit: [] }); + }); + + (hasGit ? test : test.skip)('reviews an added (untracked) file and a deleted file', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n', 'gone.txt': 'bye\n' }); + await fs.writeFile(join(tmpRoot!, 'fresh.txt'), 'fresh\n'); + await fs.unlink(join(tmpRoot!, 'gone.txt')); + + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'fresh.txt'))); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'gone.txt'))); + + assert.deepStrictEqual(await reviewedPaths(), ['fresh.txt', 'gone.txt']); + }); + + (hasGit ? test : test.skip)('advances the ref chain once per action and skips no-op marks', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + + const initial = chainLength(); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterMark = chainLength(); + // Marking the same content again is a no-op and must not grow the chain. + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterDup = chainLength(); + await svc!.markFileUnreviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterUnmark = chainLength(); + + assert.deepStrictEqual( + { initial, afterMark, afterDup, afterUnmark }, + { initial: 0, afterMark: 1, afterDup: 1, afterUnmark: 2 }); + }); + + (hasGit ? test : test.skip)('deletes the reviewed ref on session data disposal', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const beforeDispose = chainLength(); + + await svc!.disposeSessionData(sessionUri.toString()); + const afterDispose = chainLength(); + + assert.deepStrictEqual({ beforeDispose, afterDispose }, { beforeDispose: 1, afterDispose: 0 }); + }); + + (hasGit ? test : test.skip)('copies the reviewed ref to a forked session', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + + const forkUri = URI.parse('copilot:/forked-session'); + await svc!.copyReviewedRef(sessionUri.toString(), forkUri.toString(), wd()); + + const forkReviewed = [...await svc!.getReviewedPaths(forkUri.toString(), wd(), undefined)].sort(); + const sourceReviewed = await reviewedPaths(); + assert.deepStrictEqual({ forkReviewed, sourceReviewed }, { forkReviewed: ['a.txt'], sourceReviewed: ['a.txt'] }); + }); + + (hasGit ? test : test.skip)('is a no-op when the working directory is not a git repository', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-review-nongit-')); + tmpRoot = dir; + await svc!.markFileReviewed(sessionUri.toString(), URI.file(dir), undefined, URI.file(join(dir, 'a.txt'))); + assert.deepStrictEqual([...await svc!.getReviewedPaths(sessionUri.toString(), URI.file(dir), undefined)], []); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index e8d4f102697..277c8f11da3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -33,6 +33,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts new file mode 100644 index 00000000000..c2b212977cd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { AgentSession } from '../../common/agentService.js'; +import type { ToolDefinition } from '../../common/state/protocol/state.js'; +import { IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; + +interface IRestrictedCall { + eventName: string; + properties: TelemetryProps | undefined; +} + +class TestRestrictedTelemetryService implements ITelemetryService, IAgentHostRestrictedTelemetry { + declare readonly _serviceBrand: undefined; + + telemetryLevel = TelemetryLevel.USAGE; + sendErrorTelemetry = true; + sessionId = 'sessionId'; + machineId = 'machineId'; + sqmId = 'sqmId'; + devDeviceId = 'devDeviceId'; + firstSessionDate = 'firstSessionDate'; + + readonly enhancedEvents: IRestrictedCall[] = []; + + publicLog(): void { } + publicLogError(): void { } + publicLog2(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } + + sendGHTelemetryEvent(): void { } + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.enhancedEvents.push({ eventName, properties }); + } + sendInternalMSFTTelemetryEvent(): void { } + setCopilotTrackingId(): void { } + setRestrictedTelemetryEndpoint(): void { } + setRestrictedTelemetryEnabled(): void { } +} + +suite('AgentHostTelemetryReporter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'agent-session://copilot/abc'; + const tools: ToolDefinition[] = [{ name: 'grep' }, { name: 'edit' }]; + + test('assistantMessageReceived emits request.options.tools keyed on the service request id, and no-ops without one or without tools', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.assistantMessageReceived(session, undefined, tools); // dropped: no service request id + reporter.assistantMessageReceived(session, 'svc-1', []); // dropped: no tools + reporter.assistantMessageReceived(session, 'svc-1', tools); // emitted + + assert.deepStrictEqual(service.enhancedEvents, [{ + eventName: 'request.options.tools', + properties: { + headerRequestId: 'svc-1', + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + }, + }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 35050bc5126..7a8f05da86b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { IAgentHostRestrictedTelemetry, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { AgentHostTelemetryService, updateAgentHostTelemetryLevelFromConfig } from '../../node/agentHostTelemetryService.js'; class TestTelemetryService implements ITelemetryService { @@ -42,6 +43,31 @@ class TestTelemetryService implements ITelemetryService { setCommonProperty(): void { } } +class TestRestrictedSink implements IAgentHostRestrictedTelemetry { + readonly enhanced: string[] = []; + readonly standard: string[] = []; + readonly trackingIds: (string | undefined)[] = []; + readonly endpoints: (string | undefined)[] = []; + readonly enabledFlags: boolean[] = []; + + sendGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.standard.push(eventName); + } + sendEnhancedGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.enhanced.push(eventName); + } + sendInternalMSFTTelemetryEvent(): void { } + setCopilotTrackingId(trackingId: string | undefined): void { + this.trackingIds.push(trackingId); + } + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this.endpoints.push(endpointUrl); + } + setRestrictedTelemetryEnabled(enabled: boolean): void { + this.enabledFlags.push(enabled); + } +} + suite('AgentHostTelemetryService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -88,4 +114,45 @@ suite('AgentHostTelemetryService', () => { assert.strictEqual(service.telemetryLevel, TelemetryLevel.ERROR); }); + + test('enhanced GH telemetry is gated on the restricted (rt) opt-in; standard GH telemetry is not', () => { + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService(), restricted)); + + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // dropped: rt disabled by default + service.sendGHTelemetryEvent('completion'); // sent: standard GH telemetry is not rt-gated + service.setRestrictedTelemetryEnabled(true); + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // sent: rt now enabled + service.setCopilotTrackingId('tid-1'); + service.setRestrictedTelemetryEndpoint('https://ghe.example/telemetry'); + + assert.deepStrictEqual({ + enhanced: restricted.enhanced, + standard: restricted.standard, + trackingIds: restricted.trackingIds, + endpoints: restricted.endpoints, + }, { + enhanced: ['request.options.tools'], + standard: ['completion'], + trackingIds: ['tid-1'], + endpoints: ['https://ghe.example/telemetry'], + }); + // The rt opt-in is mirrored onto the sender (defense in depth), matching the extension's + // opted-in-only restricted reporter. + assert.deepStrictEqual(restricted.enabledFlags, [true]); + }); + + test('enhanced GH telemetry stays suppressed when telemetry is disabled, even for an rt=1 user', () => { + const delegate = new TestTelemetryService(); + delegate.telemetryLevel = TelemetryLevel.ERROR; // user opted below USAGE + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(delegate, restricted)); + + service.setRestrictedTelemetryEnabled(true); // rt=1 + service.sendEnhancedGHTelemetryEvent('request.options.tools'); + service.sendGHTelemetryEvent('completion'); + + // Neither standard nor enhanced GH telemetry is delegated below USAGE, regardless of rt. + assert.deepStrictEqual({ enhanced: restricted.enhanced, standard: restricted.standard }, { enhanced: [], standard: [] }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 0a6e58076c4..99f28c33064 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -4,25 +4,32 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/virtualScheduling/runWithFakedTimers.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; +import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; class FakeChangesetService implements IAgentHostChangesetService { declare readonly _serviceBrand: undefined; @@ -135,6 +142,34 @@ suite('AgentSideEffects — tool call telemetry', () => { }); } + function stalledEvents(): { eventName: string; data: Record }[] { + return telemetry.events + .filter(e => e.eventName === 'agentHost.toolCallStalled') + .map(e => { + const data = e.data as Record; + return { + eventName: e.eventName, + data: { ...data, stalledTimeMs: typeof data.stalledTimeMs === 'number' && data.stalledTimeMs >= 0 }, + }; + }); + } + + function stalledCompletionEvents(): { eventName: string; data: Record }[] { + return telemetry.events + .filter(e => e.eventName === 'agentHost.stalledToolCallCompleted') + .map(e => { + const data = e.data as Record; + return { + eventName: e.eventName, + data: { + ...data, + totalTimeMs: typeof data.totalTimeMs === 'number' && data.totalTimeMs >= 0, + timeAfterStallMs: typeof data.timeAfterStallMs === 'number' && data.timeAfterStallMs >= 0, + }, + }; + }); + } + setup(() => { agent = new MockAgent(); disposables.add(toDisposable(() => agent.dispose())); @@ -145,17 +180,21 @@ suite('AgentSideEffects — tool call telemetry', () => { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const telemetryService = disposables.add(new AgentHostTelemetryService(telemetry)); + const sessionDataService = createNullSessionDataService(); const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, new FakeChangesetService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], + [ISessionDataService, sessionDataService], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, { getAgent: () => agent, agents: agentList, - sessionDataService: createNullSessionDataService(), + sessionDataService, + localTurns: new AgentHostLocalTurns(sessionDataService, logService), onTurnComplete: () => { }, })); disposables.add(sideEffects.registerProgressListener(agent)); @@ -262,4 +301,135 @@ suite('AgentSideEffects — tool call telemetry', () => { assert.strictEqual(toolEvents().length, 0); }); + + test('emits once when a tool confirmation remains blocked', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-confirm', 'write'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-confirm', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-confirm', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents(), [{ + eventName: 'agentHost.toolCallStalled', + data: { + provider: 'mock', + agentSessionId: 'session-1', + isSubagentSession: false, + blockerKind: SessionInputRequestKind.ToolConfirmation, + toolId: 'write', + toolSourceKind: 'agentHost', + stalledTimeMs: true, + }, + }]); + }); + + test('replaces confirmation tracking with client execution tracking', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-client-stall', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-client-stall', + invocationMessage: 'Run tests', + confirmationTitle: 'Run tests', + }); + fire({ + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-1', + toolCallId: 'tc-client-stall', + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents().map(e => e.data.blockerKind), [SessionInputRequestKind.ToolClientExecution]); + }); + + test('does not emit after a client tool completes or its turn is cancelled', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-complete', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-complete', + invocationMessage: 'Run tests', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + toolComplete('turn-1', 'tc-complete', { success: true, pastTenseMessage: 'ran tests' }); + + toolStart('turn-1', 'tc-cancel', 'write'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-cancel', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents(), []); + assert.deepStrictEqual(stalledCompletionEvents(), []); + }); + + test('emits when a stalled client tool later completes', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-recovered', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-recovered', + invocationMessage: 'Run tests', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + await timeout(5 * 60 * 1000); + toolComplete('turn-1', 'tc-recovered', { success: true, pastTenseMessage: 'ran tests' }); + }); + + assert.deepStrictEqual(stalledCompletionEvents(), [{ + eventName: 'agentHost.stalledToolCallCompleted', + data: { + provider: 'mock', + agentSessionId: 'session-1', + isSubagentSession: false, + blockerKind: SessionInputRequestKind.ToolClientExecution, + toolId: 'run_tests', + toolSourceKind: 'client', + result: 'success', + totalTimeMs: true, + timeAfterStallMs: true, + }, + }]); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 93f0ddf2514..8bc7f39cacb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -16,13 +16,17 @@ import { AgentSession, IAgent } from '../../common/agentService.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; class FakeChangesetService implements IAgentHostChangesetService { declare readonly _serviceBrand: undefined; @@ -150,17 +154,21 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const telemetryService = disposables.add(new AgentHostTelemetryService(telemetry)); + const sessionDataService = createNullSessionDataService(); const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, new FakeChangesetService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], + [ISessionDataService, sessionDataService], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, { getAgent: () => agent, agents: agentList, - sessionDataService: createNullSessionDataService(), + sessionDataService, + localTurns: new AgentHostLocalTurns(sessionDataService, logService), onTurnComplete: () => { }, })); // Wire the agent's progress signals through side-effects (this is how diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 491e7fe6595..14626eafbec 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -79,6 +79,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -954,6 +956,50 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('listSessions overlays live workspace metadata over a stale provider snapshot', async () => { + class DelayedListAgent extends MockAgent { + readonly listStarted = new DeferredPromise(); + readonly releaseList = new DeferredPromise(); + override async listSessions() { + const snapshot = await super.listSessions(); + this.listStarted.complete(); + await this.releaseList.p; + return snapshot; + } + } + + const agent = new DelayedListAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.resolvedWorkingDirectory = URI.file('/original'); + service.registerProvider(agent); + const { session } = await agent.createSession(); + + const listing = service.listSessions(); + await agent.listStarted.p; + service.stateManager.restoreSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Materialized', + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + project: { uri: URI.file('/project').toString(), displayName: 'project' }, + workingDirectory: URI.file('/worktree').toString(), + }, []); + agent.releaseList.complete(); + + const listed = (await listing).find(item => item.session.toString() === session.toString()); + assert.deepStrictEqual({ + modifiedTime: listed?.modifiedTime, + project: listed?.project && { uri: listed.project.uri.path, displayName: listed.project.displayName }, + workingDirectory: listed?.workingDirectory?.path, + }, { + modifiedTime: 2000, + project: { uri: '/project', displayName: 'project' }, + workingDirectory: '/worktree', + }); + }); + test.skip('listSessions synthesizes the session changeset catalogue from persisted diffs for unopened sessions', async () => { // Pre-seed a `'diffs'` blob in the in-memory DB. The agent's // `listSessions()` returns the session metadata but the session @@ -1313,6 +1359,9 @@ suite('AgentService (node dispatcher)', () => { updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); @@ -1409,6 +1458,9 @@ suite('AgentService (node dispatcher)', () => { updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); @@ -1847,6 +1899,37 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.turns[0].state, TurnState.Complete); }); + test('interleaves persisted host-injected local turns after their anchor on restore', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + + // SDK transcript reconstructs a single real turn keyed by the user + // envelope id (`msg-real`, per mapSessionEvents). + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-real', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-real-a', content: 'Hi there!', toolRequests: [] }, + ]; + + // A host-injected local turn anchored after the real turn, plus one + // with no anchor (precedes any real turn), plus an orphan whose + // anchor is absent from the SDK transcript (should be dropped). + const localTurn = (id: string, text: string) => ({ id, message: { text, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete }); + await db.insertLocalTurn({ turnId: 'local-head', chatUri: defaultChatUri, anchorTurnId: undefined, seq: 1, payload: JSON.stringify(localTurn('local-head', '!pwd')) }); + await db.insertLocalTurn({ turnId: 'local-after', chatUri: defaultChatUri, anchorTurnId: 'msg-real', seq: 2, payload: JSON.stringify(localTurn('local-after', '!ls')) }); + await db.insertLocalTurn({ turnId: 'local-orphan', chatUri: defaultChatUri, anchorTurnId: 'gone', seq: 3, payload: JSON.stringify(localTurn('local-orphan', '!echo')) }); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + // head (no anchor) first, then the real turn, then its anchored local; orphan dropped. + assert.deepStrictEqual(state!.turns.map(t => t.id), ['local-head', 'msg-real', 'local-after']); + }); + + test('restores the default chat\'s independently-renamed title', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -2649,6 +2732,60 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('fork at a host-injected local turn redirects the SDK boundary to the concrete anchor and carries the local turn into the new chat', async () => { + let receivedFork: IAgentCreateChatForkSource | undefined; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise { + receivedFork = options?.fork; + } + } + const db = new TestSessionDatabase(); + const agent = disposables.add(new MultiChatAgent('copilot')); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(agent); + const { session } = await agent.createSession(); + const sessionResource = (await agent.listSessions())[0].session; + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + + // SDK transcript reconstructs a single real turn keyed by the user + // message id; a host-injected local turn is persisted after it. + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'Hi', toolRequests: [] }, + ]; + const localTurn: Turn = { id: 'local-1', state: TurnState.Complete, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }; + await db.insertLocalTurn({ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) }); + + // Restore so the source chat interleaves [real-1, local-1] and the + // in-memory local index knows local-1 is a local turn. + await localService.restoreSession(sessionResource); + assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.id), ['real-1', 'local-1']); + + // Fork the default chat AT the local turn into a new peer chat. + const peerUri = URI.parse(buildChatUri(sessionResource, 'peer-1')); + await localService.createChat(sessionResource, peerUri, { fork: { source: URI.parse(defaultChatUri), turnId: 'local-1' } }); + + const peerTurns = localService.stateManager.getChatState(peerUri.toString())?.turns ?? []; + const forkedLocals = (await db.getLocalTurns()).filter(r => r.chatUri === peerUri.toString()); + assert.deepStrictEqual({ + // SDK fork boundary redirected from the local turn to its concrete anchor. + sdkForkTurnId: receivedFork?.turnId, + // New chat seeded with remapped copies of both turns. + peerTurnCount: peerTurns.length, + // The forked local turn is persisted under the new chat, anchored to + // the forked copy of the real turn. + forkedLocalCount: forkedLocals.length, + forkedLocalAnchor: forkedLocals[0]?.anchorTurnId, + anchorIsPeerFirstTurn: forkedLocals[0]?.anchorTurnId === peerTurns[0]?.id, + }, { + sdkForkTurnId: 'real-1', + peerTurnCount: 2, + forkedLocalCount: 1, + forkedLocalAnchor: peerTurns[0]?.id, + anchorIsPeerFirstTurn: true, + }); + }); + test('a peer chat backing session is filtered out of listSessions and stays filtered across a restart', async () => { // Per-session databases so the backing SDK session's marker is // isolated from the parent session's own database. @@ -2937,6 +3074,39 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('the spawned catalog chat is resolvable from the inline pill resource via parseChatUri (the Open-Subagent contract)', async () => { + // The inline subagent pill (`ToolResultSubagentContent.resource`) and + // the catalog chat are both built from `buildSubagentChatUri`, and the + // Agents window resolves the pill to its tab by matching + // `parseChatUri(pillResource).chatId` against the catalog chat's + // parsed chatId (see `findSubagentChat`/`matchesResource` in + // `openSubagentChat.ts`). If the two ever desync, the pill shows the + // fallback "Open Subagent" label and clicking it no-ops. Guard the + // round-trip so the pill stays resolvable. + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + // The resource the inline pill carries for this subagent. + const pillResource = buildSubagentChatUri(session.toString(), 'tc-sub'); + const pillChatId = parseChatUri(pillResource)?.chatId; + const catalog = service.stateManager.getSessionState(session.toString())?.chats ?? []; + const resolvedByPill = catalog.filter(c => parseChatUri(c.resource)?.chatId === pillChatId); + assert.deepStrictEqual({ + pillChatId, + resolvedCatalogEntries: resolvedByPill.length, + }, { + pillChatId: 'subagent/tc-sub', + resolvedCatalogEntries: 1, + }); + }); + test('a subagent_started signal without a taskDescription falls back to the agent display name for the tab title', async () => { service.registerProvider(copilotAgent); const session = await service.createSession({ provider: 'copilot' }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 361bee74a42..2d63bf755c0 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -36,10 +36,13 @@ import { IAgentHostChangesetService, StaticChangesetKind } from '../../common/ag import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { createNoopGitService, createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; +import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; // ---- Tests ------------------------------------------------------------------ @@ -95,10 +98,11 @@ class FakeChangesetService implements IAgentHostChangesetService { function createTestSideEffects( disposables: DisposableStore, stateManager: AgentHostStateManager, - options: IAgentSideEffectsOptions, + options: Omit & { localTurns?: AgentHostLocalTurns }, _gitService?: IAgentHostGitService, telemetryService: ITelemetryService = NullTelemetryService, changesets: IAgentHostChangesetService = new FakeChangesetService(), + terminalManager: IAgentHostTerminalManager = disposables.add(new TestAgentHostTerminalManager()), ): AgentSideEffects { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); @@ -108,8 +112,14 @@ function createTestSideEffects( [IAgentHostChangesetService, changesets], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, terminalManager], + [ISessionDataService, options.sessionDataService], ), /*strict*/ true)); - return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options)); + const resolvedOptions: IAgentSideEffectsOptions = { + ...options, + localTurns: options.localTurns ?? new AgentHostLocalTurns(options.sessionDataService, logService), + }; + return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, resolvedOptions)); } class TestTelemetryService implements ITelemetryService { @@ -482,6 +492,208 @@ suite('AgentSideEffects', () => { }); }); + // ---- handleAction: generic ! terminal command ------------------------ + + suite('handleAction — ! terminal command', () => { + + function createBangSideEffects(terminalManager: TestAgentHostTerminalManager): AgentSideEffects { + return createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + } + + test('runs a ! message as a terminal command and completes the turn without calling the agent', async () => { + setupSession('file:///work'); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createBangSideEffects(terminalManager); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + // Mirror production: the reducer opens the turn, then side effects run. + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + // Wait until the command is running (its completion listener is + // registered), then signal that the command finished. + await terminalManager.commandFinishedListenerRegistered.p; + const terminalUri = terminalManager.created[0].channel; + terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' }); + + // Wait for the turn to be closed out. + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + + assert.deepStrictEqual(agent.sendMessageCalls, []); + const state = stateManager.getSessionState(sessionUri.toString()); + const part = state?.turns.at(-1)?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + assert.strictEqual(toolCall?.status, ToolCallStatus.Completed); + assert.strictEqual(toolCall?.status === ToolCallStatus.Completed ? toolCall.success : undefined, true); + assert.ok(toolCall?.status === ToolCallStatus.Completed + && toolCall.content?.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)); + assert.strictEqual(terminalManager.created.length, 1); + assert.ok(terminalManager.sentTexts.some(s => s.data.includes('echo hi'))); + }); + + test('a lone ! is forwarded to the agent instead of running a command', async () => { + setupSession(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createBangSideEffects(terminalManager); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + await waitForSendMessageCalls(1); + + assert.strictEqual(agent.sendMessageCalls[0].prompt, '!'); + assert.strictEqual(terminalManager.created.length, 0); + }); + + test('records the completed bang turn as a local turn, stripped of the live terminal reference', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const localTurns = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createSessionDataService(db), + localTurns, + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' }); + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + + // The turn with no preceding real turn has no anchor. + assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'turn-1'), undefined); + const persisted = await db.getLocalTurns(); + assert.strictEqual(persisted.length, 1); + const payload = JSON.parse(persisted[0].payload) as { responseParts: { kind: string; toolCall?: { content?: { type: string }[] } }[] }; + const toolCallPart = payload.responseParts.find(p => p.kind === ResponsePartKind.ToolCall); + // Live terminal reference is stripped; text output is retained. + assert.ok(toolCallPart?.toolCall?.content?.every(c => c.type !== ToolResultContentType.Terminal)); + assert.ok(toolCallPart?.toolCall?.content?.some(c => c.type === ToolResultContentType.Text)); + }); + }); + + // ---- local turn persistence: anchoring + truncate resolution --------- + + suite('local turn persistence', () => { + + let clientSeq: number; + + setup(() => { + clientSeq = 0; + }); + + /** Drives a normal (SDK-backed) turn into `turns[]` via the reducer. */ + function seedRealTurn(turnId: string, text: string): void { + stateManager.dispatchClientAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, turnId, message: { text, origin: { kind: MessageKind.User } }, + }, { clientId: 'test', clientSeq: ++clientSeq }); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId }); + } + + async function runBang(se: AgentSideEffects, terminalManager: TestAgentHostTerminalManager, turnId: string): Promise { + const action: ChatAction = { + type: ActionType.ChatTurnStarted, turnId, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, action); + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ commandId: turnId, command: 'echo hi', exitCode: 0, output: 'hi\n' }); + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + } + + let localTurns: AgentHostLocalTurns; + + function createLocalTurnSideEffects(db: TestSessionDatabase, terminalManager: TestAgentHostTerminalManager): AgentSideEffects { + const sessionDataService = createSessionDataService(db); + localTurns = new AgentHostLocalTurns(sessionDataService, new NullLogService()); + return createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService, + localTurns, + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + } + + test('anchors a bang turn to the preceding concrete turn', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'local-1'), 'real-1'); + const persisted = await db.getLocalTurns(); + assert.deepStrictEqual(persisted.map(r => ({ turnId: r.turnId, chatUri: r.chatUri, anchorTurnId: r.anchorTurnId })), [ + { turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1' }, + ]); + }); + + test('truncating at a local turn redirects the SDK truncation to the concrete anchor', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + // Truncate at the local turn (keep it). Reducer keeps [real-1, local-1]. + stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' }, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' }); + + // The SDK is told to keep up to the concrete turn before the local one. + const truncateCall = agent.truncateSessionCalls.at(-1); + assert.strictEqual(truncateCall?.session.toString(), sessionUri.toString()); + assert.strictEqual(truncateCall?.turnId, 'real-1'); + }); + + test('truncating at a real turn drops the trailing local turn', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + // Truncate at the real turn (drop the local turn after it). + stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' }, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' }); + + assert.strictEqual(agent.truncateSessionCalls.at(-1)?.turnId, 'real-1'); + // The local turn is dropped from memory synchronously and from the DB async. + assert.strictEqual(localTurns.isLocal(defaultChatUri, 'local-1'), false); + await new Promise(r => setTimeout(r, 10)); + assert.deepStrictEqual(await db.getLocalTurns(), []); + }); + }); + // ---- immediate title on first turn ----------------------------------- suite('immediate title on first turn', () => { @@ -2651,6 +2863,40 @@ suite('AgentSideEffects', () => { assert.strictEqual(state!.title, 'Restored Title'); }); + test('restore interleaves a persisted local turn after its anchor', async () => { + const sessionDataService = createSessionDataService(sessionDb); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(localAgent); + + const { session } = await localAgent.createSession(); + const sessions = await localAgent.listSessions(); + const sessionResource = sessions[0].session; + + // The SDK transcript yields a single real turn keyed by the first + // user message id (`buildTurnsFromHistory`). + localAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Hi', toolRequests: [] }, + ]; + + // A host-injected local turn recorded against that real turn. + const localTurn = { + id: 'local-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'ran' }], + usage: undefined, + state: 2, // TurnState.Complete + }; + await sessionDb.insertLocalTurn({ turnId: 'local-1', chatUri: buildDefaultChatUri(sessionResource.toString()), anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) }); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + assert.deepStrictEqual(state?.turns.map(t => t.id), ['real-1', 'local-1']); + }); + test('SessionConfigChanged persists merged config values to the database', async () => { const sessionDataService = createSessionDataService(sessionDb); const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -3679,6 +3925,34 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(changesets.truncates, [sessionUri.toString()]); }); + + test('truncating a chat forwards that chat to the agent (default and peer)', () => { + setupSession(); + const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1'); + + // Peer chat: the chat URI is forwarded so the agent targets that + // chat's own backing rather than the session's default chat. + sideEffects.handleAction(peerChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-peer' }); + const peerCall = agent.truncateSessionCalls.at(-1); + + // Default chat: forwarded as the session's default chat URI. + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-default' }); + const defaultCall = agent.truncateSessionCalls.at(-1); + + assert.deepStrictEqual({ + peerSession: peerCall?.session.toString(), + peerTurnId: peerCall?.turnId, + peerChat: peerCall?.chat?.toString(), + defaultTurnId: defaultCall?.turnId, + defaultChat: defaultCall?.chat?.toString(), + }, { + peerSession: sessionUri.toString(), + peerTurnId: 'turn-peer', + peerChat: peerChatUri, + defaultTurnId: 'turn-default', + defaultChat: defaultChatUri, + }); + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 3445f4d0a81..744d3b0e00c 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -239,6 +239,9 @@ class StubCopilotApiService implements ICopilotApiService { readonly messagesCallCount = { count: 0 }; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + messages( token: string, request: Anthropic.MessageCreateParamsStreaming, @@ -492,6 +495,7 @@ class RoundTripQuery implements AsyncGenerator { setMaxThinkingTokens(): never { throw new Error('not modeled'); } applyFlagSettings(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index c66a3cae473..c510205e0d3 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -46,7 +46,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IAgentChatDataChange, IAgentMaterializeSessionEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; -import { ActionType } from '../../common/state/sessionActions.js'; +import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js'; import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, parseChatUri, parseDefaultChatUri, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -149,6 +149,8 @@ class FakeCopilotApiService implements ICopilotApiService { countTokens(): Promise { throw new Error('not used in ClaudeAgent tests'); } responses(): Promise { throw new Error('not used in ClaudeAgent tests'); } utilityChatCompletion(): Promise { throw new Error('not used in ClaudeAgent tests'); } + resolveRestrictedTelemetryContext() { return Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }); } + resolveApiEndpoint() { return Promise.resolve(undefined); } } const FakeProductService: IProductService = { @@ -567,6 +569,7 @@ class FakeQuery implements AsyncGenerator { setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise { this.recordedFlagSettings.push(s); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } + reinitialize(): never { throw new Error('FakeQuery: reinitialize not modeled'); } supportedCommands(): never { return Promise.resolve(this._sdk.supportedCommandsResult) as never; @@ -970,6 +973,69 @@ suite('ClaudeAgent', () => { assert.deepStrictEqual({ accepted, proxyStarts: proxy.startCalls.length }, { accepted: true, proxyStarts: 0 }); }); + test('transport flip native→proxy with no proxy handle emits auth/required once', () => { + const { agent, configService } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + const events: Omit[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + configService.updateRootConfig({ claudeUseCopilotProxy: true }); + + assert.deepStrictEqual(events, [{ resource: 'https://api.github.com', reason: 'required' }]); + }); + + test('transport flip does not emit auth/required when a proxy handle already exists', async () => { + const { agent, proxy, configService } = createTestContext(disposables); + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + assert.strictEqual(proxy.startCalls.length, 1); + + const events: Omit[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + configService.updateRootConfig({ claudeUseCopilotProxy: false }); // → native + configService.updateRootConfig({ claudeUseCopilotProxy: true }); // → proxy; handle persists + + assert.deepStrictEqual(events, []); + }); + + test('transport flip proxy→native does not emit auth/required', () => { + const { agent, configService } = createTestContext(disposables); + const events: Omit[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + configService.updateRootConfig({ claudeUseCopilotProxy: false }); + + assert.deepStrictEqual(events, []); + }); + + test('construction in proxy mode does not emit auth/required', async () => { + const { agent } = createTestContext(disposables); + const events: Omit[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + await tick(); + + assert.deepStrictEqual(events, []); + }); + + test('proxy-mode authenticate with an unchanged token starts the proxy when no handle exists', async () => { + // Native mode records the Copilot token without starting the proxy. After + // a flip to proxy the agent has a token but no handle; re-authenticating + // with the SAME token must still start the proxy rather than short- + // circuiting on the "token unchanged" path. + const { agent, proxy, configService } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + await agent.authenticate('https://api.github.com', 'T'); + assert.strictEqual(proxy.startCalls.length, 0); + + configService.updateRootConfig({ claudeUseCopilotProxy: true }); + await agent.authenticate('https://api.github.com', 'T'); + await tick(); + + assert.deepStrictEqual({ + startTokens: proxy.startCalls.map(c => c.token), + disposeCount: proxy.disposeCount, + }, { startTokens: ['T'], disposeCount: 0 }); + }); + test('createSession before authenticate throws ProtocolError(AHP_AUTH_REQUIRED) with protected resources', async () => { const { agent } = createTestContext(disposables); diff --git a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts index 1624281974a..b0cba0e0f2d 100644 --- a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts @@ -56,6 +56,9 @@ type MessagesResult = class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + messagesResult: MessagesResult = { kind: 'error', error: new Error('not configured') }; modelsResult: { kind: 'value'; value: CCAModel[] } | { kind: 'error'; error: Error } = { kind: 'value', value: [] }; @@ -1252,6 +1255,8 @@ suite('ClaudeProxyService', () => { models: () => Promise.resolve([]), responses: () => Promise.reject(new Error('not used')), utilityChatCompletion: () => Promise.reject(new Error('not used')), + resolveRestrictedTelemetryContext: () => Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }), + resolveApiEndpoint: () => Promise.resolve(undefined), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); @@ -1321,6 +1326,8 @@ suite('ClaudeProxyService', () => { models: fake.models.bind(fake), responses: fake.responses.bind(fake), utilityChatCompletion: fake.utilityChatCompletion.bind(fake), + resolveRestrictedTelemetryContext: fake.resolveRestrictedTelemetryContext.bind(fake), + resolveApiEndpoint: fake.resolveApiEndpoint.bind(fake), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index fa9b03d05c4..0851e0f9f58 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -69,6 +69,7 @@ class ImmediatelyDoneQuery implements Query { async [Symbol.asyncDispose](): Promise { /* not exercised here */ } setMaxThinkingTokens(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts index 38588162104..fb251fdc511 100644 --- a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts @@ -232,8 +232,8 @@ suite('claudeToolDisplay — §4 mapping table', () => { ['TodoWrite', undefined, undefined, 'Updating todo list', 'Updated todo list', '"Update todo list" failed', '{\n "todos": []\n}'], ['WebFetch', undefined, undefined, { markdown: 'Fetching [https://example.com](https://example.com)' }, { markdown: 'Fetched [https://example.com](https://example.com)' }, '"Fetch URL" failed', '{\n "url": "https://example.com"\n}'], ['Task', 'subagent', { toolKind: 'subagent' }, 'find the bug', 'Ran subagent', '"Run subagent task" failed', '{\n "description": "find the bug",\n "subagent_type": "Explore"\n}'], - ['ExitPlanMode', undefined, undefined, 'Ready to code?', 'Used "Ready to code?"', '"Ready to code?" failed', '{\n "plan": "..."\n}'], - ['AskUserQuestion', undefined, undefined, 'Ask user a question', 'Used "Ask user a question"', '"Ask user a question" failed', '{\n "question": "why?"\n}'], + ['ExitPlanMode', undefined, undefined, 'Ready to code?', 'Ready to code?', '"Ready to code?" failed', '{\n "plan": "..."\n}'], + ['AskUserQuestion', undefined, undefined, 'Ask user a question', 'Ask user a question', '"Ask user a question" failed', '{\n "question": "why?"\n}'], ]); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts index 39d1bd24011..fce6ecdb788 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts @@ -24,6 +24,9 @@ interface IResponsesCall { class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + readonly responsesCalls: IResponsesCall[] = []; messages(): never { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 66351081bee..eacf4a8b6d7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -26,6 +26,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; @@ -44,7 +45,9 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, getCopilotWorktreeName, getCopilotWorktreesRoot, migrateEnablementKeys, rebaseUnder } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; @@ -173,6 +176,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async revParse(_repositoryRoot: URI, expression: string): Promise { return expression === 'HEAD' ? this.headCommit : undefined; } + async resolveBranchBaselineCommit(): Promise { return undefined; } + async overlayPathIntoTree(): Promise { return undefined; } + async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } @@ -213,6 +219,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise { throw new Error('not used'); } async models(): Promise { return []; } async responses(): Promise { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -430,6 +438,18 @@ class MockAgentHostOTelService implements IAgentHostOTelService { } } +class TestProxyResolver implements IAgentHostProxyResolver { + declare readonly _serviceBrand: undefined; + + register(): IDisposable { + return Disposable.None; + } + + async resolveProxy(): Promise { + return undefined; + } +} + class ResumePathCopilotAgent extends CopilotAgent { constructor( private readonly _copilotClient: ITestCopilotClient, @@ -442,8 +462,10 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentHostCompletions completions: IAgentHostCompletions, @INativeEnvironmentService environmentService: INativeEnvironmentService, @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, environmentService, byokBridgeRegistry); + super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService, proxyResolver); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -471,8 +493,10 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentHostCompletions completions: IAgentHostCompletions, @INativeEnvironmentService environmentService: INativeEnvironmentService, @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, environmentService, byokBridgeRegistry); + super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService, proxyResolver); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -534,6 +558,7 @@ function createTestAgentContext(disposables: Pick, optio services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); + services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); services.set(IAgentHostOTelService, { _serviceBrand: undefined, @@ -542,6 +567,7 @@ function createTestAgentContext(disposables: Pick, optio flush: async () => undefined, }); services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); + services.set(IAgentHostProxyResolver, new TestProxyResolver()); services.set(IByokLmBridgeRegistry, new ByokLmBridgeRegistry()); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); @@ -2294,7 +2320,10 @@ suite('CopilotAgent', () => { assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); const systemMessage = capturedConfig.systemMessage; - assert.deepStrictEqual(systemMessage, COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(systemMessage, { + ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE, + content: COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, + }); if (!systemMessage || systemMessage.mode !== 'customize') { assert.fail('Expected customize-mode system message'); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b989740dccd..80dae055ffd 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -25,7 +25,7 @@ import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedb import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { McpAuthRequiredReason, McpServerStatus } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; @@ -2004,7 +2004,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, }, }), { - content: 'Shell done', messageText: '`sleep 6` completed', startsTurn: true, }); @@ -2016,7 +2015,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'shell_detached_completed', shellId: 'detached-a' }, }, }), { - content: 'Detached done', messageText: 'Shell `detached-a` completed', startsTurn: true, }); @@ -2028,7 +2026,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' }, }, }), { - content: 'Agent done', messageText: 'Background agent agent-a completed', startsTurn: true, }); @@ -2040,7 +2037,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_completed', agentId: 'agent-b', agentType: 'task', status: 'failed' }, }, }), { - content: 'Agent failed', messageText: 'Background agent agent-b failed', startsTurn: true, }); @@ -2052,7 +2048,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, }, }), { - content: 'Agent idle', messageText: 'Background agent agent-a is complete', startsTurn: true, }); @@ -2064,7 +2059,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, }, }), { - content: 'Inbox message', messageText: 'New inbox message from sidekick', startsTurn: false, }); @@ -2076,7 +2070,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, }, }), { - content: 'Discovered instruction', messageText: 'Instruction discovered: AGENTS.md from packages/billing/', startsTurn: false, }); @@ -2150,7 +2143,7 @@ suite('CopilotAgentSession', () => { turnId: 'turn-active', part: { kind: ResponsePartKind.SystemNotification, - content: 'Agent "agent-a" has finished processing and is now idle.', + content: 'Background agent agent-a is complete', }, }); }); @@ -2177,8 +2170,8 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(getActions(signals) .filter(action => action.type === ActionType.ChatResponsePart) .map(action => action.part), [ - { kind: ResponsePartKind.SystemNotification, content: 'Inbox from sidekick' }, - { kind: ResponsePartKind.SystemNotification, content: 'Discovered instruction' }, + { kind: ResponsePartKind.SystemNotification, content: 'New inbox message from sidekick' }, + { kind: ResponsePartKind.SystemNotification, content: 'Instruction discovered: AGENTS.md from packages/billing/' }, ]); }); @@ -2240,6 +2233,22 @@ suite('CopilotAgentSession', () => { } }); + test('tool_start derives intention from a shell tool description argument', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-intent'); + + // The shell tool's own `description` argument carries the intention. + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-intent', + toolName: 'bash', + arguments: { command: 'ls', description: 'List files in the repo root' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); + assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); + assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); + }); + test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { const wd = URI.file('/repo/project'); const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); @@ -3034,6 +3043,67 @@ suite('CopilotAgentSession', () => { ]); }); + test('subagent skill invocation routes to the subagent session scope', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-1'); + + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('skill.invoked', { + name: 'explore', + path: '/skills/explore/SKILL.md', + } as SessionEventPayload<'skill.invoked'>['data'], { id: 'skill-event', agentId: 'agent-1' }); + + const skillActions = signals + .filter((signal): signal is IAgentActionSignal => signal.kind === 'action') + .filter(signal => + signal.action.type === ActionType.ChatToolCallStart + || signal.action.type === ActionType.ChatToolCallReady + || signal.action.type === ActionType.ChatToolCallComplete + ) + .map(signal => ({ parentToolCallId: signal.parentToolCallId, action: signal.action })); + + assert.deepStrictEqual(skillActions, [ + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + toolName: 'skill', + displayName: 'Read Skill', + }, + }, + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + invocationMessage: { markdown: 'Reading skill [explore](file:///skills/explore/SKILL.md)' }, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }, + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + result: { + success: true, + pastTenseMessage: { markdown: 'Read skill [explore](file:///skills/explore/SKILL.md)' }, + }, + }, + }, + ]); + }); + test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { // Regression test: fork / truncate look up the SDK boundary // event id via `getNextTurnEventId(turnId)`, which keys on @@ -3613,6 +3683,31 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.textResultForLlm, 'result text'); }); + test('agent-coordination client tools auto-ready with a tailored invocation message', async () => { + const agentSnapshot: IActiveClientSnapshot = { + tools: [{ name: 'list_agents', description: 'List agents', inputSchema: { type: 'object', properties: {} } }], + plugins: [], + mcpServers: {}, + }; + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('agent-client', agentSnapshot.tools); + const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: agentSnapshot, activeClientToolSet }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-list-agents', + toolName: 'list_agents', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + + // Unlike other client tools (which defer to the permission flow), + // the auto-approved agent-coordination tools auto-ready so their + // invocation renders our tailored message instead of the generic + // "Running {displayName}…" fallback. + const readySignal = signals.find(s => isAction(s, ActionType.ChatToolCallReady)); + assert.ok(readySignal && isAction(readySignal, ActionType.ChatToolCallReady)); + assert.strictEqual((readySignal.action as ChatToolCallReadyAction).invocationMessage, 'Listed agents'); + }); + test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { const activeClientToolSet = new ActiveClientToolSet(); activeClientToolSet.set('client-perm', snapshot.tools); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 37a24eea2ac..7e91b7947d0 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -38,6 +38,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async updateRef(): Promise { } async deleteRefs(): Promise { } async revParse(): Promise { return undefined; } + async resolveBranchBaselineCommit(): Promise { return undefined; } + async overlayPathIntoTree(): Promise { return undefined; } + async diffTreePaths(): Promise { return undefined; } async computeFileDiffsBetweenRefs(): Promise { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts index bf4b1615bda..02935d7eb3a 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionEvent, ToolExecutionCompleteContent } from '@github/copilot-sdk'; +import type { Attachment, SessionEvent, SessionEventPayload, ToolExecutionCompleteContent } from '@github/copilot-sdk'; // ============================================================================= // Minimal session-event shapes for tests @@ -106,6 +106,21 @@ export interface ISessionEventAbort { }; } +export interface ISessionEventAssistantTurn { + type: 'assistant.turn_start' | 'assistant.turn_end'; + agentId?: string; + data: { + turnId: string; + interactionId?: string; + }; +} + +export interface ISessionEventSystemNotification { + type: 'system.notification'; + id?: string; + data: SessionEventPayload<'system.notification'>['data']; +} + /** Minimal event shape for session history mapping. */ export type ISessionEvent = | ISessionEventToolStart @@ -114,6 +129,8 @@ export type ISessionEvent = | ISessionEventSubagentStarted | ISessionEventSkillInvoked | ISessionEventAbort + | ISessionEventAssistantTurn + | ISessionEventSystemNotification | { type: string; data?: unknown }; /** diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index db94d9d8682..924a0070b03 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('copilotToolDisplay — friendly tool names', () => { @@ -299,6 +299,60 @@ suite('view tool — view_range display', () => { }); }); +suite('copilotToolDisplay — built-in tool invocation/past-tense messages', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function invocation(toolName: string, parameters: Record | undefined): string { + const result = getInvocationMessage(toolName, getToolDisplayName(toolName), parameters); + return typeof result === 'string' ? result : result.markdown; + } + + function pastTense(toolName: string, parameters: Record | undefined): string { + const result = getPastTenseMessage(toolName, getToolDisplayName(toolName), parameters, true); + return typeof result === 'string' ? result : result.markdown; + } + + test('agent-coordination tools use a single message (past tense) for both invocation and completion', () => { + // read/write agents surface the agent id, and the invocation message + // matches the past-tense message (these tools are fast). + assert.strictEqual(invocation('read_agent', { agent_id: 'math-helper' }), 'Read agent `math-helper`'); + assert.strictEqual(pastTense('read_agent', { agent_id: 'math-helper' }), 'Read agent `math-helper`'); + assert.strictEqual(invocation('write_agent', { agent_id: 'math-helper', message: 'hi' }), 'Wrote to agent `math-helper`'); + assert.strictEqual(pastTense('write_agent', { agent_id: 'math-helper', message: 'hi' }), 'Wrote to agent `math-helper`'); + }); + + test('agent tools fall back to a generic phrase without an agent id', () => { + assert.strictEqual(invocation('read_agent', {}), 'Read agent'); + assert.strictEqual(pastTense('write_agent', undefined), 'Wrote to agent'); + }); + + test('agent tools ignore a malformed (non-string) agent id instead of throwing', () => { + // agent_id comes from untrusted JSON, so a non-string must not reach the + // markdown inline-code formatter (which would throw). + assert.strictEqual(invocation('read_agent', { agent_id: 123 }), 'Read agent'); + assert.strictEqual(pastTense('write_agent', { agent_id: '' }), 'Wrote to agent'); + }); + + test('list_agents shares one message; task keeps distinct present/past phrases', () => { + // list_agents is a fast agent-coordination tool: one message. + assert.strictEqual(invocation('list_agents', {}), 'Listed agents'); + assert.strictEqual(pastTense('list_agents', {}), 'Listed agents'); + // task delegates to a (possibly slow) subagent, so it keeps a present-tense invocation. + assert.strictEqual(invocation('task', {}), 'Delegating task'); + assert.strictEqual(pastTense('task', {}), 'Delegated task'); + }); + + test('unhandled tools fall back to just the display name', () => { + // Known tool with no tailored message: uses its friendly display name. + assert.strictEqual(invocation('store_memory', {}), 'Store Memory'); + assert.strictEqual(pastTense('store_memory', {}), 'Store Memory'); + // Unknown tool: display name is the raw tool name. + assert.strictEqual(invocation('some_new_tool', {}), 'some_new_tool'); + assert.strictEqual(pastTense('some_new_tool', {}), 'some_new_tool'); + }); +}); + // ---- write_/read_ shell tool display --------------------------------------- // // Coverage for the secondary shell helpers (write_bash, read_bash, and their @@ -722,3 +776,29 @@ suite('apply_patch tool display', () => { assert.deepStrictEqual(getEditFilePaths(singleFilePatch), ['/repo/src/foo.ts']); }); }); + +suite('getShellIntention', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads the description argument of shell tools, and ignores non-shell tools', () => { + assert.deepStrictEqual({ + bash: getShellIntention('bash', { command: 'ls', description: 'List files' }), + powershell: getShellIntention('powershell', { command: 'Get-ChildItem', description: 'List files' }), + shellNoDescription: getShellIntention('bash', { command: 'ls' }), + shellEmptyDescription: getShellIntention('bash', { command: 'ls', description: '' }), + // The `task` (subagent) tool also has a `description` argument, but it is + // the subagent task description, not a shell intention — must be ignored. + taskTool: getShellIntention('task', { description: 'Explore the codebase' }), + viewTool: getShellIntention('view', { path: '/repo/file.ts', description: 'why' }), + noArgs: getShellIntention('bash', undefined), + }, { + bash: 'List files', + powershell: 'List files', + shellNoDescription: undefined, + shellEmptyDescription: undefined, + taskTool: undefined, + viewTool: undefined, + noArgs: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index fb5fd929d06..7fb87183fdb 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { MessageAttachmentKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; @@ -16,8 +16,8 @@ suite('mapSessionEvents — history replay', () => { const session = AgentSession.uri('copilot', 'test-session'); - function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: string }> { - return parts.map(p => p.kind === ResponsePartKind.Markdown ? { kind: p.kind, content: p.content } : { kind: p.kind }); + function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: StringOrMarkdown }> { + return parts.map(p => p.kind === ResponsePartKind.Markdown || p.kind === ResponsePartKind.SystemNotification ? { kind: p.kind, content: p.content } : { kind: p.kind }); } test('task_complete with a summary renders as a markdown part, not a tool call', async () => { @@ -90,6 +90,21 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('derives shell tool intention from the description argument on replay', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'ls', description: 'List files in the repo root' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true, result: { content: 'a\nb\n' } } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.intention, 'List files in the repo root'); + }); + test('preserves SDK shell_exit content on replayed tool completion', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, @@ -235,6 +250,109 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('restores a system notification inside an assistant turn as a response part', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Wait for the background command' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '\nShell command completed\n', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + }, + }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'Reading the output now.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + id: 'user-event', + message: { text: 'Wait for the background command', origin: { kind: MessageKind.User } }, + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.SystemNotification, content: '`sleep 6` completed' }, + { kind: ResponsePartKind.Markdown, content: 'Reading the output now.' }, + ], + }]); + }); + + test('restores an idle system notification as a system-initiated turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Start the background agent' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'The background agent is running.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '\nAgent completed\n', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' }, + }, + }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-2' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-2', content: 'Reading the background agent result.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [ + { + id: 'user-event', + message: { text: 'Start the background agent', origin: { kind: MessageKind.User } }, + state: TurnState.Complete, + parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent is running.' }], + }, + { + id: 'notification-event', + message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, + state: TurnState.Complete, + parts: [{ kind: ResponsePartKind.Markdown, content: 'Reading the background agent result.' }], + }, + ]); + }); + + test('does not restore a passive notification outside an assistant turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Check for instructions' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'No new instructions.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '\nInstruction discovered\n', + kind: { type: 'instruction_discovered', sourcePath: 'AGENTS.md', triggerFile: 'src/index.ts', triggerTool: 'view', description: 'Workspace instructions' }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + parts: partKinds(turn.responseParts), + })), [{ + id: 'user-event', + parts: [{ kind: ResponsePartKind.Markdown, content: 'No new instructions.' }], + }]); + }); + test('synthetic user messages do not start a new turn', async () => { const events: ISessionEvent[] = [ { type: 'user.message', id: 'user-event-1', data: { interactionId: 'interaction-1', content: 'Use the skill' } }, diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 73b2df4840d..1b128cd2491 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -70,6 +70,7 @@ export class MockAgent implements IAgent { readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = []; readonly removeActiveClientCalls: { clientId: string }[] = []; readonly clientToolCallCompleteCalls: { session: URI; chat: URI; toolCallId: string; result: ToolCallResult }[] = []; + readonly truncateSessionCalls: { session: URI; turnId: string | undefined; chat: URI | undefined }[] = []; readonly setCustomizationEnabledCalls: { id: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ customizations: Customization[] = []; @@ -164,6 +165,10 @@ export class MockAgent implements IAgent { this.abortSessionCalls.push(session); } + async truncateSession(session: URI, turnId?: string, chat?: URI): Promise { + this.truncateSessionCalls.push({ session, turnId, chat }); + } + respondToPermissionRequest(requestId: string, approved: boolean): void { this.respondToPermissionCalls.push({ requestId, approved }); } diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index f57a37d664f..cf7a546a2eb 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -13,6 +13,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; +import { buildChatUri } from '../../common/state/sessionState.js'; import { SessionDataService } from '../../node/sessionDataService.js'; suite('SessionDataService', () => { @@ -43,6 +44,17 @@ suite('SessionDataService', () => { assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString()); }); + test('getSessionDataDir gives each peer chat of a session its own directory', () => { + const session = AgentSession.uri('copilotcli', 'session-1'); + const chatA = URI.parse(buildChatUri(session, 'chat-a')); + const chatB = URI.parse(buildChatUri(session, 'chat-b')); + const dirA = service.getSessionDataDir(chatA); + const dirB = service.getSessionDataDir(chatB); + assert.notStrictEqual(dirA.toString(), dirB.toString()); + // The plain session URI keeps its authority-free directory. + assert.strictEqual(service.getSessionDataDir(session).toString(), URI.joinPath(basePath, 'agentSessionData', 'session-1').toString()); + }); + test('deleteSessionData removes directory', async () => { const session = AgentSession.uri('copilot', 'session-1'); const dir = service.getSessionDataDir(session); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index af48bc6bd11..db19777b333 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -10,6 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; import { FileEditKind, MessageKind } from '../../common/state/sessionState.js'; +import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js'; import { join } from '../../../../base/common/path.js'; @@ -618,6 +619,85 @@ suite('SessionDatabase', () => { }); }); + // ---- reviewed files ------------------------------------------------- + + suite('reviewed files', () => { + const uriA = URI.parse('file:///workspace/a.ts'); + const uriB = URI.parse('file:///workspace/b.ts'); + + const normalize = (records: readonly IReviewedFileRecord[]) => records.map(r => ({ uri: r.uri.toString(), nonce: r.nonce })); + + test('markFileReviewed and isFileReviewed discriminate by uri and nonce', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual( + await Promise.all([ + db.isFileReviewed(uriA, 'n1'), + db.isFileReviewed(uriA, 'n2'), + db.isFileReviewed(uriB, 'n1'), + ]), + [true, false, false], + ); + }); + + test('getReviewedFiles returns all entries in insertion order', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriB.toString(), nonce: 'n2' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('getReviewedFilesForUri returns only the given uri', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFilesForUri(uriA)), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('unmarkFileReviewed removes an entry and is a no-op when absent', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); // no-op, must not throw + + assert.deepStrictEqual( + await Promise.all([db.isFileReviewed(uriA, 'n1'), db.getReviewedFiles()]), + [false, []], + ); + }); + + test('marking the same (uri, nonce) twice keeps a single entry', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [{ uri: uriA.toString(), nonce: 'n1' }]); + }); + + test('migration v7 creates the reviewed_files table', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('reviewed_files')); + }); + }); + // ---- vacuumInto ----------------------------------------------------- suite('vacuumInto', () => { diff --git a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts index de5543056c9..68802f922f3 100644 --- a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts @@ -15,6 +15,7 @@ import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFil import { NullLogService } from '../../../../log/common/log.js'; import { NullTelemetryServiceShape } from '../../../../telemetry/common/telemetryUtils.js'; import { EditSurvivalReporterFactory } from '../../../node/shared/editSurvivalReporter.js'; +import { buildDefaultChatUri } from '../../../common/state/sessionState.js'; class RecordingTelemetryService extends NullTelemetryServiceShape { readonly events: Array<{ name: string; data: unknown }> = []; @@ -83,6 +84,28 @@ suite('agentHost editSurvivalReporter', () => { assert.strictEqual(data.aiCharCount, 'after-text'.length); }); + test('resolves ahp-chat sub-channel URIs back to the parent harness', async () => { + await fileService.writeFile(URI.file('/workspace/b.ts'), VSBuffer.fromString('after-text')); + + const reporter = factory.launch({ + sessionUri: buildDefaultChatUri('claude:/session-9'), + turnId: 'turn-1', + toolCallId: 'tc-9', + filePath: '/workspace/b.ts', + beforeText: 'before-text', + afterText: 'after-text', + isCreate: false, + aiChunks: ['after-text'], + }); + disposables.add(reporter); + + await timeout(50); + + const data = telemetry.events[0].data as Record; + assert.strictEqual(data.provider, 'claude'); + assert.strictEqual(data.agentSessionId, 'session-9'); + }); + test('emits a delete event when the file is missing', async () => { const reporter = factory.launch({ sessionUri: 'codex:/session-2', diff --git a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts new file mode 100644 index 00000000000..a6b42f7459b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; +import type { TerminalClaim, TerminalInfo, TerminalState } from '../../common/state/protocol/state.js'; +import type { IAgentHostTerminalManager, ICommandFinishedEvent } from '../../node/agentHostTerminalManager.js'; + +/** + * Controllable fake {@link IAgentHostTerminalManager} for tests. `createTerminal` + * records the request and announces the terminal URI via + * {@link onDidCreateTerminal}; tests drive command completion with + * {@link fireCommandFinished}. When no terminal interaction is needed it also + * serves as a benign no-op stand-in. + */ +export class TestAgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager { + declare readonly _serviceBrand: undefined; + + defaultShell = '/bin/bash'; + commandDetectionSupported = true; + readonly created: CreateTerminalParams[] = []; + readonly sentTexts: { uri: string; data: string }[] = []; + readonly disposedTerminals: string[] = []; + + /** Resolves once a command-finished listener is registered (i.e. a command is running). */ + readonly commandFinishedListenerRegistered = new DeferredPromise(); + + private readonly _onCommandFinished = this._register(new Emitter()); + private readonly _onData = this._register(new Emitter()); + private readonly _onExit = this._register(new Emitter()); + private readonly _onClaimChanged = this._register(new Emitter()); + private readonly _onDidCreateTerminal = this._register(new Emitter()); + readonly onDidCreateTerminal = this._onDidCreateTerminal.event; + + async createTerminal(params: CreateTerminalParams): Promise { + this.created.push(params); + this._onDidCreateTerminal.fire(params.channel); + } + writeInput(): void { } + async sendText(uri: string, data: string): Promise { this.sentTexts.push({ uri, data }); } + onData(_uri: string, cb: (data: string) => void): IDisposable { return this._onData.event(cb); } + onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); } + onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); } + onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { + this.commandFinishedListenerRegistered.complete(); + return this._onCommandFinished.event(cb); + } + createAltBufferPromise(): Promise { return new Promise(() => { }); } + getContent(): string | undefined { return undefined; } + getClaim(): TerminalClaim | undefined { return undefined; } + hasTerminal(): boolean { return false; } + getExitCode(): number | undefined { return undefined; } + supportsCommandDetection(): boolean { return this.commandDetectionSupported; } + disposeTerminal(uri: string): void { this.disposedTerminals.push(uri); } + getTerminalInfos(): TerminalInfo[] { return []; } + getTerminalState(): TerminalState | undefined { return undefined; } + async getDefaultShell(): Promise { return this.defaultShell; } + fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } +} diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 79f076df6bc..92db79754a4 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -123,6 +123,14 @@ export interface IBrowserViewWindowConfiguration { * workspace folder paths. */ readonly trustedFileRoots: readonly string[]; + /** + * Whether Workspace Trust is disabled entirely + * (`security.workspace.trust.enabled: false`) for this window. When + * `true`, every `file://` request is allowed regardless of + * {@link trustedFileRoots} since there is no meaningful notion of an + * untrusted folder to enforce. + */ + readonly trustAllFiles: boolean; } export interface IBrowserViewBounds { diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index ace51fa2384..7748280b41d 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -184,10 +184,13 @@ export class BrowserSession { } private static readonly _trustedFileRoots = TernarySearchTree.forPaths(!isLinux); + private static _trustAllFiles = false; + /** * Set trusted file roots for all browser sessions. */ - static setTrustedFileRoots(roots: readonly string[]): void { + static setTrustedFileRoots(roots: readonly string[], trustAllFiles: boolean): void { + BrowserSession._trustAllFiles = trustAllFiles; BrowserSession._trustedFileRoots.clear(); for (const root of roots) { if (root) { @@ -274,7 +277,7 @@ export class BrowserSession { }); this.electronSession.protocol.handle(Schemas.file, request => { const filePath = normalize(URI.parse(request.url).fsPath); - if (!BrowserSession._trustedFileRoots.findSubstr(filePath)) { + if (!BrowserSession._trustAllFiles && !BrowserSession._trustedFileRoots.findSubstr(filePath)) { return new Response(localize('browserSession.untrustedFile', 'Forbidden. File does not reside within a trusted folder.'), { status: 403 }); } return this.electronSession.fetch(request, { bypassCustomProtocolHandlers: true }); diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 8a513c1d051..3934db7b5b8 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -390,12 +390,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _recomputeTrustedFileRoots(): void { const roots = new Set(); + let trustAllFiles = false; for (const configuration of this._windowConfigurations.values()) { for (const root of configuration.trustedFileRoots) { roots.add(root); } + trustAllFiles ||= configuration.trustAllFiles; } - BrowserSession.setTrustedFileRoots([...roots]); + BrowserSession.setTrustedFileRoots([...roots], trustAllFiles); } /** diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index ffee8e7b664..d5630b57899 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -9,6 +9,9 @@ const _allApiProposals = { activeComment: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.activeComment.d.ts', }, + agentEditorComments: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts', + }, agentSessionsWorkspace: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentSessionsWorkspace.d.ts', }, diff --git a/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts index 3f0dfd2cd3d..076932c8fce 100644 --- a/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts +++ b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts @@ -8,7 +8,7 @@ import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { INativeSystemWideKeybinding } from '../../native/common/native.js'; +import { INativeSystemWideKeybinding, INativeSystemWideKeybindingResult } from '../../native/common/native.js'; import { INativeRunActionInWindowRequest } from '../../window/common/window.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; @@ -31,11 +31,11 @@ export interface IGlobalKeybindingsMainService { /** * Replaces the set of system-wide (OS global) keybindings owned by the given window with the - * provided set, reconciling the actual OS registrations. Returns the user settings labels (or - * accelerators) that could not be registered, e.g. because the accelerator is already taken by - * the OS or another application. + * provided set, reconciling the actual OS registrations. The result reports the user settings + * labels (or accelerators) that could not be registered (e.g. because the accelerator is already + * taken by the OS or another application). */ - updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): string[]; + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult; } export class GlobalKeybindingsMainService extends Disposable implements IGlobalKeybindingsMainService { @@ -64,7 +64,7 @@ export class GlobalKeybindingsMainService extends Disposable implements IGlobalK this._register(toDisposable(() => this.unregisterAll())); } - updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): string[] { + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult { const perWindow = new Map(); for (const keybinding of keybindings) { if (!this.isValid(keybinding)) { @@ -92,7 +92,8 @@ export class GlobalKeybindingsMainService extends Disposable implements IGlobalK failed.push(keybinding.userSettingsLabel ?? keybinding.accelerator); } } - return failed; + + return { failed }; } private isValid(keybinding: INativeSystemWideKeybinding): boolean { diff --git a/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts index dd57d029eb1..99e34ee9b9c 100644 --- a/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts +++ b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts @@ -129,7 +129,7 @@ suite('GlobalKeybindingsMainService', () => { test('registers desired accelerators and reports no failures', () => { const { service, shortcut } = createService(); - const failed = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); assert.deepStrictEqual(failed, []); assert.deepStrictEqual([...shortcut.registered.keys()].sort(), ['Control+Cmd+A', 'Control+Cmd+B']); @@ -149,19 +149,19 @@ suite('GlobalKeybindingsMainService', () => { shortcut.failFor.add('Control+Cmd+A'); const { service } = createService(shortcut); - const failed = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); assert.deepStrictEqual(failed, ['ctrl+cmd+a']); // Now the accelerator becomes available; a re-sync should register it. shortcut.failFor.delete('Control+Cmd+A'); - const failedAgain = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + const { failed: failedAgain } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); assert.deepStrictEqual(failedAgain, []); assert.ok(shortcut.isRegistered('Control+Cmd+A')); }); test('deduplicates accelerators within a single window payload', () => { const { service, shortcut } = createService(); - const failed = service.updateKeybindings(1, [binding('Control+Cmd+A', 'first'), binding('Control+Cmd+A', 'second')]); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'first'), binding('Control+Cmd+A', 'second')]); assert.deepStrictEqual(failed, []); assert.strictEqual(shortcut.registered.size, 1); diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 7b605737f10..13ee6139050 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -330,8 +330,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain if (typeof windowId !== 'number') { return { failed: [] }; } - const failed = this.globalKeybindingsMainService.updateKeybindings(windowId, keybindings); - return { failed }; + return this.globalKeybindingsMainService.updateKeybindings(windowId, keybindings); } async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise { diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 039f93ca6b7..0356b8644d0 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -681,7 +681,7 @@ export class TerminalSandboxEngine extends Disposable { }; if (this._os !== OperatingSystem.Windows) { const sandboxRuntimeSettings = sandboxSettings as Record; - this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, allowNetwork ? this._withoutNetworkRuntimeSetting(runtimeSetting) : runtimeSetting); + this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, runtimeSetting); this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, commandRuntimeSetting); if (this._os === OperatingSystem.Macintosh) { sandboxRuntimeSettings.allowPty ??= true; @@ -843,12 +843,6 @@ export class TerminalSandboxEngine extends Disposable { return paths.filter((path): path is string => typeof path === 'string'); } - private _withoutNetworkRuntimeSetting(runtimeSetting: Record): Record { - const sanitizedRuntimeSetting = { ...runtimeSetting }; - delete sanitizedRuntimeSetting.network; - return sanitizedRuntimeSetting; - } - private _mergeAdditionalSandboxConfigProperties(target: Record, additional: Record): void { for (const [key, value] of Object.entries(additional)) { if (!Object.prototype.hasOwnProperty.call(target, key)) { diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index 83c2b65599f..3e1600aa9ae 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -254,6 +254,28 @@ suite('TerminalSandboxEngine', () => { strictEqual(config.allowPty, false); }); + test('sandbox config preserves advanced runtime network settings when allowNetwork is enabled', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAdvancedRuntime, { + network: { + allowAllUnixSockets: true, + enabled: true, + }, + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + deepStrictEqual(config.network, { + allowedDomains: [], + deniedDomains: [], + enabled: false, + allowAllUnixSockets: true, + }); + }); + test('requestAllowNetwork keeps the command sandboxed and refreshes its network config', async () => { setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 1300cd5f2f3..bdb8912277e 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -71,6 +71,8 @@ The workbench grid is built with `proportionalLayout: false` (see `createWorkben | Editor | `Normal` | Keeps its user-set width (`600` default); only resized via its own sash. | | Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | +In the single-pane detail-panel layout, first-run sidebar width is slightly narrower (280px) so a typical window keeps roughly balanced chat and third-pane widths when the pane is shown. Persisted `_savedPartSizes` always win over these defaults. + **Invariant — exactly one `High` view in the horizontal chain.** A grid branch derives its priority from its children (`BranchNode.priority` in [base/browser/ui/grid/gridview.ts](src/vs/base/browser/ui/grid/gridview.ts)): `High` if any child is `High`, else `Low` if any child is `Low`, else `Normal`. The Top Right row contains a `Low` auxiliary bar, so unless the Sessions Part is `High` the whole Right Section derives to `Low`. The Content Section would then be `Sidebar (Low) | Right Section (Low)` — two equal-priority views — and with no high-priority absorber the resize delta spreads across **both**, growing the sidebar toward half the window. The Sessions Part being `High` is what lifts the Right Section to `High` so it (not the sidebar) absorbs the delta. > **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. @@ -100,10 +102,18 @@ The center section shows a clickable session picker widget. When a session is ac When no session is active (new chat view) the widget hides its chrome so the center is empty. Clicking opens the session switcher quick pick. +When the primary side bar is hidden and at least one session is **blocked** the widget instead switches to a **requires-input** state (see [Blocked Sessions](#blocked-sessions-center) below). + +After the user approves a pending action on a session from the sessions list (e.g. the **Allow** button on an approval row), the widget briefly shows a green "Approved N sessions" confirmation. Each approval within the rolling 3s window increments the count and restarts the countdown; while visible it takes precedence over the requires-input state. Driven by `ISessionActionFeedbackService` (`contrib/sessions`), whose `approvedCount` observable the widget reads. + ### Agent Host Filter (Left) When multiple remote agent hosts are known, a dropdown pill in the left toolbar scopes the workbench to a specific host. When no hosts are known the pill acts as a re-discover trigger. +### Blocked Sessions (Center) + +When the primary side bar is hidden and at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to an orange "N sessions require input" state (orange foreground, background and border), and blinks twice whenever a newly blocked session appears. A session counts as blocked when it needs input, or — while not in progress — has failing CI checks or unresolved pull request comments. Detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`) that the widget instantiates, which reuses the shared, background-polled GitHub CI / review-thread models via `computePullRequestIconStatus`. Clicking the widget opens those sessions rendered exactly like the sessions list but flat — no sections, groups or workspace headers — via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. When the side bar is visible or no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven directly by the `BlockedSessions` model's `blockedSessions` observable together with `Parts.SIDEBAR_PART` visibility. + ### Account Widget (Right) Shows the signed-in GitHub profile image (falls back to the account codicon). Clicking opens a combined account and Copilot status panel with sign-in/sign-out and settings actions. @@ -129,7 +139,9 @@ A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/se - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. -The header and the composite bar are deliberately separate widgets: the header represents the session identity/actions and is always present, while the tab strip is a per-chat navigation concern that appears (and then stays, per the sticky rule above) once a session has multiple chats or a diverged default-chat title. They share visual tokens via `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) and stylesheet ([browser/parts/media/chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). `SessionView` sums each widget's reported height to lay out the chat view below them. The header and tab strip are centered and capped to 990px via their own CSS classes (`.chat-composite-bar.session-header-bar` / `.chat-composite-bar.session-chat-tabs-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). The chat view itself is still laid out at full session width so its scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. +The header and the composite bar are deliberately separate widgets: the header represents the session identity/actions and is always present, while the tab strip is a per-chat navigation concern that appears (and then stays, per the sticky rule above) once a session has multiple chats or a diverged default-chat title. They share visual tokens via `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) and stylesheet ([browser/parts/media/chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). `SessionView` sums each widget's reported height to lay out the chat view below them. The header and tab strip are centered and capped to 990px via their own CSS classes (`.chat-composite-bar.session-header-bar` / `.chat-composite-bar.session-chat-tabs-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). The chat view itself is still laid out at full session width so its scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. Each constrained message row is also the positioning context for request overlays such as steering-message actions, keeping those controls anchored to the message instead of the full-width scroll viewport. + +**Pitfall:** absolute request overlays must not remain positioned against the full-width `.interactive-session` after message rows are independently constrained. Make the constrained row their positioning context or hover actions drift into the viewport gutter. Request rows must also override the tree's `.monaco-tl-contents { overflow: hidden; }`, otherwise controls positioned above the request are clipped at the row boundary. **Pitfall:** don't cap the chat viewport width in `SessionView` layout when you need edge-aligned scrollbars. Keep the viewport full-width and center only the inner chat content so alignment and scroll ergonomics both hold. @@ -183,14 +195,34 @@ Editors open as modal overlays rather than occupying grid space. The configurati | Editor opens (no explicit group) | Opens in modal overlay | | All editors closed / Escape / backdrop click | Modal closes and is disposed | -When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Secondary Side Bar** action for the auxiliary bar, and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). +When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Details** action for the auxiliary bar (labelled "Toggle Secondary Side Bar" in the non-single-pane layout), and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). When the auxiliary bar is hidden the editor becomes the rightmost card and expands into the freed space; the workbench's 10px right gutter still applies, and a `.noauxiliarybar` rule in `browser/media/style.css` restores the editor's right border and right corner radii so it keeps its card appearance. -The Toggle Secondary Side Bar action collapses or restores the secondary side bar while the editor stays open. When a session's editor working set is restored on session switch, the editor part is revealed programmatically and the session's saved auxiliary bar visibility is honored (a side bar the user hid for a session stays hidden when returning to it). +The Toggle Details action (Toggle Secondary Side Bar in the non-single-pane layout) collapses or restores the secondary side bar while the editor stays open. When a session's editor working set is restored on session switch, the editor part is revealed programmatically and the session's saved auxiliary bar visibility is honored (a side bar the user hid for a session stays hidden when returning to it). The main editor part can be explicitly revealed for workflows that target it directly. +### Single-pane redesign (experimental — `sessions.layout.singlePaneDetailPanel`, default OFF) + +> See [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) for the full scenario/state/transition catalog and the manual validation checklist. + +The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **off** (default) the Agents window renders exactly as documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). When **on**, the third pane becomes a **single pane with one full-width tab bar**: + +- The auxiliary bar is removed from the workbench grid and **docked inside the editor part** (absolutely positioned on the right, below the editor tab strip); the grid's top-right row becomes `Sessions | Editor`, and the editor part spans the editor + detail-panel width. +- The editor group's **title/tab strip spans the full width** while its content is inset on the right by the detail-panel width, via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). +- A vertical **sash** on the left edge of the docked panel resizes it (`DockedAuxiliaryBarController` in `browser/dockedAuxiliaryBarController.ts` owns `layout()` / `_ensureSash()`, created/driven by `browser/workbench.ts` `_ensureDockedAuxBarController()` / `_dockedAuxBar.layout()`), clamped to `[220px, editorWidth − 300px]`; the width persists via the part-sizes snapshot. +- Collapsing the sessions list transfers the freed sidebar width to the editor grid node when the editor content is **visible**, and to the **detail panel** (`_dockedAuxiliaryBarWidth`, with the editor node kept equal to it) when the editor content is **hidden** (detail-only). Reopening the sessions list restores the pre-collapse editor-node width / detail width. Keeping the hidden-editor node equal to the detail width ensures the width-based reveal-sync never mistakes a wide detail-only node for a revealed editor. +- When the editor part is hidden while the docked detail panel remains visible, the editor grid node stays visible for the shared tab strip but shrinks to the persisted detail-panel width, letting the Sessions part absorb the freed editor-content space. The detail panel fills that narrowed node below the tab strip, the editor content area collapses to zero, and the sash is disabled without overwriting the persisted detail-panel width. +- If the user drags the workbench grid sash to widen that narrowed editor node, `MainEditorPart.layout(...)` reports the new node width to the workbench, which treats the editor content as visible again and updates editor visibility without resizing the node, so the Hide Editor action returns while preserving the dragged width. +- Revealing the side pane from *closed* (`setEditorHidden(false)`, e.g. the session-header Changes button opening the Changes editor) gives the editor a comfortable **even split** with the chat via `_applyEditorSplitSize(mainAreaWidthBeforeReveal)` (`max(300, mainArea/2)`). In docked mode this runs on **every** reveal that has no `_dockedEditorSizeBeforeHide` to restore — not just the first per window — because hiding collapses the editor node to the detail width and the grid caches it, so a later reveal (including in another session, where the per-window `_hasAppliedInitialEditorSplit` flag is already set) would otherwise restore the narrow cached width. A genuinely user-chosen width captured on hide (`_dockedEditorSizeBeforeHide`) takes precedence and is restored as-is. Non-docked layout keeps the original first-reveal-only gating via `_hasAppliedInitialEditorSplit`. +- `_dockedEditorSizeBeforeHide` is captured on hide **only for "Hide Editor"** (detail/auxiliary bar still visible, so the editor node stays visible at a real user-chosen width). When the **whole** side pane closes (auxiliary bar also hidden — e.g. **Toggle Side Panel** or the last-tab close, where `setEditorHidden(true)` runs with `partVisibility.auxiliaryBar === false`), the editor grid node collapses to `0px`, so capturing it would restore a bogus/cramped width; instead `_dockedEditorSizeBeforeHide` is cleared and the stale sidebar-collapse grow snapshots (`_editorSizeGrownForSidebarHide` / `_detailWidthGrownForSidebarHide`) are dropped, so reopening falls through to the even split. This matters when the **sessions list is collapsed**: the sessions part then spans nearly the full width, so half of it is a comfortable width — reopening the side pane is a generous even split rather than the cramped node a captured `0px` (or a stale pre-collapse snapshot) would restore. +- The shared editor title's inline layout cluster orders the Hide Editor chevron before maximize/restore, followed by the detail-panel toggle. No chevron is shown while the editor is hidden; opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. +- Changes opens as a **custom `SessionChangesEditor`** (Branch Changes dropdown + diff stats header above the embedded multi-diff), and clicking a Branch Changes file always reveals that file in this multi-diff editor (ignoring `sessions.changes.openSingleFileDiff` and the Alt inversion used by the standard layout). The auxiliary bar's composite tab strip + title are hidden, and `DetailPanelController` maps the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser → hidden). Activating a File or Changes editor reveals the matching detail panel once; after that, an explicit detail-panel hide is respected until the active editor changes. Browser-driven hides are transient: switching back to File or Changes re-opens the detail panel. +- Closing the last editor tab hides both the editor content and the docked detail panel, leaving the Agents window chat-only. Opening any tab reveals the editor part again, and `DetailPanelController` restores the matching detail content for File/Changes tabs. +- While a new (uncreated) workspace session view is active, the editor content is kept hidden **continuously** so the Files detail panel and editor tab bar remain visible without showing editor content by default. The rule is **level-triggered** on the active editor + editor-part visibility (in `singlePaneDesktopSessionLayoutController.ts`): it hides the editor whenever the active editor is **not real content** (a `FileEditorInput` for a real file or a `BrowserEditorInput`), treating the managed empty landing tab (`EmptyFileEditorInput`) and "no active editor" as not real content. Because it re-reads visibility + active editor, any spurious reveal (a session-switch working-set restore, a layout race, the reveal-good-size even split) is **re-hidden** — so reopening a new session after visiting a created session keeps the editor closed. While there is no real content the width-based reveal-sync is also suppressed (`setSuppressDockedEditorRevealSync(true)`), so sidebar-collapse, grid relayout, and grid-sash drags never re-reveal the editor there. Once a real file/diff is the active editor the hide **short-circuits** (and the suppress flag clears), so a real open (via `onWillOpenEditor` → `setEditorHidden(false)`) or the detail-panel toggle reveals it and sticks. On submit, a Changes tab is added and the Changes detail is shown, but the editor content stays closed. The auto-managed Changes and File tabs never reveal the editor content. +- CSS is scoped by a `.dock-detail-panel` class on the workbench container; `:not(.dock-detail-panel)` reproduces the original grid-based styling. + --- ## 6. Feature Support @@ -262,6 +294,8 @@ Each session independently remembers whether the auxiliary bar is visible and wh **Default view on new sessions:** An untitled (new-session) session opens the side pane by default — the Files view, or the Changes view once it has changes — and that choice sticks until the user changes it. When a new session is submitted (it converts to a real session while staying active) the side pane is kept as the user left it: if it was open it stays open and switches to the Changes view so changes are visible as soon as they land; if it was closed it stays closed. +The Changes view's body is a vertical `SplitView` of File Changes, Other Files, and Checks. Other Files is the flexible middle pane: while it is expanded, File Changes is capped to its content height and Checks is capped to its checks content height, so Other Files receives the remaining space; when Other Files is hidden or collapsed, File Changes receives the remaining space after Checks reaches its content height. When File Changes has no changed files, it keeps a 140px minimum height for the empty state. + **Editor maximized:** While the editor area is maximized (`IAgentWorkbenchLayoutService.isEditorMaximized()`), the Changes view is always shown in the auxiliary bar, **irrespective of the session's previous or saved state**. This is driven directly from the auxiliary-bar sync autorun, so it holds across session changes and changes-state updates while maximized. The forced visibility is never captured as the session's per-session preference, so when the editor is un-maximized the autorun re-runs and restores the session's real auxiliary bar state. `setEditorMaximized` (in `browser/workbench.ts`) treats maximize as a fully reversible state: on entering it snapshots the editor part's size and the surrounding parts' visibility, and on exiting it restores the auxiliary bar to its pre-maximize visibility and resizes the editor part back to its captured width. Without this, the auxiliary bar that the controller forces visible while maximized would otherwise remain (and shrink the editor) after un-maximizing, so the editor would not return to its previous size. @@ -272,7 +306,7 @@ The panel (terminal / debug output) is hidden by default for all sessions. Each ### Editor Working Sets -When `workbench.editor.useModal` is not `'all'`, each session remembers which editors were open. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. +Each session remembers which editors were open, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid editor part even when other editors are forced modal (`useModal: 'all'`), so their tabs still need per-session tracking. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. A session also remembers whether its editor part was hidden (e.g. the user closed the Side Panel while keeping editors open). Restoring such a session keeps the editor part hidden rather than forcing it back open with the working set. diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 7cade126a5c..198061e98a6 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -8,7 +8,7 @@ a separate "Implementation notes" section); the code and tests reference these r | File | Spec | Rules | |------|------|-------| | `contrib/layout/browser/baseSessionLayoutController.ts` (`BaseLayoutController`) | [baseSessionLayoutController.md](contrib/layout/browser/baseSessionLayoutController.md) | `B1`–`B5` | -| `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D10` | +| `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D11` | | `contrib/layout/browser/mobileSessionLayoutController.ts` (`MobileLayoutController`) | [mobileSessionLayoutController.md](contrib/layout/browser/mobileSessionLayoutController.md) | `M1`–`M2` | The abstract `BaseLayoutController` owns the platform-agnostic mechanics (panel, editor working sets, @@ -68,6 +68,17 @@ cleared — they survive multi-session mode. Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-expand on narrow viewports. +> **Docked detail panel (experimental).** With `sessions.layout.singlePaneDetailPanel` enabled, the auxiliary +> bar is docked inside the editor part rather than being a grid column (see [LAYOUT.md](../LAYOUT.md) §5), and +> `DetailPanelController` drives which container it shows from the active editor tab. The controller here is +> unchanged and still toggles visibility via `IWorkbenchLayoutService.setPartHidden(AUXILIARYBAR_PART)`; the +> workbench fires `onDidChangePartVisibility` for the docked part so these capture/restore rules apply in both +> modes. When the setting is off, everything below applies unchanged. +> Single-pane also keeps new-session views Files-first: when an uncreated workspace session is entered, +> `SinglePaneDesktopSessionLayoutController` hides the editor content once under editor-auto-visibility +> suppression so the editor tab bar and Files detail panel remain visible. Later user reveals are respected. +> The shared new-session hide memory (`sessions.newSessionViewState`) remains unchanged. + ### 3.1 Switching away — capture `_captureViewState(previousSession)` records, for the **outgoing** session: @@ -105,7 +116,9 @@ strict priority order: When the active new session becomes created (`isCreated` changes from false to true for the same session), the side pane stays in whatever visibility state the user left it. If it is visible, the controller switches it to Changes immediately. If it is hidden, the controller records Changes as that -session's default active container so opening the side pane later shows Changes. +session's default active container so opening the side pane later shows Changes. In single-pane mode, +the submit transition keeps editor content closed: the managed Changes tab opens under editor-auto- +visibility suppression, while the visible side pane maps to the Changes detail. ### 3.4 No auto-reveal on changes @@ -143,14 +156,32 @@ workspace-less quick chat, where the Changes and Files containers are gated off `IViewDescriptorService.getViewContainersByLocation(AuxiliaryBar)` + `IViewsService.isViewContainerActive` (the same rule the workbench uses: `!hideIfEmpty || activeViewDescriptors.length > 0`). `_registerAuxiliaryBarPartVisibility` (desktop) re-checks it reactively — on container add/remove, location -moves, each container model's `onDidChangeActiveViewDescriptors` (the gating signal), and aux-bar -`onDidChangeViewContainerVisibility` — and `_syncAuxiliaryBarPartVisibility` hides the part (routing -through `_hideAuxiliaryBarForRestore` so §3.5 does not record it as a choice). It **only hides**; a -container becoming active again lets the normal restore rules (§3.2 / D8) reveal the part. The -`toggleSidePane` re-open path (§ base) guards the aux-bar un-hide with `_hasActiveAuxViewContainers()` -symmetric to `hasEditors`, and its "ensure a visible effect" fallback prefers the editor and never reveals -an empty aux bar. The `Toggle Side Panel` command is additionally **disabled** for quick chats -(`precondition: IsQuickChatSessionContext.negate()`), since a quick chat has no side pane to toggle. +moves, each container model's `onDidChangeActiveViewDescriptors` (the gating signal), aux-bar +`onDidChangeViewContainerVisibility`, and the aux-bar **part itself becoming visible** +(`onDidChangePartVisibility`) — and `_syncAuxiliaryBarPartVisibility` hides the part (routing +through `_hideAuxiliaryBarForRestore` so §3.5 does not record it as a choice). The part-visibility trigger +closes a gap: the part can become visible without any container-/descriptor-change signal firing (a bare +detail toggle that shows the column before a container opens, or a restore that shows it while its +containers are gated off), which would otherwise leave the toggle/context key reading "on" over a blank +panel. The empty-part hide runs under `suppressEditorPartAutoVisibility()` so reconciling away an empty +column never, as a side effect, pops the editor open (editor visibility stays governed by §3.2 / D8). It +**only hides**; a container becoming active again lets the normal restore rules (§3.2 / D8) reveal the +part. Symmetrically, the docked host (`setAuxiliaryBarHidden`) never force-opens a `hideIfEmpty` container +with no active views when the aux bar is shown, so a show can never present a blank docked panel. Together +these guarantee the invariant: **in single-pane docked mode `partVisibility.auxiliaryBar` (⇒ +`AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true iff the docked detail panel is rendered with an +active view container.** In single-pane +detail-panel mode, a Browser tab can hide the part transiently, but switching back to Changes re-opens it, +and activating a File or Changes editor reveals the matching detail panel once while respecting later +explicit hides for the same active editor. When the main editor part has no tabs, the docked detail panel is +hidden with the editor so the whole side pane closes to chat-only; opening a tab restores it through the +normal editor-open and active-tab detail mapping. The detail-panel toggle reveals editor content when it hides the +detail from an editor-hidden state; the `toggleSidePane` re-open path +(§ base) guards the aux-bar un-hide with +`_hasActiveAuxViewContainers()` symmetric to `hasEditors`, and its "ensure a visible effect" fallback +prefers the editor and never reveals an empty aux bar. The `Toggle Side Panel` command is additionally +**disabled** for quick chats (`precondition: IsQuickChatSessionContext.negate()`), since a quick chat has +no side pane to toggle. --- @@ -170,8 +201,11 @@ session (suppressed while multiple sessions are visible). ## 5. Editor Working Sets -Active only when `workbench.editor.useModal` is **not** `'all'` (editors live in the grid editor -part rather than as modal overlays). Driven by `_useModalConfigObs`. +Always active, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid +editor part even when editors are otherwise forced modal (`useModal: 'all'`) — they except themselves +from the modal part — so their tabs still need per-session capture/restore. `_useModalConfigObs` is +consulted only inside `_applyWorkingSet`, to decide whether to auto-reveal the editor part on switch +(skipped in modal mode, since modal editors manage their own visibility). ### 5.1 Workspace-folder ordering @@ -250,7 +284,18 @@ does, causing the aux bar to fall back to the default-visible logic (§3.2) on t - Working-set save/apply waits for **workspace folders** to catch up with the active session. - **An empty auxiliary bar is hidden (desktop, [D10])** — when the aux bar has no active view container (e.g. a workspace-less quick chat where Changes/Files are gated off), the `AUXILIARYBAR_PART` is kept - hidden instead of showing an empty column, updating reactively as the active session flips. The - controller only hides an empty aux bar (reveals stay with D3/D8), and **Toggle Side Panel** only - reveals the part that has content — never an empty aux bar, and is **disabled entirely for quick chats** - (`IsQuickChatSessionContext.negate()`). + hidden instead of showing an empty column, updating reactively as the active session flips — including + when the part itself becomes visible (a bare toggle / restore that shows the column before a container + opens), so the detail toggle never reads "on" over a blank panel. The empty-part hide runs under + `suppressEditorPartAutoVisibility()` so it never resurrects the editor as a side effect, and the docked + host never force-opens a `hideIfEmpty` container with no active views. The controller only hides an empty + aux bar (reveals stay with D3/D8), and **Toggle Side Panel** only reveals the part that has content — + never an empty aux bar, and is **disabled entirely for quick chats** + (`IsQuickChatSessionContext.negate()`). Invariant: `partVisibility.auxiliaryBar` + (⇒ `AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true iff the docked detail panel is rendered with + an active view container. +- **Single-pane new-session views are Files-first (desktop, [D11])** — when an uncreated workspace + session is entered in single-pane mode (single session visible, not maximized, not a quick chat), the + editor content is hidden once under `suppressEditorPartAutoVisibility()`. D3b keeps the Files detail + panel active unless the shared new-session side pane state says it is hidden; later user editor reveals + are respected until the controller exits and re-enters a new-session resource. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 60ed73bb4ea..e62d3b9a347 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -154,11 +154,11 @@ In the agent host, the real producer of read-only chats is **subagent (worker) c Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session. -**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The pill is inserted at the **start** of the subagent header row (before the streaming title) so it keeps a fixed position instead of shifting as the title grows; its label reactively shows the **subagent chat's own title** (resolved from the forwarded chat resource via `findSubagentChat`), so in the Agents window the duplicate inline header title is hidden (single chip — the pill is only contributed there, so the CSS is scoped to `.agent-sessions-workbench`), with the subagent's **agent name** (e.g. "General-purpose", "Task"; forwarded on the toolbar context, falling back to "Subagent") rendered as a prefix before the pill. The pill itself is a standalone chip styled like the chat file/diff pill (`chat-codeblock-pill-widget`) — a colorless, bordered chip rather than a filled button (`OpenSubagentChatActionViewItem` extends `BaseActionViewItem` and renders its own DOM, avoiding the meta-button's inline-style foreground that CSS can't override) — with a leading conversation icon by default, swapped for a **spinner** while the subagent is still running, driven purely by CSS from the `chat-thinking-active` class the chat widget toggles on the enclosing `.chat-subagent-part` (see `media/openSubagentChat.css`). The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes the plain-string command `CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID` (`workbench.action.chat.openAgentHostChat`) with the subagent chat URI; the sessions layer registers the handler (`openSubagentChat.ts`), which derives the chatId from the URI (handling the AHP, synthetic-fragment, and backend-path forms), finds the matching surfaced peer across the visible sessions, and calls `sessionsService.openChat` to activate the tab. The command no-ops when no handler is registered (e.g. the widget hosted outside the Agents window). +**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The pill is inserted at the **start** of the subagent header row (before the streaming title) so it keeps a fixed position instead of shifting as the title grows; its label reactively shows the **subagent chat's own title** (resolved from the forwarded chat resource via `findSubagentChat`), so in the Agents window the duplicate inline header title is hidden (single chip — the pill is only contributed there, so the CSS is scoped to `.agent-sessions-workbench`), with the subagent's **agent name** (e.g. "General-purpose", "Task"; forwarded on the toolbar context, falling back to "Subagent") rendered as a prefix before the pill. The pill itself is a standalone chip styled like the chat file/diff pill (`chat-codeblock-pill-widget`) — a colorless, bordered chip rather than a filled button (`OpenSubagentChatActionViewItem` extends `BaseActionViewItem` and renders its own DOM, avoiding the meta-button's inline-style foreground that CSS can't override) — with a leading conversation icon by default, swapped for a **spinner** while the subagent is still running. The running state is driven by the pill's own `chat-subagent-running` class, which `OpenSubagentChatActionViewItem` toggles reactively from the resolved subagent chat's own `SessionStatus.InProgress` status (the same `findSubagentChat` autorun that resolves the title); the enclosing `.chat-subagent-part`'s `chat-thinking-active` class is kept only as a CSS fallback because the spawning tool call completes as soon as the subagent is dispatched, so it stops early while the worker keeps running (see `media/openSubagentChat.css`). The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes the plain-string command `CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID` (`workbench.action.chat.openAgentHostChat`) with the subagent chat URI; the sessions layer registers the handler (`openSubagentChat.ts`), which derives the chatId from the URI (handling the AHP, synthetic-fragment, and backend-path forms), finds the matching surfaced peer across the visible sessions, and calls `sessionsService.openChat` to activate the tab. The command no-ops when no handler is registered (e.g. the widget hosted outside the Agents window). **Restoring subagent chats.** Subagent chats are in-memory only; on restart the agent host restores them as separate sessions but no longer re-adds them to the parent catalog. `AgentService._registerRestoredSubagent` mirrors the live `_handleSubagentStarted` flow on restore — it re-adds the subagent to the parent session's catalog (same `ahp-chat://subagent/...` chat URI, `origin: Tool`, `interactivity: ReadOnly`, restored turns) so it reappears as a read-only tab. -**Subagents dropdown.** `SessionAgentsControl` (`contrib/chat/browser/sessionAgentsControl.ts`), mounted by `ChatView` directly **above the chat input** (as the first child of the input part, above the session banners), is a **"Subagents"** dropdown (comment-discussion icon) that lists the subagents spawned by the **currently-viewed** chat. Activating it opens a context menu of those subagents; selecting one opens that read-only subagent chat via `sessionsService.openChat`. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin now carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`). The control hides when the active chat has no subagents. +**Subagents in the Conversations menu.** Subagents spawned by the **currently-active** chat are shown as a separate group (`2_subagents`) at the bottom of the **Conversations** submenu, below the session's regular chats (`1_chats`); a separator divides the two groups. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`) — so the group changes as the active chat changes. Selecting a subagent entry toggles its read-only tab open/closed like any other chat entry. The entries are populated per session by `SessionConversationsMenuContribution` (only when the active chat has subagents). The chat tab strip is shown as soon as the session has any subagent (`IActiveSession.shouldShowChatTabs`), so the Conversations menu surfaces in the tab bar; `SessionActiveChatHasSubagentsContext` also keeps the menu available even when the parent is the only committed chat. Read-only is honored on both rendering paths: `SessionView` only routes an `Untitled` chat to the editable new-chat composer (`NewChatView`) when the chat is also `Full` — a non-interactive chat always uses the standard `ChatView` (whose `setReadOnly(true)` hides the input). Without this guard a freshly-added read-only peer chat (which is briefly `Untitled`) would surface the new-chat composer and remain editable. @@ -190,7 +190,7 @@ A **quick chat** is a workspace-less session — one that is not scoped to any f The contract is small and provider-agnostic: -- **`ISessionsProvider.supportsQuickChats`** (optional `boolean`) — whether the provider can mint quick chats. It may change at runtime (e.g. when agent-host enablement toggles); providers signal such changes via the optional **`onDidChangeCapabilities`** event so consumers re-evaluate. Only the local agent-host provider sets it, and only while the agent host is enabled. +- **`ISessionsProvider.supportsQuickChats`** (optional `boolean`) — whether the provider can mint quick chats. Providers that truly change capabilities at runtime can signal that via the optional **`onDidChangeCapabilities`** event. The local agent-host provider snapshots `chat.agentHost.enabled` at startup, so its value is stable for the life of the provider instance. - **`ISessionsProvider.createQuickChat(sessionTypeId)`** — required when `supportsQuickChats` is `true`. Returns an untitled draft (like `createNewSession`) that is not added to the session list until the first request is sent. - **`ISessionsManagementService.createQuickChat(options?)`** — selects the first quick-chat-capable provider (honouring `order` and `options.providerId`), resolves the session type from `options.sessionTypeId` or the last-used / first advertised type, persists the resolved type as last-used, and mints a new quick-chat session **per call** (New Quick Chat = new session). - **`ISessionsManagementService.getQuickChatSessionTypes()`** — every session type advertised by quick-chat-capable providers, for the inline composer type picker. @@ -550,6 +550,8 @@ Backend state change (turn complete, status update, etc.) Providers may fire `onDidReplaceSession` when a temporary (untitled) session is atomically replaced by a committed one after the first turn. +Provider add notifications are authoritative upserts. A provisional `listSessions()` entry may already be cached when the backend publishes its materialized project and working directory, so providers update the existing session adapter in place and report it as changed rather than replacing its identity. + --- ## Adding a New Provider diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 1ccd533d6b8..46eea385d3a 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -34,6 +34,8 @@ Each session row displays: - **Status description or timestamp** — InProgress/NeedsInput/Error show a status message; otherwise a relative timestamp - **Approval row** (optional) — pending agent approvals with an "Allow" button +`SessionsFlatList` reuses the same session row renderer for sectionless surfaces, including the approval row and dynamic row height updates. Consumers that size their own container listen for content-height changes and relayout the list. When embedded inside another hover, consumers disable row hovers so moving over the list does not replace the parent hover. + ### Grouping Sessions are organized into sections with fixed priority: @@ -46,7 +48,7 @@ Sessions are organized into sections with fixed priority: 5. Done/Archived ← always last, not reorderable ``` -The **Chats** section holds workspace-less quick-chat sessions, detected via the `isQuickChatSession(session)` helper (which reads the session's own `ISession.isQuickChat` observable — **not** `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too). It renders **inside the Sessions list directly below the Pinned section** (above the workspace/date groups) in **both** grouping modes — quick chats are neither a workspace nor a date bucket, so they are partitioned out of workspace/date grouping and rendered as their own entry right after Pinned. The section is **always visible** (even with no quick chats) whenever a provider advertises `supportsQuickChats`, so its create affordance is always reachable. Both the Pinned and Chats section headers carry a **leading icon** (`Codicon.pinned` for Pinned, `Codicon.commentDiscussion` for Chats) and share the standard section-header font/styling (the two headers look consistent — no prominent top-title variant). The Chats header shows the chat icon, the label "Chats", and a **"+" New Quick Chat** action in its section toolbar (also bound to **Cmd+K Cmd+N**) — the *only* create affordance for quick chats (Cmd+N always creates a new **session**, not a quick chat; there is no quick-chat action in the top Sessions header). "Mark All as Done" is not offered on the Chats section. When the section has **no quick chats**, it shows a muted, centered **"No chats" placeholder row** (a synthetic non-session list item, like the "show more" rows) instead of an empty section. A pinned quick chat still appears in Pinned (pin wins), and an archived one still goes to Done (archive wins). Quick-chat rows use a comment/chat icon instead of the folder/worktree/cloud workspace icon and carry no workspace badge. That per-row chat icon is **suppressed when the row renders under the Chats section** (whose header already carries a chat icon) and shown only where the chat identity is useful — a quick chat pinned to Pinned or moved into a custom group. +The **Chats** section holds workspace-less quick-chat sessions, detected via the `isQuickChatSession(session)` helper (which reads the session's own `ISession.isQuickChat` observable — **not** `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too). It renders **inside the Sessions list directly below the Pinned section** (above the workspace/date groups) in **both** grouping modes — quick chats are neither a workspace nor a date bucket, so they are partitioned out of workspace/date grouping and rendered as their own entry right after Pinned. The section is **always visible** (even with no quick chats) whenever a provider advertises `supportsQuickChats` — subject to the `sessions.list.showEmptyDefaultGroups` setting (default `true`; when `false` the empty Pinned and Chats sections are hidden). Both the Pinned and Chats section headers carry a **leading icon** (`Codicon.pinned` for Pinned, `Codicon.commentDiscussion` for Chats) and share the standard section-header font/styling (the two headers look consistent — no prominent top-title variant). The Chats header shows the chat icon, the label "Chats", and a **"+" New Quick Chat** action in its section toolbar (also bound to **Cmd+K Cmd+N**) — the *only* create affordance for quick chats (Cmd+N always creates a new **session**, not a quick chat; there is no quick-chat action in the top Sessions header). "Mark All as Done" is not offered on the Chats section. When the section has **no quick chats**, it shows a muted, centered **"No chats" placeholder row** (a synthetic non-session list item, like the "show more" rows) instead of an empty section. A pinned quick chat still appears in Pinned (pin wins), and an archived one still goes to Done (archive wins). Quick-chat rows use a comment/chat icon instead of the folder/worktree/cloud workspace icon and carry no workspace badge. That per-row chat icon is **suppressed when the row renders under the Chats section** (whose header already carries a chat icon) and shown only where the chat identity is useful — a quick chat pinned to Pinned or moved into a custom group. Each quick chat is its **own single-chat session** (New Quick Chat = a new session per create), so it occupies one list row like any other session — there are no chat-level (`IChat`) rows. A quick chat is pinned/grouped/archived as a whole session: a pinned quick chat appears in Pinned (pin wins), an archived one goes to Done (archive wins). The earlier "single quick-chat container session whose peer `IChat`s become their own rows" model was descoped. @@ -93,7 +95,7 @@ A built-in find widget filters the list by session title and section label. When ### Pinning -Pinned sessions appear in a dedicated "Pinned" section at the top. The section is **always visible** (even with no pinned sessions), mirroring the "Chats" section; when empty it shows a muted, centered **"No pinned sessions" placeholder row**. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). +Pinned sessions appear in a dedicated "Pinned" section at the top. The section is **always visible** (even with no pinned sessions), mirroring the "Chats" section — subject to the `sessions.list.showEmptyDefaultGroups` setting (default `true`; when `false` the empty Pinned/Chats sections are hidden). When empty it shows a muted, centered **"No pinned sessions" placeholder row**. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). The **Pinned** and **Chats** sections start **collapsed on first open** (their default collapse state is `PreserveOrCollapsed` when no saved state exists). Once the user expands or collapses either section, that choice is persisted per-section under `sessionsListControl.sectionCollapseState` and honored on subsequent loads. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md new file mode 100644 index 00000000000..8c1a0e8423b --- /dev/null +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -0,0 +1,237 @@ +# Single-Pane Detail Panel — Scenarios + +This document enumerates the user-facing scenarios, states, and transitions for the **single-pane +detail panel** layout of the Agents window (the third pane redesigned as one pane with a single tab +bar spanning the editor content and a docked detail panel). + +- The whole feature is gated behind the experimental setting **`sessions.layout.singlePaneDetailPanel`** + (const `DOCK_DETAIL_PANEL_SETTING`), read **once at startup** — a window reload applies a change. +- When the setting is **OFF** (default), the Agents window renders exactly as before (auxiliary bar as + its own grid column with its composite tab strip; the standard multi-diff Changes editor). Nothing in + this document applies. +- Companion specs: [LAYOUT.md](LAYOUT.md) §5, [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md), and + [contrib/layout/browser/desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md). + +--- + +## 1. The three regions + +The third pane is a single visual card containing three regions: + +| Region | What it is | Owner | +|--------|-----------|-------| +| **Tab bar** | One tab strip spanning the full width (Changes / File / Browser tabs + trailing `+`) | Editor group title (`MainEditorPart` / `EditorGroupView`) | +| **Editor content** | The editor pane below the tab bar (multi-diff Changes, a file, a browser) | Editor part, inset on the right by the detail width | +| **Detail panel** | The docked auxiliary bar on the right (Branch Changes + Checks, or Explorer) | `DockedAuxiliaryBarController` (docks the aux bar inside the editor part) | + +**Invariant:** the **tab bar is always visible** whenever the pane is shown — including when the editor +content is hidden and in the new-session view. It is kept laid out by `MainEditorPart.layout`'s +`keepForDockedTabBar` path (single-pane + detail visible), even while the editor part is logically +hidden. + +--- + +## 2. Pane visibility states + +Let **E** = editor content visible, **D** = detail panel visible. The pane supports: + +| State | E | D | Meaning | +|-------|---|---|---------| +| **Editor + Detail** | ✅ | ✅ | Normal working state: editor content on the left, detail on the right, tab bar across the top. | +| **Detail only** | ❌ | ✅ | Editor content collapsed (Hide Editor); tab bar + detail shown; the chat reclaims the freed editor width. The detail **keeps its width** (it does not stretch to fill the pane). | +| **Editor only** | ✅ | ❌ | Detail toggled off; editor content fills the pane; tab bar across the top. **This is the default state for a created session** — opening the side pane shows the Changes editor with the detail panel closed; the detail is opened only via **Toggle Details** (or restored per-session). | +| **Side pane closed** | ❌ | ❌ | The whole third pane is closed (chat-only). Reached via **Toggle Side Panel** or when the last editor tab closes; never via the detail toggle. | + +A created session opens the side pane to **Editor only** (Changes editor, detail closed) by default; a Changes/file editor becoming active never force-opens the detail (the one exception is restoring the detail after a transient browser-tab hide). A new-session view opens to the **Files detail** (its editor content stays hidden by R1). + +**Size distribution when opening the side pane.** Opening the side pane from *closed* (e.g. clicking +**Changes** while the chat is full-width) gives it a comfortable **~even split** with the chat, so the +editor content is readable beside the detail — never the collapsed detail-only width. This applies on +**every** such reveal that has no user-chosen width to restore (not just the first in a window): +hiding the editor collapses its grid node to the detail width and the grid caches that, so a later +reveal — including in a different session — must re-apply the even split rather than restore the narrow +cached width. A width the user **deliberately set** (captured on hide as `_dockedEditorSizeBeforeHide`) +always takes precedence and is restored as-is. + +**Reopening after the sessions list is collapsed.** Closing the **whole** side pane collapses the editor +grid node to `0px`, so its size at that moment is **not** a real user width — closing the whole pane +therefore does **not** capture `_dockedEditorSizeBeforeHide` (and clears any stale sidebar-collapse grow +snapshots). This matters when the **sessions list is collapsed**: reopening the side pane falls through to +the **even split**, and because the collapsed list makes the sessions part span nearly the full width, half +of it is a **comfortable** width — not the cramped/narrow node that a captured `0px` (or a stale +pre-collapse snapshot) would otherwise restore. Only **Hide Editor** (detail stays visible, node stays +visible at a real width) captures a width to restore later. + +--- + +## 3. Controls + +| Control | Location | Effect | +|---------|----------|--------| +| **Hide Editor** (chevron `>`) | Editor title bar, primary inline, **before** Maximize | Closes the editor content, keeps the detail (→ *Detail only*). The docked side pane shrinks to the detail width so the freed editor width goes to the **chat** (not the detail), and the **sessions list is reshown** (it may have been auto-collapsed when details was opened). Shown **only** when the active tab is **Changes or Files** (not Browser). Hidden when the editor is already closed, and hidden while the editor area is **maximized**. | +| **Toggle Details** (`≡`) | Editor title bar, primary inline, after Maximize | Shows/hides the detail panel. Hiding the detail **while the editor is hidden reveals the editor** (→ *Editor only*), so the pane is never left empty — this applies in the **new-session view** too (revealing the empty editor rather than closing the whole pane). Opening the detail panel via this action auto-collapses the **sessions list** to free width for the editor area; closing it restores the sessions list. Its `toggled` state (`AuxiliaryBarVisibleContext`) is kept **in sync with the actual rendering**: the toggle reads "on" iff the detail panel is rendered with an active view container — an empty (gated-off) container is never shown, and the layout controller (D10) reconciles the part away if it becomes visible with nothing to render. | +| **Maximize / Restore** | Editor title bar, primary inline | Maximizes the editor area (forces the Changes detail while maximized; restores on un-maximize). | +| **Collapse All Diffs** | Changes editor header, primary inline | Collapses every file in the Changes multi-diff (`SessionChangesEditor.collapseAllDiffs`). | +| **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (New File `⌘K B`, New Browser `⇧⌘K B`). **Hidden when the editor area is closed.** | +| **Toggle Side Panel** | Command / keybinding | Closes/opens the **whole** side pane (editor + detail together) → chat-only and back. | +| **Toggle Sessions List** | Title bar / command | Collapses/opens the left sessions list. Collapsing it gives the freed width to the editor/detail side pane (not the chat); reopening restores the previous editor/detail width so the chat gets that space back. The list is **also** auto-collapsed when the user opens the detail panel via **Toggle Details**, or when they open a real file/diff into the editor area **in an existing (created) session while the editor area is currently closed** (and restored when they close it), unless the user has since reopened it manually. | +| **Grid sash** | Between the chat and the third pane | In a **created** session, dragging it wider re-reveals the editor content and re-syncs state (the Hide Editor chevron reappears); dragging it narrow enough that the editor content is squeezed to the detail width **hides** the editor content (mirroring the reveal), which hides all editor-title actions. In the **new-session** view a width reveal is momentary — R1 re-hides the editor, which stays closed until a file is opened. | + +**Editor-title action visibility.** All single-pane editor-title actions (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal) are hidden while the **editor area is closed** (`MainEditorAreaVisibleContext`). Hide Editor is additionally shown only when the active tab is **Changes or Files** (`SinglePaneDetailChangesOrFilesActiveContext`) and only while the editor area is **not maximized** (`EditorMaximizedContext` negated). + +**Managed Files tab.** The empty Files placeholder tab is shown only when the editor area is **closed** or **no real (non-managed) editor is open**; once a real file/diff is opened into a visible editor area it is removed as redundant, and re-added when the editor area closes again. + +**Closing managed tabs.** The user can close the managed Changes and Files tabs (they are non-preview, not sticky). A user-initiated close is remembered (`_dismissedManagedTabs`) so the controller does not immediately re-create it; the dismissal is cleared — and the tabs re-populate — on a **session change** or when the **side pane is reopened** from fully closed. + +**Per-session detail state.** A created session's detail-panel (aux-bar) visible/hidden choice is captured per session and restored on switch-back (a detail-closed session stays detail-closed when returning to it), even if an external component transiently reveals the aux bar during the working-set restore. + +**Reopening after closing all tabs.** Closing all tabs closes the whole side pane; the managed Changes (created) / Files (new-session) tabs are re-ensured, so reopening the side pane shows the Changes editor or Files tab — never an empty editor. + +**Side-pane-closed persists across reload.** Closing the whole side pane is remembered across a window reload. On reload the restored managed tab does **not** re-reveal the detail: the detail-panel forced reveal is gated on the editor content being visible, so a fully-closed side pane stays closed until the user reopens it. + +**Opening a file.** The **New File** add-tab entry opens its tab **pinned** (not a preview tab). + +Actions **not** present in single-pane mode: **Close Editor Area**, **Show Editor** (the standard +layout keeps *Close Editor Area*). + +--- + +## 4. Tabs + +- **Changes** — a custom `SessionChangesEditor` (Branch Changes dropdown + diff stats + embedded + multi-diff). Pinned first, present for **created** sessions with a workspace. +- **File** — the empty File tab (`EmptyFileEditorInput`) as a landing tab, plus real file editors the + user opens. Opened **pinned, inactive, preserve-focus** so it never steals focus from the chat. +- **Browser** — the integrated browser (`BrowserEditorInput`). + +The **auto-managed** tabs (the pinned Changes tab and the default File tab) are opened under +`suppressEditorPartAutoVisibility()` — they **never reveal the editor content**. Only a user action +(opening an actual file/diff, or dragging the sash) reveals the editor. + +--- + +## 5. Detail panel content (driven by the active tab) + +The single-pane layout controller (`SinglePaneDesktopSessionLayoutController`) maps the active editor tab to the detail content. By default the detail panel is **closed** for a created session (Editor-only); it is opened via **Toggle Details** (or restored per-session), and while visible its container follows the active tab (the one exception is restoring the detail after a transient browser-tab hide): + +| Active tab | Detail panel | +|-----------|--------------| +| **Changes** | Branch Changes file list + Checks — shown (Changes container) while the detail is visible | +| **File** (Explorer) | Files/Explorer tree — shown (Files container) while the detail is visible | +| **Browser** | **Hidden** (transiently) while the Browser tab is active; restored when switching back | + +Rules: +- **Reveal on activate, respect after.** Switching to a Changes/File tab reveals the detail with the + right container. While the **same** tab stays active, an explicit user hide of the detail (via the + detail toggle) is **respected** — it is not re-forced. Switching tabs reveals it again. +- **Browser is transient.** A Browser tab hides the detail panel; switching back to Files/Changes + **restores** it. + +--- + +## 6. Layout rules (new-session lifecycle) + +### R1 — New-session (uncreated) view +When the new-session composer is active (uncreated session, has a workspace, not a quick chat): +- **Initial state:** **File tab** active + **Files detail** open + **editor content closed** (*Detail + only*). Tab bar visible. The composer keeps focus (the File tab is inactive/preserve-focus). +- The editor is kept hidden while this view is active, but the hide is **transition-triggered**: it fires + when the editor **just became visible**, or when the new-session view was **just entered** with the editor + already visible (an inherited-visible editor from the previous session) — where *real content* is a real + file (`FileEditorInput`) or the integrated browser (`BrowserEditorInput`); the managed empty landing tab + (`EmptyFileEditorInput`) and "no active editor" are **not** real content. Any **spurious reveal** (a + session-switch working-set restore, a layout race, the reveal-good-size even split) is **re-hidden** — + fixing the case where reopening a new session after visiting a created session left the editor open. + Crucially, **switching to a managed tab (e.g. the Files placeholder) while the editor is already visible + does NOT hide it** — only a visibility transition or entering the view does, so the user can keep the + editor open and switch tabs. R1 wins in the new-session view: the editor stays closed until the user + **explicitly opens a file/diff**. A width-based reveal (e.g. a sash drag) may momentarily reveal the + editor, but R1 re-hides it (it was a non-explicit reveal). Once a real file is the active editor the hide + **short-circuits**, so a user action that reveals the editor via a real editor open **sticks**: + - **Opening a file** from the Files view → editor content shows (via `onWillOpenEditor` → + `setEditorHidden(false)`) (→ *Editor + Detail* or *Editor only*). + - **Detail toggle** → reveals the editor (→ *Editor only*). + - **Sash drag** in the new-session view does **not** keep the editor revealed (the sash-reveal sticks for + *created* sessions only); R1 re-hides it. +- **Collapsing the sessions list** while the editor is closed gives the freed width to the **detail + panel** (not the editor node), keeping the editor node width equal to the detail width so it is never + mistaken for a revealed editor. Reopening the sessions list restores the pre-collapse detail width. + +### R2 — New session submitted (uncreated → created) +When the new session is submitted: +- A **Changes tab** is added and the **Changes detail** is shown. +- The **editor content stays closed** (*Detail only*) — neither the submit nor the auto-opened Changes + editor reveals it. The user opens the editor when they want it (open a file/diff, or drag the sash). + +### Quick chats / no workspace +No side pane at all — the detail panel and managed tabs are not shown; the chat is full-width. + +--- + +## 7. Transition matrix (single-session, not maximized) + +| From | Action | To | +|------|--------|-----| +| — | Enter new-session view | *Detail only* (File tab + Files detail, editor closed) | +| *Detail only* (new session) | Open a file from Files | *Editor + Detail* (editor revealed, stays open) | +| *Detail only* (new session) | Toggle Details (hide detail) | *Editor only* (empty editor revealed — the side pane does not vanish) | +| *Detail only* (new session) | Drag grid sash wider | *Detail only* (editor stays closed; a momentary width reveal is re-hidden by R1 in the new-session view) | +| *Detail only* (new session) | Toggle Sessions List closed | *Detail only*; the **detail panel** widens by the sessions-list width (editor stays closed) | +| *Detail only* | Toggle Details (hide detail) | *Editor only* (editor revealed) | +| *Editor + Detail* | Hide Editor chevron | *Detail only* (detail keeps width, chat expands) | +| *Editor + Detail* | Toggle Details (hide detail) | *Editor only* | +| *Editor only* | Toggle Details (show detail) | *Editor + Detail* | +| *Detail only* / *Editor only* / *Editor + Detail* | Toggle Side Panel | *Side pane closed* | +| *Side pane closed* | Toggle Side Panel | previous state restored | +| editor/detail side pane visible | Toggle Sessions List closed | same pane state; editor/detail side pane widens by the sessions-list width | +| sessions list closed after side-pane growth | Toggle Sessions List open | same pane state; editor/detail side pane returns to its pre-collapse width | +| any | Close the last editor tab | *Side pane closed* (chat-only; opening a tab restores the pane) | +| *Detail only* (created session) | Drag grid sash wider | *Editor + Detail* (editor content re-revealed) | +| any | Activate **Browser** tab | detail hidden (transient) | +| Browser active (detail hidden) | Activate **Files/Changes** tab | detail restored | +| new-session *Detail only* | **Submit** the session | *Detail only* + Changes tab + Changes detail | + +--- + +## 8. Manual validation checklist + +1. **New session view:** File tab + Files detail open + **no editor content**; tab bar visible; the + "What are you building?" composer keeps focus. +2. **Open a file** from the Files view in the new-session view → the editor content appears and stays. +3. **Detail toggle** in the new-session view → the editor content appears (detail hides). +4. **Submit** a new session → a Changes tab appears with the Changes detail; the editor content is + **still closed**. +5. **Hide Editor** chevron → editor content closes, detail **keeps its width**, chat expands, tab bar + stays; the chevron then hides. +6. **Detail toggle** from *Editor + Detail* → detail hides, editor stays (*Editor only*); toggle again + → detail returns. +7. **Toggle Side Panel** → the whole side pane closes (chat-only); toggle again → it restores. +8. **Browser tab** → detail hides; switch back to Files/Changes → detail restores. +9. **File tab** active → the Explorer detail is shown (revealed on activation). +10. **Close the last editor tab** → the whole side pane closes (chat-only); opening any tab restores it. +11. **`+` button** hidden while the editor area is closed; reappears when the editor is open. +12. **Sash drag** to widen the third pane in a **created** session while the editor is closed → editor + content re-reveals and the Hide Editor chevron reappears; hiding the editor never leaves a + corrupted/overlapping layout. In the **new-session** view the same drag widens the detail panel and + the editor stays closed. +13. **Toggle Sessions List** while the side pane is visible → when the editor content is visible the + editor/detail pane widens by the sessions-list width; when the editor is closed (new-session / + detail-only) the **detail panel** widens instead and the editor stays closed. Toggle it back → the + pane returns to its previous width and the chat regains the space. +14. **Setting OFF** → the Agents window is the original layout, unchanged. + +--- + +## 9. Where it lives (implementation map) + +| Concern | File | +|---------|------| +| Docked layout, hide/show editor, detail width, sash-reveal sync, grid | `browser/workbench.ts` | +| Docked panel overlay + resize sash | `browser/dockedAuxiliaryBarController.ts` | +| Editor tab bar kept visible when content hidden; sash-reveal trigger | `browser/parts/editorPart.ts` | +| Active tab → detail container mapping (browser transient) | `contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts` | +| Managed Changes + File tabs (suppressed opens) | `contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts` | +| Startup controller selection | `contrib/layout/browser/sessions.layout.contribution.ts` | +| New-session transition-triggered editor hide (R1) | `contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts` | +| Hide Editor chevron, Maximize, add-tab actions | `contrib/editor/browser/editor.contribution.ts`, `contrib/editor/browser/addTabActions.ts` | +| Toggle Details command + editor-title item | `contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts` | diff --git a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts new file mode 100644 index 00000000000..fddc68d7f01 --- /dev/null +++ b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { ISashEvent, IVerticalSashLayoutProvider, Sash, SashState, Orientation as SashOrientation } from '../../base/browser/ui/sash/sash.js'; +import { Part } from '../../workbench/browser/part.js'; + +/** Accessors the controller uses to read/write the docked panel width and query visibility. */ +export interface IDockedAuxiliaryBarHost { + getWidth(): number; + setWidth(width: number): void; + /** Whether the editor area (editor or docked aux bar) is visible. */ + isEditorAreaVisible(): boolean; + /** Whether the editor part itself is visible, excluding the docked aux bar. */ + isEditorVisible(): boolean; + /** Whether the docked auxiliary bar (detail panel) is visible. */ + isAuxiliaryBarVisible(): boolean; + /** + * Reserves an inset (px) on the right of the editor content while the editor + * tab bar keeps the full width, so the docked panel can sit beside it. `0` + * restores full-width content. + */ + setEditorContentRightInset(px: number): void; +} + +/** + * Owns the single-pane "docked detail panel" behaviour: reparenting the auxiliary + * bar into the editor part as an absolutely-positioned overlay on the right (below + * the editor tab strip), sizing it, insetting the editor content, and the draggable + * resize sash. Created by the workbench only in single-pane mode; the standard + * layout never constructs it. + */ +export class DockedAuxiliaryBarController extends Disposable { + + static readonly TOP = 34; + static readonly MIN_WIDTH = 220; + static readonly EDITOR_MIN_WIDTH = 300; + static readonly DEFAULT_WIDTH = 300; + + private _docked = false; + private _sash: Sash | undefined; + private _sashStartWidth = 0; + + constructor( + private readonly editorPartContainer: HTMLElement, + private readonly auxiliaryBarPart: Part, + private readonly host: IDockedAuxiliaryBarHost, + ) { + super(); + } + + /** + * Position the auxiliary bar inside the editor part's right region so the editor + * tab bar spans the full width across the editor content and the detail panel. + */ + layout(): void { + const auxiliaryBarContainer = this.auxiliaryBarPart.getContainer(); + if (!auxiliaryBarContainer) { + return; + } + + // Reparent the auxiliary bar into the editor part once, as an absolutely + // positioned overlay on the right that moves with the editor part. + if (!this._docked) { + this.editorPartContainer.appendChild(auxiliaryBarContainer); + auxiliaryBarContainer.classList.add('docked-auxiliarybar'); + this._docked = true; + } + + if (!this.host.isEditorAreaVisible() || !this.host.isAuxiliaryBarVisible()) { + auxiliaryBarContainer.style.display = 'none'; + this.host.setEditorContentRightInset(0); + if (this._sash) { + this._sash.state = SashState.Disabled; + } + return; + } + + const editorRect = this.editorPartContainer.getBoundingClientRect(); + const editorContentHidden = !this.host.isEditorVisible(); + const auxWidth = editorContentHidden ? editorRect.width : this._auxiliaryBarWidth(this.host.getWidth(), editorRect.width); + const top = DockedAuxiliaryBarController.TOP; + const height = Math.max(0, editorRect.height - top); + + auxiliaryBarContainer.style.display = ''; + auxiliaryBarContainer.style.position = 'absolute'; + auxiliaryBarContainer.style.right = '0'; + auxiliaryBarContainer.style.top = `${top}px`; + auxiliaryBarContainer.style.width = `${auxWidth}px`; + auxiliaryBarContainer.style.height = `${height}px`; + + this.host.setEditorContentRightInset(auxWidth); + this.auxiliaryBarPart.layout(auxWidth, height, top, editorRect.width - auxWidth); + + if (editorContentHidden) { + if (this._sash) { + this._sash.state = SashState.Disabled; + } + } else { + this._ensureSash(); + this._sash!.state = SashState.Enabled; + this._sash!.layout(); + } + } + + private _auxiliaryBarWidth(hostWidth: number, editorWidth: number): number { + const maxWidth = editorWidth - DockedAuxiliaryBarController.EDITOR_MIN_WIDTH; + // When the editor is too narrow, the detail panel yields instead of enforcing its minimum. + if (maxWidth < DockedAuxiliaryBarController.MIN_WIDTH) { + return Math.max(0, maxWidth); + } + + return Math.max(DockedAuxiliaryBarController.MIN_WIDTH, Math.min(hostWidth, maxWidth)); + } + + private _ensureSash(): void { + if (this._sash) { + return; + } + + const editorPartContainer = this.editorPartContainer; + const layoutProvider: IVerticalSashLayoutProvider = { + getVerticalSashLeft: () => { + const width = editorPartContainer.clientWidth; + const auxWidth = this._auxiliaryBarWidth(this.host.getWidth(), width); + return Math.max(0, width - auxWidth); + }, + getVerticalSashTop: () => DockedAuxiliaryBarController.TOP, + getVerticalSashHeight: () => Math.max(0, editorPartContainer.clientHeight - DockedAuxiliaryBarController.TOP), + }; + + const sash = this._register(new Sash(editorPartContainer, layoutProvider, { orientation: SashOrientation.VERTICAL })); + this._sash = sash; + + this._register(sash.onDidStart(() => { + this._sashStartWidth = this.host.getWidth(); + })); + this._register(sash.onDidChange((e: ISashEvent) => { + // Dragging left (currentX < startX) widens the detail panel. + const delta = e.startX - e.currentX; + const width = editorPartContainer.clientWidth; + this.host.setWidth(this._auxiliaryBarWidth(this._sashStartWidth + delta, width)); + this.layout(); + })); + this._register(sash.onDidReset(() => { + this.host.setWidth(DockedAuxiliaryBarController.DEFAULT_WIDTH); + this.layout(); + })); + } +} diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 6458fdeffa4..3a4821d7132 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -17,6 +17,7 @@ import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../common/sessionConfig.js'; // Register Icons const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); @@ -72,14 +73,16 @@ class ToggleSidebarVisibilityAction extends Action2 { registerAction2(ToggleSidebarVisibilityAction); -// The editor-title secondary side bar toggle reuses the core `workbench.action.toggleAuxiliaryBar` -// command (registered by the workbench auxiliary bar part, which is also loaded in the agents -// window). Two mutually-exclusive menu items give the state-dependent icon without the -// checked/highlighted background that a single `toggled` menu item would render. +// The original (non-single-pane) editor-title secondary side bar toggle reuses the core +// `workbench.action.toggleAuxiliaryBar` command (registered by the workbench auxiliary bar +// part, which is also loaded in the agents window), using two mutually-exclusive items to +// avoid the toggled background. The single-pane "Toggle Details" item is a dedicated command +// registered by `SinglePaneDesktopSessionLayoutController`. const editorTitleAuxiliaryBarWhen = ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), IsTopRightEditorGroupContext); +const isSinglePaneDetailPanelDisabled = ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true).negate(); MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { command: { @@ -89,7 +92,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { }, group: 'navigation', order: 99.5, - when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext) + when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext, isSinglePaneDetailPanelDisabled) }); MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { @@ -100,9 +103,13 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { }, group: 'navigation', order: 99.5, - when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext.toNegated()) + when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext.toNegated(), isSinglePaneDetailPanelDisabled) }); +// The single-pane "Toggle Details" editor-title item is registered by +// `SinglePaneDesktopSessionLayoutController` (a dedicated command that toggles +// the detail panel and auto-hides / restores the sessions list in one gesture). + MenuRegistry.appendMenuItem(Menus.PanelTitle, { command: { id: 'workbench.action.closePanel', diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index 8a234ab8b6c..f6ad130ad7c 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -83,6 +83,23 @@ export class SessionsLayoutPolicy extends Disposable { /** Current viewport class derived from the most recent `update()` call. */ readonly viewportClass: IObservable = this._viewportClass; + /** + * Whether the agents window uses the single-pane layout (editor spans the + * detail panel as a docked auxiliary bar). Resolved once at startup from the + * setting; toggling requires a window reload. Never active on phone (which + * has its own mobile layout). + */ + private _singlePane = false; + + get isSinglePane(): boolean { + return this._singlePane && this._viewportClass.get() !== 'phone'; + } + + /** Set once at startup (from the redesign setting) before the first layout. */ + setSinglePane(value: boolean): void { + this._singlePane = value; + } + /** `true` when the viewport class is `phone`. */ readonly isPhoneLayout: IObservable = derived(this, reader => { return this._viewportClass.read(reader) === 'phone'; diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 7e5f9daa166..54157b7609c 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -252,6 +252,10 @@ line-height: 22px; } +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container { + gap: 4px; +} + .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { outline: none; @@ -375,22 +379,46 @@ margin-left: 0; } -.agent-sessions-workbench .part.auxiliarybar { - /* - * The right gutter is provided by the workbench grid (see Workbench.layout). - * The bottom margin is 5px when the panel is visible (paired with the panel's - * 5px top margin to center the sash) and 0 when the panel is hidden - * (see `.nopanel` override below) so the card fills its cell. - * - * Default (editor visible): card flushes against the editor with the - * grid sash at the seam. `padding-left: 5px` keeps the inner content - * aligned with the editor-hidden state. - * - * Editor hidden (override below): 5px left gutter against the sash. - * - * The TS `layout()` mirrors this and re-runs on editor visibility - * changes so the inner area is sized correctly. - */ +/* Docked detail panel: the auxiliary bar is absolutely positioned + * inside the editor part (right, below the tab strip). The workbench sizes and + * positions the container directly; paint the card background + a left separator. + * The editor part, Changes editor and aux bar all use the editor background so the + * single pane reads as one uniform surface. */ +.agent-sessions-workbench.dock-detail-panel .monaco-grid-view .part.editor:not(.modal-editor-part) { + /* Become the containing block for the docked aux bar so `overflow: hidden` + * clips it inside the rounded card and the borders below/right stay visible. */ + position: relative; + overflow: hidden; + background: var(--vscode-editor-background); + /* Complete the card: right + bottom border and rounded right corners so the + * editor + docked aux bar read as one bordered card. */ + border-right-width: var(--vscode-strokeThickness); + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar { + --part-background: var(--vscode-editor-background); + --vscode-sideBar-background: var(--vscode-editor-background); + background: var(--vscode-editor-background); + border-left: var(--vscode-strokeThickness) solid var(--part-border-color, transparent); + box-sizing: border-box; + z-index: 2; +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar > .content .pane-body, +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar .monaco-list, +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar .monaco-list-rows { + background-color: var(--vscode-editor-background); +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar.docked-auxiliarybar { + overflow: hidden; +} + +/* Previous (non-docked, original) layout: the auxiliary bar is a trailing grid + * column rendered as its own card. Reproduces the pre-redesign styling. */ +.agent-sessions-workbench:not(.dock-detail-panel) .part.auxiliarybar { margin: 0 0 5px 0; padding-left: 5px; background: var(--part-background); @@ -402,14 +430,14 @@ box-sizing: border-box; } -.agent-sessions-workbench.nomaineditorarea .part.auxiliarybar { +.agent-sessions-workbench:not(.dock-detail-panel).nomaineditorarea .part.auxiliarybar { margin-left: 5px; padding-left: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; } -.agent-sessions-workbench.nopanel .part.auxiliarybar, +.agent-sessions-workbench:not(.dock-detail-panel).nopanel .part.auxiliarybar, .agent-sessions-workbench.nopanel .monaco-grid-view .part.editor:not(.modal-editor-part) { margin-bottom: 0; } @@ -492,6 +520,15 @@ background: transparent !important; } +/* Chat list rows inherit the list's `cursor: pointer` (from `.monaco-list.mouse-support`), + * but chat requests/responses are not clickable as a whole row. Because the item container + * is centered with a max-width, the row extends into gutters on either side where the pointer + * cursor would otherwise show even though the item container itself uses `cursor: default`. + * Force the default cursor on the rows so the gutters match the item container. */ +.agent-sessions-workbench .interactive-list > .monaco-list.mouse-support > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row { + cursor: default; +} + /* Constrain content items to the same max-width, centered. * Scoped to the chat panel (`.part.sessionspart`) and the chat editor host * (`.chat-editor-relative`) so the "card style" doesn't leak into other chat @@ -508,6 +545,16 @@ box-sizing: border-box; } +.agent-sessions-workbench .part.sessionspart .interactive-session .interactive-item-container, +.agent-sessions-workbench .chat-editor-relative .interactive-session .interactive-item-container { + position: relative; +} + +.agent-sessions-workbench .part.sessionspart .interactive-list .monaco-list-row.request > .monaco-tl-row > .monaco-tl-contents, +.agent-sessions-workbench .chat-editor-relative .interactive-list .monaco-list-row.request > .monaco-tl-row > .monaco-tl-contents { + overflow: visible; +} + /* Let request bubbles that contain a code block grow to fit the code editor. */ .agent-sessions-workbench .part.sessionspart .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown:has(.interactive-result-code-block), .agent-sessions-workbench .chat-editor-relative .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown:has(.interactive-result-code-block) { diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 1606b7e7fed..2609f988c99 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -18,6 +18,7 @@ export const Menus = { TitleBarCenterRight: new MenuId('SessionsTitleBarCenterRight'), TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), + BlockedSessionsHeader: new MenuId('SessionsBlockedSessionsHeader'), TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), MobileTitleBarCenter: new MenuId('SessionsMobileTitleBarCenter'), PanelTitle: new MenuId('SessionsPanelTitle'), diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts index 1725d8f31b7..8f302d2c10a 100644 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -17,12 +17,15 @@ import { SINGLE_WINDOW_PARTS } from '../../workbench/services/layout/browser/lay import { PanelPart } from './parts/panelPart.js'; import { SidebarPart } from './parts/sidebarPart.js'; import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; +import { SinglePaneAuxiliaryBarPart } from './parts/singlePaneAuxiliaryBarPart.js'; import { MobilePanelPart } from './parts/mobile/mobilePanelPart.js'; import { MobileSidebarPart } from './parts/mobile/mobileSidebarPart.js'; import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js'; import { getClientArea } from '../../base/browser/dom.js'; import { mainWindow } from '../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { DOCK_DETAIL_PANEL_SETTING } from './../common/sessionConfig.js'; export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { @@ -37,16 +40,22 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane private readonly paneCompositeParts = new Map(); constructor( - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); const { width } = getClientArea(mainWindow.document.body); const isPhoneLayout = width < 640; + const singlePane = !isPhoneLayout && configurationService.getValue(DOCK_DETAIL_PANEL_SETTING) === true; + + const auxiliaryBarPartCtor = isPhoneLayout + ? MobileAuxiliaryBarPart + : (singlePane ? SinglePaneAuxiliaryBarPart : AuxiliaryBarPart); this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(auxiliaryBarPartCtor)); } private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 4d3f6f71baf..18059fcddab 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -49,7 +49,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly placeholderViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; - /** Visual margin values for the card-like appearance */ + /** Visual margin values for the card-like appearance (non-docked layout). */ static readonly MARGIN_TOP = 0; static readonly MARGIN_BOTTOM = 5; static readonly MARGIN_LEFT = 5; @@ -169,11 +169,13 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { const container = assertReturnsDefined(this.getContainer()); + const backgroundColor = this.getPartBackgroundColor(); + // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); + container.style.setProperty('--part-background', backgroundColor); container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; + container.style.backgroundColor = backgroundColor; // Clear borders - the card appearance uses border-radius instead container.style.borderLeftColor = ''; @@ -184,6 +186,11 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { container.style.borderRightWidth = ''; } + /** The part background color. Overridden by the single-pane variant to match the editor. */ + protected getPartBackgroundColor(): string { + return this.getColor(agentsPanelBackground) || ''; + } + protected getCompositeBarOptions(): IPaneCompositeBarOptions { const $this = this; return { diff --git a/src/vs/sessions/browser/parts/editorPart.ts b/src/vs/sessions/browser/parts/editorPart.ts index ec003bdd3b6..e330d92a9d6 100644 --- a/src/vs/sessions/browser/parts/editorPart.ts +++ b/src/vs/sessions/browser/parts/editorPart.ts @@ -7,6 +7,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { mainWindow } from '../../../base/browser/window.js'; import { MainEditorPart as MainEditorPartBase } from '../../../workbench/browser/parts/editor/editorPart.js'; import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import type { IAgentWorkbenchLayoutService } from '../workbench.js'; export class MainEditorPart extends MainEditorPartBase { static readonly MARGIN_TOP = 0; @@ -25,7 +26,10 @@ export class MainEditorPart extends MainEditorPartBase { override priority = LayoutPriority.Normal; override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + const agentLayoutService = this.layoutService as IAgentWorkbenchLayoutService; + const keepForDockedTabBar = agentLayoutService.isSinglePaneLayoutEnabled + && this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (!this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && !keepForDockedTabBar) { return; } @@ -44,5 +48,9 @@ export class MainEditorPart extends MainEditorPartBase { const adjustedHeight = height - MainEditorPart.MARGIN_TOP - marginBottom - 2 /* border width */; super.layout(adjustedWidth, adjustedHeight, top, left); + + if (agentLayoutService.isSinglePaneLayoutEnabled && !this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + agentLayoutService.handleDockedEditorPartLayout(width); + } } } diff --git a/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts new file mode 100644 index 00000000000..c141016bf8f --- /dev/null +++ b/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { editorBackground } from '../../../platform/theme/common/colorRegistry.js'; +import { AbstractPaneCompositePart } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { AuxiliaryBarPart } from './auxiliaryBarPart.js'; + +/** + * Single-pane variant of the auxiliary bar. In the single-pane layout the + * auxiliary bar is docked inside the editor part as a contextual detail panel: + * it has no title/composite bar, shares the editor background so the pane reads + * as one card, and fills the exact rectangle the workbench positions it in. + */ +export class SinglePaneAuxiliaryBarPart extends AuxiliaryBarPart { + + protected override createTitleArea(): HTMLElement | undefined { + // No title strip; the single tab bar lives on the editor part. + return undefined; + } + + protected override shouldShowCompositeBar(): boolean { + return false; + } + + protected override getPartBackgroundColor(): string { + return this.getColor(editorBackground) || ''; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // The workbench docks and sizes the aux bar to an exact rectangle (below the + // editor tab strip); fill it directly without the card margins/border math. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index fd45e5ada87..b76ef4b0af7 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -6,8 +6,9 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { DockedAuxiliaryBarController } from './dockedAuxiliaryBarController.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { addDisposableListener, getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -18,6 +19,7 @@ import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.j import { Part } from '../../workbench/browser/part.js'; import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid } from '../../base/browser/ui/grid/grid.js'; import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; +import { EditorPart } from '../../workbench/browser/parts/editor/editorPart.js'; import { IEditorService } from '../../workbench/services/editor/common/editorService.js'; import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; @@ -32,6 +34,7 @@ import { getSingletonServiceDescriptors } from '../../platform/instantiation/com import { ILifecycleService, LifecyclePhase, WillShutdownEvent } from '../../workbench/services/lifecycle/common/lifecycle.js'; import { IStorageService, WillSaveStateReason, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../common/sessionConfig.js'; import { IHostService } from '../../workbench/services/host/browser/host.js'; import { IDialogService } from '../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../platform/notification/common/notification.js'; @@ -41,7 +44,7 @@ import { setHoverDelegateFactory } from '../../base/browser/ui/hover/hoverDelega import { setBaseLayerHoverDelegate } from '../../base/browser/ui/hover/hoverDelegate2.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../workbench/common/contributions.js'; -import { IEditorFactoryRegistry, EditorExtensions } from '../../workbench/common/editor.js'; +import { IEditorFactoryRegistry, EditorExtensions, IEditorWillOpenEvent } from '../../workbench/common/editor.js'; import { setARIAContainer } from '../../base/browser/ui/aria/aria.js'; import { FontMeasurements } from '../../editor/browser/config/fontMeasurements.js'; import { createBareFontInfoFromRawSettings } from '../../editor/common/config/fontInfoFromSettings.js'; @@ -132,6 +135,25 @@ export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService { readonly onDidChangeEditorMaximized: Event; + /** + * Whether the single-pane (docked detail panel) layout setting is enabled. + * Read live from configuration (toggling requires a window reload). This is the + * single source of truth for the setting; features must query this instead of + * reading the configuration key directly. Note this is the pure setting value and + * differs from the phone-aware runtime layout used internally. + */ + readonly isSinglePaneLayoutEnabled: boolean; + + handleDockedEditorPartLayout(nodeWidth: number): void; + + /** + * Whether the editor's current visible state was produced by an explicit user + * reveal (opening an editor, or toggling the detail panel off) rather than an + * automatic layout/working-set reveal. The single-pane new-session rule (R1) + * uses this to avoid re-hiding an editor the user explicitly asked to show. + */ + isEditorRevealedExplicitly(): boolean; + /** * Suppresses the automatic editor part show/hide that normally fires from * `editorService.onWillOpenEditor` / `onDidCloseEditor`. Use this around @@ -152,6 +174,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic declare readonly _serviceBrand: undefined; + private static readonly _DOCKED_EDITOR_CONTENT_VISIBLE_THRESHOLD = 4; + //#region Lifecycle Events private readonly _onWillShutdown = this._register(new Emitter()); @@ -287,6 +311,22 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private sessionsPartView!: ISerializableView; + /** The editor part container; the auxiliary bar is docked inside it. */ + private _editorPartContainer: HTMLElement | undefined; + /** Whether the docked detail-panel (single-pane) layout is enabled. Backed by the layout policy. */ + private get _dockDetailPanel(): boolean { return this.layoutPolicy.isSinglePane; } + get isSinglePaneLayoutEnabled(): boolean { + return this._configurationServiceForLayout?.getValue(DOCK_DETAIL_PANEL_SETTING) === true; + } + /** Current width of the docked auxiliary bar (detail panel). */ + private _dockedAuxiliaryBarWidth = DockedAuxiliaryBarController.DEFAULT_WIDTH; + /** Owns the docked detail-panel overlay + sash; created only in single-pane mode. */ + private _dockedAuxBar: DockedAuxiliaryBarController | undefined; + private _syncingDockedEditorVisibility = false; + /** `true` while the editor's current visible state was produced by an explicit user reveal (opening an editor, or toggling the detail panel off) rather than an automatic layout/working-set reveal. Read by the single-pane new-session rule (R1) so it does not undo an explicit reveal. */ + private _editorRevealedExplicitly = false; + private _configurationServiceForLayout: IConfigurationService | undefined; + private readonly partVisibility: IPartVisibilityState = { sidebar: true, auxiliaryBar: true, @@ -305,6 +345,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private _editorMaximized = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; private _editorLastNonMaximizedSize: IViewSize | undefined; + private _dockedEditorSizeBeforeHide: IViewSize | undefined; + private _editorSizeGrownForSidebarHide: IViewSize | undefined; + /** Pre-hide detail-panel width remembered while the sidebar is collapsed and its freed width grew the detail (editor content hidden). */ + private _detailWidthGrownForSidebarHide: number | undefined; private _restoreAttachedEditorMaximizedOnShow = false; private _editorPartAutoVisibilitySuppressionCount = 0; private _hasAppliedInitialEditorSplit = false; @@ -427,6 +471,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const lifecycleService = accessor.get(ILifecycleService); const storageService = accessor.get(IStorageService); const configurationService = accessor.get(IConfigurationService); + this._configurationServiceForLayout = configurationService; const hostService = accessor.get(IHostService); const hoverService = accessor.get(IHoverService); const dialogService = accessor.get(IDialogService); @@ -710,11 +755,17 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return this.workbenchGrid.getViewCachedVisibleSize(view); }; + const editorGridWidth = getSize(this.editorPartView, 'width', this.partVisibility.editor); + // The docked panel lives inside the editor grid node; exclude it to avoid reload drift. + const editorWidth = this._dockDetailPanel && typeof editorGridWidth === 'number' + ? Math.max(0, editorGridWidth - this._dockedAuxiliaryBarWidth) + : editorGridWidth; + const sizes: IPartSizesState = { sidebar: getSize(this.sideBarPartView, 'width', this.partVisibility.sidebar), - auxiliaryBar: getSize(this.auxiliaryBarPartView, 'width', this.partVisibility.auxiliaryBar), + auxiliaryBar: this._dockDetailPanel ? this._dockedAuxiliaryBarWidth : getSize(this.auxiliaryBarPartView, 'width', this.partVisibility.auxiliaryBar), sessions: getSize(this.sessionsPartView, 'width', this.partVisibility.sessions), - editor: getSize(this.editorPartView, 'width', this.partVisibility.editor), + editor: editorWidth, panel: getSize(this.panelPartView, 'height', this.partVisibility.panel), }; @@ -745,6 +796,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // grid descriptor so editor/sidebar/auxbar/panel restore to their previous // dimensions across reloads. this._savedPartSizes = this._loadPartSizes(storageService); + if (this._savedPartSizes.auxiliaryBar !== undefined) { + this._dockedAuxiliaryBarWidth = this._savedPartSizes.auxiliaryBar; + } // State specific classes const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; @@ -941,6 +995,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorPartContainer.classList.add('part', 'editor'); editorPartContainer.id = Parts.EDITOR_PART; editorPartContainer.setAttribute('role', 'main'); + this._editorPartContainer = editorPartContainer; mark('code/willCreatePart/workbench.parts.editor'); this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); @@ -1025,6 +1080,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.storageService = accessor.get(IStorageService); accessor.get(ITitleService); + // Resolve the single-pane layout mode once (reload to toggle). + this.layoutPolicy.setSinglePane(this.isSinglePaneLayoutEnabled); + // Register layout listeners this.registerLayoutListeners(); @@ -1032,32 +1090,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // they actually target one of the main editor groups. Modal // opens stay neutral. Programmatic opens that suppress auto // visibility (e.g. working set application) are ignored. - this._register(this.editorService.onWillOpenEditor(e => { - if (this._editorPartAutoVisibilitySuppressionCount > 0) { - return; - } - - const targetsMainEditorPart = this.editorGroupService.mainPart.groups.some(group => group.id === e.groupId); - if (!targetsMainEditorPart) { - return; - } - - if (!this.partVisibility.editor) { - this.setEditorHidden(false); - this.restoreAttachedEditorMaximizedState(); - } - })); + // The managed empty Files tab is a placeholder that activates as a side + // effect of closing another tab (e.g. the Changes tab); it must never + // reveal a hidden editor. Real content (files, diffs, browser) still does. + this._register(this.editorService.onWillOpenEditor(e => this._handleWillOpenEditor(e))); // Hide editor part when last editor closes - this._register(this.editorService.onDidCloseEditor(() => { - if (this._editorPartAutoVisibilitySuppressionCount > 0) { - return; - } - if (this.partVisibility.editor && this.areAllGroupsInMainPartEmpty()) { - this.rememberAttachedEditorMaximizedState(); - this.setEditorHidden(true); - } - })); + this._register(this.editorService.onDidCloseEditor(() => this.handleDidCloseEditor())); // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); @@ -1087,6 +1126,59 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return true; } + private _handleWillOpenEditor(e: IEditorWillOpenEvent): void { + if (this._editorPartAutoVisibilitySuppressionCount > 0) { + return; + } + + const group = this.editorGroupService.mainPart.groups.find(g => g.id === e.groupId); + if (!group) { + return; + } + + // `EmptyFileEditorInput.ID` — kept as a literal to avoid a core -> contrib import. + if (e.editor.typeId === 'workbench.editors.agentSessions.emptyFile') { + return; + } + + if (!this.partVisibility.editor) { + this.setEditorHidden(false, /* explicit */ true); + this.restoreAttachedEditorMaximizedState(); + } + } + + private handleDidCloseEditor(): void { + if (this._editorPartAutoVisibilitySuppressionCount > 0 || !this.areAllGroupsInMainPartEmpty()) { + return; + } + + if (this._dockDetailPanel) { + if (!this.partVisibility.editor && !this.partVisibility.auxiliaryBar) { + return; + } + if (this.partVisibility.editor) { + this.rememberAttachedEditorMaximizedState(); + } + const suppress = this.suppressEditorPartAutoVisibility(); + try { + if (this.partVisibility.editor) { + this.setEditorHidden(true); + } + if (this.partVisibility.auxiliaryBar) { + this.setAuxiliaryBarHidden(true); + } + } finally { + suppress.dispose(); + } + return; + } + + if (this.partVisibility.editor) { + this.rememberAttachedEditorMaximizedState(); + this.setEditorHidden(true); + } + } + suppressEditorPartAutoVisibility(): IDisposable { this._editorPartAutoVisibilitySuppressionCount++; let disposed = false; @@ -1124,8 +1216,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Window resize — needed for device emulation and mobile viewport changes const onWindowResize = () => this.layout(); - mainWindow.addEventListener('resize', onWindowResize); - this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); + this._register(addDisposableListener(mainWindow, 'resize', onWindowResize)); } private updateFullscreenClass(): void { @@ -1141,6 +1232,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Workbench Layout Creation createWorkbenchLayout(): void { + this.mainContainer.classList.toggle('dock-detail-panel', this._dockDetailPanel); + const titleBar = this.getPart(Parts.TITLEBAR_PART); const editorPart = this.getPart(Parts.EDITOR_PART); const panelPart = this.getPart(Parts.PANEL_PART); @@ -1176,6 +1269,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.mainContainer.setAttribute('role', 'application'); this.workbenchGrid = workbenchGrid; this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; + this._register(this.workbenchGrid.onDidChange(() => { + if (this._dockDetailPanel) { + this._syncDockedEditorVisibilityFromGrid(); + } + })); // If the editor is restored visible, it already has an established // width, so a later reveal must not force an even split over it. @@ -1254,8 +1352,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const sizes = this.layoutPolicy.getPartSizes(width, height); // For hidden parts, still provide a reasonable cached size for when they're shown later. // Saved sizes from a previous session take precedence over policy defaults. + const defaultSideBarSize = this._dockDetailPanel ? Math.min(sizes.sideBarSize, 280) : sizes.sideBarSize; const sideBarSize = this._savedPartSizes.sidebar - ?? (this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250)); + ?? (this.partVisibility.sidebar ? defaultSideBarSize : Math.max(defaultSideBarSize, 250)); const auxiliaryBarSize = this._savedPartSizes.auxiliaryBar ?? (this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300)); const panelSize = this._savedPartSizes.panel @@ -1294,13 +1393,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic visible: this.partVisibility.sidebar }; - const auxiliaryBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.AUXILIARYBAR_PART }, - size: auxiliaryBarSize, - visible: this.partVisibility.auxiliaryBar - }; - const sessionsNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.SESSIONS_PART }, @@ -1311,8 +1403,20 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const editorNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.EDITOR_PART }, - size: editorSize, - visible: this.partVisibility.editor + // When the detail panel is docked, the editor part spans the + // editor + auxiliary bar width (the aux bar is docked inside it, not a + // grid column) so the editor tab bar spans the full width. + size: this._dockDetailPanel ? effectiveEditorWidth + effectiveAuxBarWidth : effectiveEditorWidth, + visible: this._dockDetailPanel + ? (this.partVisibility.editor || this.partVisibility.auxiliaryBar) + : this.partVisibility.editor + }; + + const auxiliaryBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.AUXILIARYBAR_PART }, + size: auxiliaryBarSize, + visible: this.partVisibility.auxiliaryBar }; const panelNode: ISerializedLeafNode = { @@ -1322,10 +1426,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic visible: this.partVisibility.panel }; - // Top right section: Chat Bar | Editor | Auxiliary Bar (horizontal) + // Top right section: Chat Bar | Editor [| Auxiliary Bar] (horizontal). + // When docked, the auxiliary bar is inside the editor part and + // omitted from the grid; otherwise it is its own trailing grid column. const topRightSection: ISerializedNode = { type: 'branch', - data: [sessionsNode, editorNode, auxiliaryBarNode], + data: this._dockDetailPanel ? [sessionsNode, editorNode] : [sessionsNode, editorNode, auxiliaryBarNode], size: topRightHeight }; @@ -1447,12 +1553,89 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Layout the grid widget this.workbenchGrid.layout(gridWidth, gridHeight); + + // Dock + layout the auxiliary bar inside the editor part so the + // editor tab bar spans the full width above both. + this._ensureDockedAuxBarController(); + this._dockedAuxBar?.layout(); + this.layoutMobileSidebar(); // Emit as event this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); } + /** + * Create the docked detail-panel controller once (single-pane mode only), + * after the editor part and editor group service are available. + */ + private _ensureDockedAuxBarController(): void { + if (this._dockedAuxBar || !this._dockDetailPanel || !this._editorPartContainer) { + return; + } + + this._dockedAuxBar = this._register(new DockedAuxiliaryBarController( + this._editorPartContainer, + this.getPart(Parts.AUXILIARYBAR_PART), + { + getWidth: () => this._dockedAuxiliaryBarWidth, + setWidth: (width: number) => { this._dockedAuxiliaryBarWidth = width; }, + isEditorAreaVisible: () => this.partVisibility.editor || this.partVisibility.auxiliaryBar, + isEditorVisible: () => this.partVisibility.editor, + isAuxiliaryBarVisible: () => this.partVisibility.auxiliaryBar, + setEditorContentRightInset: (px: number) => (this.editorGroupService.mainPart as EditorPart).setContentRightInset(px), + }, + )); + } + + private _syncDockedEditorVisibilityFromGrid(): void { + this._syncDockedEditorVisibility(this.workbenchGrid.getViewSize(this.editorPartView).width); + } + + handleDockedEditorPartLayout(nodeWidth: number): void { + this._syncDockedEditorVisibility(nodeWidth); + } + + isEditorRevealedExplicitly(): boolean { + return this._editorRevealedExplicitly; + } + + private _syncDockedEditorVisibility(nodeWidth: number): void { + if (this._syncingDockedEditorVisibility || !this._dockDetailPanel) { + return; + } + + this._syncingDockedEditorVisibility = true; + try { + const editorContentVisible = nodeWidth > this._dockedAuxiliaryBarWidth + Workbench._DOCKED_EDITOR_CONTENT_VISIBLE_THRESHOLD; + + // Reveal: if editor content is hidden and the node is wide enough + if (!this.partVisibility.editor && editorContentVisible) { + this.partVisibility.editor = true; + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, false); + this._dockedEditorSizeBeforeHide = undefined; + this._dockedAuxBar?.layout(); + this._onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + this._savePartVisibility(); + } + + // Hide: if editor content is visible and the node is squeezed down to the detail width. + // Only hide when the detail is visible, so we don't hide when both parts are closed. + if (this.partVisibility.editor && !editorContentVisible && this.partVisibility.auxiliaryBar) { + this.partVisibility.editor = false; + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, true); + this._editorRevealedExplicitly = false; + this._editorSizeGrownForSidebarHide = undefined; + this._detailWidthGrownForSidebarHide = undefined; + this._dockedAuxBar?.layout(); + this._onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + this._savePartVisibility(); + } + } finally { + this._syncingDockedEditorVisibility = false; + } + } + private layoutMobileSidebar(): void { const sidebarContainer = this.getContainer(mainWindow, Parts.SIDEBAR_PART); const sidebarPart = this.getPart(Parts.SIDEBAR_PART); @@ -1663,6 +1846,20 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } + const shouldResizeDockedSidePane = this._dockDetailPanel && (this.partVisibility.editor || this.partVisibility.auxiliaryBar); + // Grow the editor node when the editor is visible, else the detail (keeps node == detail width so reveal-sync can't misfire). + const growEditorNode = shouldResizeDockedSidePane && this.partVisibility.editor; + const growDetailPanel = shouldResizeDockedSidePane && !this.partVisibility.editor; + const freedSideBarWidth = hidden && shouldResizeDockedSidePane + ? this.workbenchGrid.getViewSize(this.sideBarPartView).width + : 0; + const editorSizeBeforeSideBarHide = hidden && growEditorNode + ? this.workbenchGrid.getViewSize(this.editorPartView) + : undefined; + const detailWidthBeforeSideBarHide = hidden && growDetailPanel + ? this._dockedAuxiliaryBarWidth + : undefined; + this.partVisibility.sidebar = !hidden; this.mainContainer.classList.toggle(LayoutClasses.SIDEBAR_HIDDEN, hidden); @@ -1672,6 +1869,26 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic !hidden, ); + if (editorSizeBeforeSideBarHide) { + this._editorSizeGrownForSidebarHide = editorSizeBeforeSideBarHide; + this._resizeDockedEditorAfterSidebarChange({ + width: editorSizeBeforeSideBarHide.width + freedSideBarWidth, + height: editorSizeBeforeSideBarHide.height + }); + } else if (detailWidthBeforeSideBarHide !== undefined) { + this._detailWidthGrownForSidebarHide = detailWidthBeforeSideBarHide; + this._growDockedDetailAfterSidebarChange(detailWidthBeforeSideBarHide + freedSideBarWidth); + } else if (!hidden && this._editorSizeGrownForSidebarHide) { + this._resizeDockedEditorAfterSidebarChange(this._editorSizeGrownForSidebarHide); + this._editorSizeGrownForSidebarHide = undefined; + } else if (!hidden && this._detailWidthGrownForSidebarHide !== undefined) { + this._growDockedDetailAfterSidebarChange(this._detailWidthGrownForSidebarHide); + this._detailWidthGrownForSidebarHide = undefined; + } else if (!hidden) { + this._editorSizeGrownForSidebarHide = undefined; + this._detailWidthGrownForSidebarHide = undefined; + } + // If sidebar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); @@ -1690,6 +1907,30 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._savePartVisibility(); } + private _resizeDockedEditorAfterSidebarChange(size: IViewSize): void { + this._syncingDockedEditorVisibility = true; + try { + this.workbenchGrid.resizeView(this.editorPartView, size); + } finally { + this._syncingDockedEditorVisibility = false; + } + this._dockedAuxBar?.layout(); + } + + private _growDockedDetailAfterSidebarChange(width: number): void { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, width); + this._syncingDockedEditorVisibility = true; + try { + this.workbenchGrid.resizeView(this.editorPartView, { + width: this._dockedAuxiliaryBarWidth, + height: this.workbenchGrid.getViewSize(this.editorPartView).height + }); + } finally { + this._syncingDockedEditorVisibility = false; + } + this._dockedAuxBar?.layout(); + } + private setAuxiliaryBarHidden(hidden: boolean): void { if (this.partVisibility.auxiliaryBar === !hidden) { return; @@ -1699,14 +1940,37 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._restoreAttachedEditorMaximizedOnShow = false; } + if (hidden && this._dockDetailPanel && !this.partVisibility.editor && this._editorPartAutoVisibilitySuppressionCount === 0) { + this.setEditorHidden(false, /* explicit */ true); + } + this.partVisibility.auxiliaryBar = !hidden; this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); - // Propagate to grid - this.workbenchGrid.setViewVisible( - this.auxiliaryBarPartView, - !hidden, - ); + if (this._dockDetailPanel) { + // The auxiliary bar is docked inside the editor part (not a + // grid view), so drive its visibility through the docked layout and fire + // the visibility event the grid path would otherwise raise (the layout + // controller listens for it to capture per-session state). + if (this.workbenchGrid) { + this.workbenchGrid.setViewVisible( + this.editorPartView, + this.partVisibility.editor || this.partVisibility.auxiliaryBar + ); + } + this._dockedAuxBar?.layout(); + this._onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: !hidden }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + } else if (this.workbenchGrid) { + // Propagate to grid. Skipped before the grid exists: during startup the + // layout controller (a BlockRestore contribution) runs before + // createWorkbenchLayout(), so the visibility is recorded in + // partVisibility above and applied when the grid is built. + this.workbenchGrid.setViewVisible( + this.auxiliaryBarPartView, + !hidden, + ); + } // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { @@ -1717,7 +1981,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar) ?? this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id; - if (paneCompositeToOpen) { + // In docked (single-pane) mode never force-open a container that has no + // active views: doing so would leave the detail panel rendered but blank + // while the toggle/context key (driven by `partVisibility.auxiliaryBar`) + // reads "on" — the exact toggle/pane desync this layout must avoid. The + // layout controller (D10) additionally hides such an empty part; skipping + // the force-open here keeps the two from fighting into a re-entrant loop. + if (paneCompositeToOpen && (!this._dockDetailPanel || this._isAuxViewContainerActive(paneCompositeToOpen))) { this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar); } } @@ -1725,47 +1995,117 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._savePartVisibility(); } - private setEditorHidden(hidden: boolean): void { + /** + * Whether the given auxiliary-bar view container currently has content to show + * (mirrors `IViewsService.isViewContainerActive`: a `hideIfEmpty` container is + * only active once it has at least one active view descriptor). Used to avoid + * presenting an empty docked detail panel. + */ + private _isAuxViewContainerActive(containerId: string): boolean { + const viewContainer = this.viewDescriptorService.getViewContainerById(containerId); + if (!viewContainer) { + return false; + } + if (!viewContainer.hideIfEmpty) { + return true; + } + return this.viewDescriptorService.getViewContainerModel(viewContainer).activeViewDescriptors.length > 0; + } + + private setEditorHidden(hidden: boolean, explicit: boolean = false): void { if (this.partVisibility.editor === !hidden) { return; } - // If hiding the editor while maximized - if (hidden && this._editorMaximized) { - this.setEditorMaximized(false); - } + // Track whether this visible state was an explicit user reveal so R1 does + // not undo it. Any hide clears it; an automatic reveal leaves it false. + this._editorRevealedExplicitly = !hidden && explicit; - this.partVisibility.editor = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); - - if (this.editorPartView) { - // Force an even 50/50 split only the *first* time the editor is - // revealed in this workbench instance. On later show/hide cycles the - // grid caches and restores the user-adjusted width, so we must not - // override it. The grid always seeds a cached visible size from the - // serialized descriptor, so it can't be used to detect the first - // reveal — a runtime flag is needed instead. - const shouldApplyEvenSplit = !hidden && !this._hasAppliedInitialEditorSplit; - - // Capture the sessions part width *before* revealing the editor. The - // editor is hidden (0px) right now, so the sessions part spans the - // whole main area; we split that in half below so the editor opens - // as an even split rather than at its minimum/restored width. - // Measuring after the reveal is unreliable because the grid first - // restores the editor to its cached/minimum width. - const mainAreaWidthBeforeReveal = shouldApplyEvenSplit - ? this.workbenchGrid.getViewSize(this.sessionsPartView).width - : 0; - - this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); - - if (shouldApplyEvenSplit) { - this._hasAppliedInitialEditorSplit = true; - this._applyEditorSplitSize(mainAreaWidthBeforeReveal); + this._syncingDockedEditorVisibility = true; + try { + // If hiding the editor while maximized + if (hidden && this._editorMaximized) { + this.setEditorMaximized(false); } - } - this._savePartVisibility(); + this.partVisibility.editor = !hidden; + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + + if (this.editorPartView) { + // Give the editor a comfortable even split when revealed without a + // user-chosen width to restore. Docked mode applies this on *every* + // such reveal: hiding collapses the node to the detail width and the + // grid caches it, so a later cross-session reveal would otherwise come + // back narrow. A captured `_dockedEditorSizeBeforeHide` always wins. + // Non-docked keeps the first-reveal-only `_hasAppliedInitialEditorSplit`. + const dockedEditorSizeBeforeHide = this._dockedEditorSizeBeforeHide; + const shouldRestoreDockedEditorSize = this._dockDetailPanel && !hidden && !!dockedEditorSizeBeforeHide; + const shouldApplyEvenSplit = !hidden && !shouldRestoreDockedEditorSize && + (this._dockDetailPanel ? true : !this._hasAppliedInitialEditorSplit); + + // Capture the sessions part width *before* revealing the editor. The + // editor is hidden (0px) right now, so the sessions part spans the + // whole main area; we split that in half below so the editor opens + // as an even split rather than at its minimum/restored width. + const mainAreaWidthBeforeReveal = shouldApplyEvenSplit + ? this.workbenchGrid.getViewSize(this.sessionsPartView).width + : 0; + + this.workbenchGrid.setViewVisible( + this.editorPartView, + this._dockDetailPanel ? (this.partVisibility.editor || this.partVisibility.auxiliaryBar) : !hidden + ); + + if (this._dockDetailPanel) { + if (hidden) { + // Only "Hide Editor" (detail still visible) keeps the editor grid + // node visible, so its current width is a real user-chosen width to + // restore later. Closing the whole side pane (detail also hidden) + // collapses the node to 0px, so capturing it would restore a bogus + // width; instead reset so the next reveal applies a fresh even + // split, and drop the stale sidebar-collapse snapshots that would + // otherwise force a pre-collapse width on reopen. + if (this.partVisibility.auxiliaryBar) { + this._dockedEditorSizeBeforeHide = this.workbenchGrid.getViewSize(this.editorPartView); + this.workbenchGrid.resizeView(this.editorPartView, { + width: this._dockedAuxiliaryBarWidth, + height: this._dockedEditorSizeBeforeHide.height + }); + // The node is now at the detail width, so any grown-editor + // snapshot captured while the sessions list was hidden is + // stale: restoring it on a later sessions-list reveal would + // re-inflate the node and make the detail fill it. Drop both + // snapshots so showing the sessions list only takes from the + // chat, keeping the detail at its own width. + this._editorSizeGrownForSidebarHide = undefined; + this._detailWidthGrownForSidebarHide = undefined; + } else { + this._dockedEditorSizeBeforeHide = undefined; + this._editorSizeGrownForSidebarHide = undefined; + this._detailWidthGrownForSidebarHide = undefined; + } + } else if (dockedEditorSizeBeforeHide) { + this.workbenchGrid.resizeView(this.editorPartView, dockedEditorSizeBeforeHide); + this._dockedEditorSizeBeforeHide = undefined; + } + } + + if (shouldApplyEvenSplit) { + this._hasAppliedInitialEditorSplit = true; + this._applyEditorSplitSize(mainAreaWidthBeforeReveal); + } + + if (this._dockDetailPanel) { + this._dockedAuxBar?.layout(); + this._onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: !hidden }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + } + } + + this._savePartVisibility(); + } finally { + this._syncingDockedEditorVisibility = false; + } } /** @@ -1872,6 +2212,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Size Methods getSize(part: Parts): IViewSize { + if (part === Parts.AUXILIARYBAR_PART && this._dockDetailPanel) { + return { width: this._dockedAuxiliaryBarWidth, height: this._editorPartContainer?.clientHeight ?? 0 }; + } const view = this.getPartView(part); if (!view) { return { width: 0, height: 0 }; @@ -1880,6 +2223,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } setSize(part: Parts, size: IViewSize): void { + if (part === Parts.AUXILIARYBAR_PART && this._dockDetailPanel) { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, size.width); + this._dockedAuxBar?.layout(); + return; + } const view = this.getPartView(part); if (view) { this.workbenchGrid.resizeView(view, size); @@ -1887,6 +2235,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void { + if (part === Parts.AUXILIARYBAR_PART && this._dockDetailPanel) { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, this._dockedAuxiliaryBarWidth + sizeChangeWidth); + this._dockedAuxBar?.layout(); + return; + } const view = this.getPartView(part); if (!view) { return; @@ -1921,7 +2274,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic getMaximumEditorDimensions(_container: HTMLElement): IDimension { // Return the available space for editor (excluding other parts) const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; - const auxiliaryBarWidth = this.partVisibility.auxiliaryBar ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; + const auxiliaryBarWidth = this.partVisibility.auxiliaryBar + ? (this._dockDetailPanel ? this._dockedAuxiliaryBarWidth : this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width) + : 0; const panelHeight = this.partVisibility.panel ? this.workbenchGrid.getViewSize(this.panelPartView).height : 0; const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; @@ -2025,6 +2380,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (this.editorPartView && size) { this.workbenchGrid.resizeView(this.editorPartView, size); } + if (this._dockDetailPanel) { + this._dockedAuxBar?.layout(); + } } this._onDidChangeEditorMaximized.fire(); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 98aaf745a5f..51aac240658 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -29,6 +29,7 @@ export const SessionIsMaximizedContext = new RawContextKey('sessionIsMa export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); export const SessionSupportsForkContext = new RawContextKey('sessionSupportsFork', false, localize('sessionSupportsFork', "Whether the session view's session supports forking a chat from a turn into a new peer chat")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); +export const SessionActiveChatHasSubagentsContext = new RawContextKey('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the session view's currently-active chat has spawned subagent (tool-origin) chats, which are listed as a separate group in the Conversations menu")); export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat (counting closed chats) or its single remaining chat's title diverged from the session title. Used to hide the header New Chat button, which the tab strip then offers instead")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); @@ -101,6 +102,7 @@ export const CanGoForwardContext = new RawContextKey('sessionsCanGoForw //#region < --- Editor --- > export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); +export const SinglePaneDetailChangesOrFilesActiveContext = new RawContextKey('agentSessionsSinglePaneDetailChangesOrFiles', false, localize('agentSessionsSinglePaneDetailChangesOrFiles', "Whether the single-pane detail panel's active editor maps to the Changes or Files detail target")); //#endregion diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index cc8590541a1..deda8edc5ed 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -5,6 +5,14 @@ import type { ResolveSessionConfigResult } from '../../platform/agentHost/common/state/protocol/commands.js'; +/** + * When enabled, the Agents window docks the detail panel (auxiliary + * bar) inside the editor part so a single editor tab bar spans the full width + * across the editor content and the detail panel. Read once at startup; toggling + * requires a window reload. + */ +export const DOCK_DETAIL_PANEL_SETTING = 'sessions.layout.singlePaneDetailPanel'; + export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts new file mode 100644 index 00000000000..8e3f79d8e5c --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IAgentEditorComment, IAgentEditorCommentsBridge, IAgentEditorCommentsProvider } from '../../../../workbench/services/agentEditorComments/common/agentEditorComments.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { getSessionEditorComments } from './sessionEditorComments.js'; + +/** + * Registers a provider with the workbench {@link IAgentEditorCommentsBridge} + * that surfaces the active session's comments (the same store the code editor + * renders from) to the extension host, so custom editors (e.g. the Markdown + * editor) can render and contribute the same comments. Lives in the sessions + * layer because the feedback service does. + */ +export class AgentEditorCommentsProviderContribution extends Disposable implements IWorkbenchContribution, IAgentEditorCommentsProvider { + + static readonly ID = 'workbench.contrib.agentEditorCommentsProvider'; + + readonly onDidChangeComments: Event; + + constructor( + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IAgentEditorCommentsBridge bridge: IAgentEditorCommentsBridge, + ) { + super(); + this.onDidChangeComments = Event.signal(this._agentFeedbackService.onDidChangeFeedback); + this._register(bridge.registerProvider(this)); + } + + getComments(resource: URI): readonly IAgentEditorComment[] { + const session = this._agentFeedbackService.getSessionForFile(resource); + if (!session) { + return []; + } + const comments: IAgentEditorComment[] = []; + const sessionComments = getSessionEditorComments(session.resource, this._agentFeedbackService.getFeedback(session.resource)); + for (const comment of sessionComments) { + if (isEqual(comment.resourceUri, resource)) { + comments.push({ id: comment.id, range: comment.range, body: comment.text }); + } + } + return comments; + } + + addComment(resource: URI, range: IRange, body: string): void { + const session = this._agentFeedbackService.getSessionForFile(resource); + if (!session) { + return; + } + this._agentFeedbackService.addFeedback(session.resource, resource, range, body); + } +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 5d00f523124..54a3479b7ba 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -17,6 +17,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; +import { AgentEditorCommentsProviderContribution } from './agentEditorCommentsProvider.js'; import { AgentFeedbackPRThreadResolverContribution } from './agentFeedbackPRThreadResolver.js'; import { AgentFeedbackPRReviewSeederContribution } from './agentFeedbackPRReviewSeeder.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; @@ -84,6 +85,7 @@ registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEdito registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackPRThreadResolverContribution.ID, AgentFeedbackPRThreadResolverContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackPRReviewSeederContribution.ID, AgentFeedbackPRReviewSeederContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentEditorCommentsProviderContribution.ID, AgentEditorCommentsProviderContribution, WorkbenchPhase.BlockRestore); registerAgentFeedbackEditorActions(); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 0ed2a08d2b3..fdaf7d3923f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -36,7 +36,12 @@ const addFeedbackAtCurrentLineActionId = 'agentFeedbackEditor.action.addAtCurren const agentFeedbackHoverGlyphClassName = 'agent-feedback-glyph'; const hasAgentFeedbackSessionForEditor = new RawContextKey('agentFeedbackEditor.hasSession', false); -class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { +/** + * The inline "Add Feedback" input shown in the editor when the user selects a + * range to comment on. Exported so it can be rendered in a component fixture; + * it only depends on {@link ICodeEditor} for its layout geometry. + */ +export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { private static readonly _ID = 'agentFeedback.inputWidget'; private static readonly _MIN_WIDTH = 150; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index eb95cdd399b..6ff2f8c18a7 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -1031,7 +1031,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid * Groups feedback items and creates combined widgets for nearby items. * Widgets start collapsed and expand when navigated to. */ -class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution { +export class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution { static readonly ID = 'agentFeedback.editorWidgetContribution'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index 693cc4bed55..aced779633f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -79,7 +79,8 @@ flex-shrink: 0; } -.agent-feedback-input-widget .agent-feedback-input-actions .action-bar .action-item .action-label { +.agent-feedback-input-widget .agent-feedback-input-actions .monaco-action-bar .action-item .action-label { + box-sizing: content-box; width: 16px; height: 16px; } diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts index 34bb397cfd4..0f4133c06f0 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -12,12 +12,14 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; -import { AgentFeedbackKind, IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; -import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget, AgentFeedbackEditorWidgetContribution } from '../../browser/agentFeedbackEditorWidgetContribution.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ICodeReviewService, ICodeReviewSuggestion } from '../../../codeReview/browser/codeReviewService.js'; import { createMockCodeReviewService } from '../../../../../workbench/test/browser/componentFixtures/sessions/mockCodeReviewService.js'; import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); @@ -40,6 +42,20 @@ const sampleCode = [ '}', ].join('\n'); +const longSampleCode = Array.from({ length: 100 }, (_, i) => { + const line = i + 1; + if (line % 6 === 1) { + return `function fn${line}() {`; + } + if (line % 6 === 0) { + return '}'; + } + if (line % 6 === 5) { + return `\treturn value${line};`; + } + return `\tconst value${line} = ${line} + compute${line}();`; +}).join('\n'); + interface IFixtureOptions { readonly expanded?: boolean; readonly focusedCommentId?: string; @@ -221,6 +237,92 @@ function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions } } +/** + * Renders the agent feedback widgets the same way production does: by + * instantiating the real {@link AgentFeedbackEditorWidgetContribution} and + * feeding it comments through the services. This exercises the production + * grouping (far-apart comments become separate widgets) and scroll handling + * (widgets follow their anchor line as the editor scrolls), which a directly + * constructed {@link AgentFeedbackEditorWidget} does not. + */ +function renderViaContribution(context: ComponentFixtureContext, code: string, comments: readonly ISessionEditorComment[]): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const feedback: readonly IAgentFeedback[] = comments.map(comment => ({ + id: comment.sourceId, + text: comment.text, + resourceUri: comment.resourceUri, + range: comment.range, + sessionResource: comment.sessionResource, + suggestion: comment.suggestion, + kind: comment.kind, + replies: comment.replies, + state: comment.state ?? AgentFeedbackState.Accepted, + })); + + const agentFeedbackService = new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override getSessionForFile(resourceUri: URI): ISession | undefined { + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return resourceUri.toString() === fileResource.toString() ? { resource: sessionResource } as ISession : undefined; + } + + override getFeedback(resource: URI): readonly IAgentFeedback[] { + return resource.toString() === sessionResource.toString() ? feedback : []; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: feedback.length }; + } + }(); + + const sessionsManagementService = new class extends mock() { + override getSession(): ISession | undefined { + return undefined; + } + }(); + + const codeReviewService = createMockCodeReviewService(); + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ISessionsManagementService, sessionsManagementService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + const model = scopedDisposables.add(createTextModel(instantiationService, code, fileResource, 'typescript')); + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + { contributions: [] } + )); + + editor.setModel(model); + + // The contribution builds, groups, positions and keeps the widgets in sync + // with editor scroll — exactly as in production. + scopedDisposables.add(instantiationService.createInstance(AgentFeedbackEditorWidgetContribution, editor)); +} + const singleFeedback = [ createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), ]; @@ -279,6 +381,11 @@ const allSourcesMixed = [ createPRReviewComment('pr-2', 'This logic duplicates what we have in utils.ts — consider reusing.', 8, 9), ]; +const longFileFeedback = [ + createFeedbackComment('lf-1', 'Consider validating this input before using it.', 5), + createFeedbackComment('lf-2', 'This computation is duplicated further down — extract a helper.', 20), +]; + export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { CollapsedSingleComment: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -392,4 +499,9 @@ export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { hidden: true, }), }), + + LongFileTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderViaContribution(context, longSampleCode, longFileFeedback), + }), }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts new file mode 100644 index 00000000000..7dc65cc1653 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from '../../../../../base/common/color.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorLayoutInfo } from '../../../../../editor/common/config/editorOptions.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { AgentFeedbackEditorInputContribution, AgentFeedbackInputWidget } from '../../browser/agentFeedbackEditorInputContribution.js'; +import { IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { ISession, ISessionFileChange } from '../../../../services/sessions/common/session.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import '../../../../../base/browser/ui/codicons/codiconStyles.js'; +import '../../browser/media/agentFeedbackEditorInput.css'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-input.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', +].join('\n'); + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + TokenizationRegistry.setColorMap([ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + ]); +} + +interface IInputFixtureOptions { + /** Initial text in the input. Empty renders the placeholder state. */ + readonly text?: string; + /** Placeholder to show — "Add Feedback" (has changes) vs "Add Comment". */ + readonly placeholder?: string; +} + +/** + * A minimal {@link ICodeEditor} stand-in for the standalone variants. The input + * widget only reads layout geometry ({@link ICodeEditor.getLayoutInfo}) and asks + * the editor to re-layout itself ({@link ICodeEditor.layoutOverlayWidget}), so a + * mock that provides just those two is enough to render it on its own. + */ +function createFakeEditor(): ICodeEditor { + return new class extends mock() { + override getLayoutInfo(): EditorLayoutInfo { + // Only `width` and `contentLeft` are read (to clamp the input width). + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return { width: 520, contentLeft: 64 } as EditorLayoutInfo; + } + override layoutOverlayWidget(): void { } + }(); +} + +/** Renders the input widget on its own — the widget's own DOM/CSS in isolation. */ +function renderInputWidget(context: ComponentFixtureContext, options: IInputFixtureOptions): void { + // The widget is `position: absolute`, so give it a positioned host with + // room, and let it flow statically so it is fully captured (not clipped). + context.container.style.position = 'relative'; + context.container.style.width = '520px'; + context.container.style.padding = '24px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const widget = context.disposableStore.add(new AgentFeedbackInputWidget(createFakeEditor())); + const domNode = widget.getDomNode(); + domNode.style.position = 'static'; + // When absolutely positioned (as in the editor) the widget shrinks to its + // content. Flowing it statically would instead stretch the flex container to + // the host width, so pin it to its content width to preserve the real layout. + domNode.style.width = 'fit-content'; + domNode.style.animation = 'none'; + context.container.appendChild(domNode); + + if (options.placeholder) { + widget.setPlaceholder(options.placeholder); + } + if (options.text) { + widget.inputElement.value = options.text; + } + + // Reveal (it starts hidden) and let it size itself + enable/disable actions + // exactly as the contribution does after mounting it. + widget.show(); + widget.updateActionEnabled(); + widget.autoSize(); +} + +/** A session whose feedback scopes {@link fileResource}, for the mock service. */ +function createFixtureSession(): ISession { + const changes = observableValue('agentFeedbackFixtureChanges', []); + return new class extends mock() { + override readonly resource = sessionResource; + override readonly changes = changes; + }(); +} + +/** + * Renders the input widget the way production does: by instantiating the real + * {@link AgentFeedbackEditorInputContribution} on a real editor and letting it + * create, show and position the widget. The contribution requires chat to be + * enabled and the file to be owned by a session, so both are stubbed here, then + * its public {@link AgentFeedbackEditorInputContribution.showAtCurrentLine} entry + * point (also used by the "add feedback at current line" command) is invoked to + * summon the box — exercising the real placement instead of re-implementing it. + */ +function renderInEditor(context: ComponentFixtureContext): Promise { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '260px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const session = createFixtureSession(); + const agentFeedbackService = new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + override getSessionForFile(resourceUri: URI): ISession | undefined { + return isEqual(resourceUri, fileResource) ? session : undefined; + } + override getFeedback() { + return []; + } + }(); + + // The contribution only offers the input when chat is enabled. The fixtures' + // default MockContextKeyService reports every rule as unmatched, so provide a + // variant that reports the chat-enabled gate as satisfied. + const contextKeyService = new class extends MockContextKeyService { + override contextMatchesRules(): boolean { return true; } + }(); + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(IContextKeyService, contextKeyService); + }, + }); + + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: false, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + { contributions: [] }, + )); + editor.setModel(model); + // Lay out synchronously so the contribution positions/sizes the widget + // against real geometry (automaticLayout would settle asynchronously, after + // the box has already been placed against a zero-size editor). + editor.layout({ width: 760, height: 260 }); + + const contribution = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackEditorInputContribution, editor)); + + // Put the cursor on the line to comment on and let the contribution create, + // show and position the input exactly as the command does in production. + editor.setPosition(new Position(7, 1)); + contribution.showAtCurrentLine(false); + + // Let the DOM settle, then trigger a layout change so the contribution + // re-measures and re-positions the (now measurable) widget against real + // geometry — this is the same reposition path it runs on editor resize. + return new Promise(resolve => { + // this is fine in fixtures + // eslint-disable-next-line no-restricted-globals + requestAnimationFrame(() => { + editor.layout({ width: 759, height: 260 }); + editor.layout({ width: 760, height: 260 }); + resolve(); + }); + }); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, {}), + }), + + AddComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { placeholder: 'Add Comment' }), + }), + + WithText: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { text: 'Prefer a clearer variable name on this line.' }), + }), + + MultilineText: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { + text: 'This branch needs a stronger explanation.\nAlso consider extracting it into a helper so the intent is explicit.', + }), + }), + + InEditor: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInEditor(context), + }), +}); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts new file mode 100644 index 00000000000..97bf7870999 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -0,0 +1,756 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; +import { WorkspacePicker } from '../../chat/browser/sessionWorkspacePicker.js'; +import { ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { AutomationInterval } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { IAutomationSessionTypeChoice, IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ChatAgentLocation, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js'; +import { isModeConsideredBuiltIn } from '../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; + +const $ = DOM.$; + +const INTERVALS: { readonly value: AutomationInterval; readonly label: string }[] = [ + { value: 'manual', label: localize('automation.interval.manual', "Manual") }, + { value: 'hourly', label: localize('automation.interval.hourly', "Hourly") }, + { value: 'daily', label: localize('automation.interval.daily', "Daily") }, + { value: 'weekly', label: localize('automation.interval.weekly', "Weekly") }, +]; + +// Popup containers (context views, quick picks, menus, hovers) must not trip the dialog's focus-trap. +export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boolean { + return !!relatedTarget.closest( + '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content' + ); +} + +export interface IFormState { + name: string; + interval: AutomationInterval; + hour: number; + minute: number; + day: number; + folderUri: URI | undefined; + providerId: string | undefined; + sessionTypeId: string | undefined; + isolationMode: string | undefined; + branch: string | undefined; + enabled: boolean; +} + +export interface IValidationState { + nameError: string | undefined; + promptError: string | undefined; + folderError: string | undefined; +} + +interface IRenderFormHandle { + readonly getPrompt: () => string; + readonly getMode: () => string | undefined; + readonly getPermissionLevel: () => string | undefined; + readonly getModelId: () => string | undefined; +} + +const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomationsHarnessChip'; +const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup'; + +function createAutomationHarnessChip(): HTMLElement { + const harnessChip = $('span.automation-form-harness-chip'); + DOM.append(harnessChip, renderIcon(Codicon.copilot)); + DOM.append(harnessChip, $('span.automation-form-harness-label', undefined, localize('automation.form.harness', "Copilot CLI"))); + return harnessChip; +} + +class AutomationHarnessChipActionViewItem extends BaseActionViewItem { + constructor(action: IAction, options?: IBaseActionViewItemOptions) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + DOM.clearNode(container); + DOM.append(container, createAutomationHarnessChip()); + } +} + +class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly branchRepoDisposable = this._register(new MutableDisposable()); + private branchRequestId = 0; + private folderChip: HTMLSpanElement | undefined; + private branchSlot: HTMLSpanElement | undefined; + + constructor( + action: IAction, + private readonly state: IFormState, + private readonly workspacePicker: AutomationsWorkspacePicker, + private readonly actionWidgetService: IActionWidgetService, + private readonly gitService: IGitService, + options?: IBaseActionViewItemOptions, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + this.renderDisposables.clear(); + this.branchRepoDisposable.clear(); + DOM.clearNode(container); + container.style.marginLeft = 'auto'; + + const isolationGroup = DOM.append(container, $('span.automation-form-isolation-group')); + this.folderChip = DOM.append(isolationGroup, $('span.automation-form-isolation-chip')) as HTMLSpanElement; + this.folderChip.setAttribute('role', 'button'); + this.folderChip.tabIndex = 0; + this.branchSlot = DOM.append(isolationGroup, $('span.automation-form-branch-slot')) as HTMLSpanElement; + this.branchSlot.setAttribute('aria-live', 'polite'); + + this.renderIsolationChip(); + this.renderBranchLabel(localize('automation.form.branch.unknown', "—"), true); + this.renderDisposables.add(this.workspacePicker.onDidSelectWorkspace(uri => { + this.updateBranchForFolder(uri); + })); + this.renderDisposables.add(DOM.addDisposableListener(this.folderChip, DOM.EventType.CLICK, (e) => { + DOM.EventHelper.stop(e, true); + this.showIsolationPicker(); + })); + this.renderDisposables.add(DOM.addDisposableListener(this.folderChip, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + DOM.EventHelper.stop(e, true); + this.showIsolationPicker(); + } + })); + this.updateBranchForFolder(this.state.folderUri); + } + + private renderIsolationChip(): void { + if (!this.folderChip) { + return; + } + DOM.clearNode(this.folderChip); + const isWorktree = this.state.isolationMode !== 'workspace'; + const modeIcon = isWorktree ? Codicon.worktree : Codicon.folder; + const modeLabel = isWorktree + ? localize('automation.form.isolation.worktree', "Worktree") + : localize('automation.form.isolation.folder', "Folder"); + this.folderChip.setAttribute('aria-label', localize('automation.form.isolation.pickerAriaLabel', "Pick Isolation Mode, {0}", modeLabel)); + this.folderChip.title = modeLabel; + DOM.append(this.folderChip, renderIcon(modeIcon)); + DOM.append(this.folderChip, $('span.automation-form-isolation-label', undefined, modeLabel)); + } + + private renderBranchLabel(text: string, isMissing: boolean): void { + if (!this.branchSlot) { + return; + } + DOM.clearNode(this.branchSlot); + this.branchSlot.classList.toggle('automation-form-branch-missing', isMissing); + DOM.append(this.branchSlot, renderIcon(Codicon.gitBranch)); + DOM.append(this.branchSlot, $('span.automation-form-branch-name', undefined, text)); + } + + private showIsolationPicker(): void { + if (!this.folderChip || this.actionWidgetService.isVisible) { + return; + } + const currentMode = this.state.isolationMode ?? 'worktree'; + const items: IActionListItem<{ readonly mode: string; readonly checked?: boolean }>[] = [ + { + kind: ActionListItemKind.Action, + label: localize('automation.form.isolation.worktree', "Worktree"), + group: { title: '', icon: Codicon.worktree }, + item: { mode: 'worktree', checked: currentMode === 'worktree' || undefined }, + }, + { + kind: ActionListItemKind.Action, + label: localize('automation.form.isolation.folder', "Folder"), + group: { title: '', icon: Codicon.folder }, + item: { mode: 'workspace', checked: currentMode === 'workspace' || undefined }, + }, + ]; + const delegate: IActionListDelegate<{ readonly mode: string; readonly checked?: boolean }> = { + onSelect: ({ mode }) => { + this.actionWidgetService.hide(); + this.state.isolationMode = mode; + this.renderIsolationChip(); + }, + onHide: () => { + this.folderChip?.focus(); + }, + }; + this.actionWidgetService.show( + 'automationIsolationPicker', + false, + items, + delegate, + this.folderChip, + undefined, + [], + { + getAriaLabel: item => item.label ?? '', + getWidgetAriaLabel: () => localize('automation.form.isolation.widgetAriaLabel', "Isolation Mode"), + }, + ); + } + + private async updateBranchForFolder(folder: URI | undefined): Promise { + const myRequestId = ++this.branchRequestId; + this.branchRepoDisposable.clear(); + if (!folder) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noFolder', "—"), true); + return; + } + const repo = await this.gitService.openRepository(folder); + if (myRequestId !== this.branchRequestId) { + return; + } + if (!repo) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noRepo', "no git repo"), true); + return; + } + const watcher = new DisposableStore(); + watcher.add(autorun(reader => { + const head = repo.state.read(reader).HEAD; + const name = head?.name; + if (name) { + this.state.branch = name; + this.renderBranchLabel(name, false); + } else if (head?.commit) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.detached', "({0})", head.commit.slice(0, 7)), false); + } else { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noBranch', "—"), true); + } + })); + this.branchRepoDisposable.value = watcher; + } +} + +registerAction2(class OpenAutomationsHarnessChipAction extends Action2 { + constructor() { + super({ + id: AUTOMATIONS_HARNESS_CHIP_ACTION_ID, + title: localize2('automation.form.harnessChip.action', "Automations Harness Chip"), + f1: false, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.ChatInputSecondary, + group: 'navigation', + order: -1, + when: ChatContextKeys.inAutomationsDialog, + }], + }); + } + + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class OpenAutomationsIsolationGroupAction extends Action2 { + constructor() { + super({ + id: AUTOMATIONS_ISOLATION_GROUP_ACTION_ID, + title: localize2('automation.form.isolationGroup.action', "Automations Isolation Group"), + f1: false, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.ChatInputSecondary, + group: 'navigation', + order: 2, + when: ChatContextKeys.inAutomationsDialog, + }], + }); + } + + override async run(): Promise { /* handled by action view item */ } +}); + +/** + * Two-way binding between the chat input's session-target chip and the form's + * providerId + sessionTypeId fields. + */ +function createSessionTypeBinder( + state: IFormState, + sessionTypeProvider: IAutomationSessionTypeProvider, + disposables: DisposableStore, +): ISessionTypePickerDelegate & { setFolder(folder: URI | undefined): void } { + const onDidChange = disposables.add(new Emitter()); + + const pickDefault = (available: readonly IAutomationSessionTypeChoice[]): IAutomationSessionTypeChoice | undefined => { + if (available.length === 0) { + return undefined; + } + return available.find(c => c.sessionTypeId === AgentSessionProviders.Background) ?? available[0]; + }; + + const validateOrDefault = (folder: URI | undefined): void => { + if (!folder) { + state.providerId = undefined; + state.sessionTypeId = undefined; + return; + } + const available = sessionTypeProvider.getSessionTypesForFolder(folder); + if (state.providerId && state.sessionTypeId) { + const match = available.find(c => c.providerId === state.providerId && c.sessionTypeId === state.sessionTypeId); + if (match) { + return; + } + } + const def = pickDefault(available); + state.providerId = def?.providerId; + state.sessionTypeId = def?.sessionTypeId; + }; + + validateOrDefault(state.folderUri); + + return { + getActiveSessionProvider: () => state.sessionTypeId as AgentSessionTarget | undefined, + setActiveSessionProvider: (target: AgentSessionTarget) => { + // Safe against folder-change races: we read the current folderUri and + // validate target against it. If the folder changed while the picker was + // open, the old target won't match the new folder's available types. + if (!state.folderUri) { + return; + } + const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri); + const match = available.find(c => c.sessionTypeId === target); + if (!match) { + return; + } + state.providerId = match.providerId; + state.sessionTypeId = match.sessionTypeId; + onDidChange.fire(match.sessionTypeId as AgentSessionTarget); + }, + onDidChangeActiveSessionProvider: onDidChange.event, + setFolder: (folder: URI | undefined) => { + validateOrDefault(folder); + if (state.sessionTypeId) { + onDidChange.fire(state.sessionTypeId as AgentSessionTarget); + } + }, + // Only show Local and Background (Copilot CLI). Scoped to what the folder offers. + isSessionTypeVisible: (type: AgentSessionTarget) => { + if (type !== AgentSessionProviders.Local && type !== AgentSessionProviders.Background) { + return false; + } + if (!state.folderUri) { + return true; + } + const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri); + return available.some(c => c.sessionTypeId === type); + }, + }; +} + +export function renderForm( + form: HTMLElement, + state: IFormState, + options: IShowAutomationDialogOptions, + disposables: DisposableStore, + validation: IValidationState, + revalidate: () => void, + instantiationService: IInstantiationService, + contextKeyService: IContextKeyService, + contextViewService: IContextViewService, + configurationService: IConfigurationService, + layoutService: ILayoutService, + logService: ILogService, + productService: IProductService, + sessionTypeProvider: IAutomationSessionTypeProvider, + initialPrompt: string, + initialMode: string | undefined, + initialPermissionLevel: string | undefined, + initialModelId: string | undefined, +): IRenderFormHandle { + const nameRow = DOM.append(form, $('.automation-form-row')); + DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); + const nameInputContainer = DOM.append(nameRow, $('.automation-form-input-host')); + const nameInput = disposables.add(new InputBox(nameInputContainer, contextViewService, { + inputBoxStyles: defaultInputBoxStyles, + placeholder: localize('automation.form.namePlaceholder', "e.g. Morning standup notes"), + ariaLabel: localize('automation.form.name', "Name"), + })); + nameInput.value = state.name; + disposables.add(nameInput.onDidChange(value => { + state.name = value; + revalidate(); + })); + + const scheduleRow = DOM.append(form, $('.automation-form-row.automation-form-schedule-row')); + const useCustomDrawn = !hasNativeContextMenu(configurationService); + + const intervalGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group')); + DOM.append(intervalGroup, $('label.automation-form-label', undefined, localize('automation.form.interval', "Schedule"))); + const intervalOptions: ISelectOptionItem[] = INTERVALS.map(item => ({ text: item.label })); + const intervalIndex = Math.max(0, INTERVALS.findIndex(item => item.value === state.interval)); + const intervalSelect = disposables.add(new SelectBox( + intervalOptions, + intervalIndex, + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.interval', "Schedule"), useCustomDrawn }, + )); + const intervalSelectContainer = DOM.append(intervalGroup, $('.automation-form-schedule-select-container')); + intervalSelect.render(intervalSelectContainer); + + const timeGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group.automation-form-time-group')); + DOM.append(timeGroup, $('label.automation-form-label', undefined, localize('automation.form.time', "Time"))); + const timeOptions = buildTimeOptions(); + const initialTimeIndex = nearestTimeOptionIndex(state.hour, state.minute); + state.hour = timeOptions[initialTimeIndex].hour; + state.minute = timeOptions[initialTimeIndex].minute; + const timeSelect = disposables.add(new SelectBox( + timeOptions.map(opt => ({ text: opt.label } satisfies ISelectOptionItem)), + initialTimeIndex, + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.time', "Time"), useCustomDrawn }, + )); + const timeSelectContainer = DOM.append(timeGroup, $('.automation-form-schedule-select-container.automation-form-time-select-container')); + timeSelect.render(timeSelectContainer); + disposables.add(timeSelect.onDidSelect(e => { + const opt = timeOptions[e.index]; + state.hour = opt.hour; + state.minute = opt.minute; + })); + + const dayGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group.automation-form-day-group')); + DOM.append(dayGroup, $('label.automation-form-label', undefined, localize('automation.form.day', "Day of week"))); + const dayOptions: ISelectOptionItem[] = DAYS_OF_WEEK.map(d => ({ text: d })); + const daySelect = disposables.add(new SelectBox( + dayOptions, + Math.min(Math.max(state.day, 0), DAYS_OF_WEEK.length - 1), + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.day', "Day of week"), useCustomDrawn }, + )); + const daySelectContainer = DOM.append(dayGroup, $('.automation-form-schedule-select-container')); + daySelect.render(daySelectContainer); + disposables.add(daySelect.onDidSelect(e => { + state.day = e.index; + })); + + const applyIntervalVisibility = () => { + const showTime = state.interval === 'daily' || state.interval === 'weekly'; + const showDay = state.interval === 'weekly'; + timeGroup.style.display = showTime ? '' : 'none'; + dayGroup.style.display = showDay ? '' : 'none'; + }; + applyIntervalVisibility(); + disposables.add(intervalSelect.onDidSelect(e => { + state.interval = INTERVALS[e.index].value; + applyIntervalVisibility(); + })); + + const sessionTypeBinder = createSessionTypeBinder(state, sessionTypeProvider, disposables); + + const workspacePicker = disposables.add(instantiationService.createInstance(AutomationsWorkspacePicker)); + + if (state.folderUri) { + workspacePicker.setSelectedWorkspace(state.folderUri, { fireEvent: false }); + } + + disposables.add(workspacePicker.onDidSelectWorkspace(uri => { + state.folderUri = uri; + sessionTypeBinder.setFolder(uri); + revalidate(); + })); + + if (!state.folderUri && workspacePicker.selectedFolderUri) { + state.folderUri = workspacePicker.selectedFolderUri; + sessionTypeBinder.setFolder(state.folderUri); + } + + const promptRow = DOM.append(form, $('.automation-form-row')); + DOM.append(promptRow, $('label.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); + const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); + + const chatInputStyles: IChatInputStyles = { + overlayBackground: 'var(--vscode-input-background)', + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-input-background)', + }; + + const chatInputOptions: IChatInputPartOptions = { + renderFollowups: false, + renderInputToolbarBelowInput: false, + renderWorkingSet: false, + enableImplicitContext: false, + supportsChangingModes: true, + hideCustomChatModes: true, + suppressModePreferredModel: true, + menus: { + executeToolbar: MenuId.AutomationsDialogInput, + telemetrySource: 'automations.dialog', + }, + widgetViewKindTag: 'automations-dialog', + inputEditorMinLines: 3, + sessionTypePickerDelegate: sessionTypeBinder, + workspacePickerInput: workspacePicker, + secondaryToolbarActionViewItemProvider: (action, itemOptions) => { + if (action.id === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { + return new AutomationHarnessChipActionViewItem(action, itemOptions); + } + if (action.id === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID) { + const actionWidgetService = instantiationService.invokeFunction(accessor => accessor.get(IActionWidgetService)); + const gitService = instantiationService.invokeFunction(accessor => accessor.get(IGitService)); + return new AutomationIsolationGroupActionViewItem(action, state, workspacePicker, actionWidgetService, gitService, itemOptions); + } + return undefined; + }, + }; + + // Minimal subset of IChatWidget needed by ChatInputPart in dialog context + type IMinimalChatWidget = Pick; + + const stubWidget: IMinimalChatWidget = { + onDidChangeViewModel: Event.None, + viewModel: undefined, + contribs: [], + location: ChatAgentLocation.Chat, + viewContext: {}, + lockToCodingAgent: () => { }, + unlockFromCodingAgent: () => { }, + }; + + // Bind context keys required by chat input toolbar `when` clauses. + const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptHost)); + ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); + ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); + ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); + const scopedInstantiationService = disposables.add( + instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService])) + ); + + const chatInput = disposables.add( + scopedInstantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, chatInputOptions, chatInputStyles, false), + ); + chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); + chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); + + if (initialMode) { + const getUnfilteredInitialMode = () => { + const modes = chatInput.currentChatModesObs.get(); + return modes.findModeById(initialMode) ?? modes.findModeByName(initialMode); + }; + const isHiddenCustomInitialMode = () => { + const mode = getUnfilteredInitialMode(); + return !!mode && chatInputOptions.hideCustomChatModes && !isModeConsideredBuiltIn(mode, productService); + }; + + if (isHiddenCustomInitialMode()) { + logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}". Falling back to the default mode.`); + } else { + chatInput.setChatMode(initialMode, /* storeSelection */ false); + } + // Retry on cold-start when extension-contributed modes arrive late. + if (chatInput.currentModeObs.get().id !== initialMode && !isHiddenCustomInitialMode()) { + const retry = disposables.add(new MutableDisposable()); + const tryApply = () => { + if (isHiddenCustomInitialMode()) { + logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}" after modes updated. Falling back to the default mode.`); + retry.clear(); + return; + } + const modes = chatInput.currentChatModesObs.get(); + if (modes.findModeById(initialMode) || modes.findModeByName(initialMode)) { + chatInput.setChatMode(initialMode, /* storeSelection */ false); + if (chatInput.currentModeObs.get().id === initialMode) { + retry.clear(); + } + } + }; + retry.value = autorun(reader => { + const modes = chatInput.currentChatModesObs.read(reader); + reader.store.add(modes.onDidChange(tryApply)); + tryApply(); + }); + } + } + if (initialPermissionLevel && isChatPermissionLevel(initialPermissionLevel)) { + chatInput.setPermissionLevel(initialPermissionLevel); + } + // On edit, apply the saved model with late-arrival retry if needed. + chatInput.resetLanguageModelToDefault(/* storeSelection */ false); + + if (initialModelId && !chatInput.switchModelByIdentifier(initialModelId, /* storeSelection */ false)) { + const languageModelsService = instantiationService.invokeFunction(accessor => accessor.get(ILanguageModelsService)); + const baseline = chatInput.selectedLanguageModel.get()?.identifier; + const retry = disposables.add(new MutableDisposable()); + retry.value = languageModelsService.onDidChangeLanguageModels(() => { + if (chatInput.selectedLanguageModel.get()?.identifier !== baseline) { + retry.clear(); + return; + } + if (chatInput.switchModelByIdentifier(initialModelId, /* storeSelection */ false)) { + retry.clear(); + } + }); + } + + disposables.add(chatInput.inputEditor.onDidChangeModelContent(() => { + revalidate(); + })); + + chatInput.layout(580); + queueMicrotask(() => chatInput.layout(580)); + + const resizeObserver = disposables.add(new DOM.DisposableResizeObserver('automationDialog.promptHost', entries => { + for (const entry of entries) { + const width = entry.contentRect.width; + if (width > 0) { + chatInput.layout(width); + } + } + }, DOM.getWindow(promptHost))); + disposables.add(resizeObserver.observe(promptHost)); + + const enabledRow = DOM.append(form, $('.automation-form-row.automation-form-checkbox-row')); + const enabledLabelText = localize('automation.form.enabled', "Enabled (the scheduler runs this automation when due)"); + const enabledCheckbox = disposables.add(new Checkbox(enabledLabelText, state.enabled, defaultCheckboxStyles)); + DOM.append(enabledRow, enabledCheckbox.domNode); + const enabledLabel = DOM.append(enabledRow, $('span.automation-form-checkbox-label', undefined, enabledLabelText)); + const setEnabled = (value: boolean) => { + if (enabledCheckbox.checked !== value) { + enabledCheckbox.checked = value; + } + state.enabled = value; + }; + disposables.add(enabledCheckbox.onChange(() => { + state.enabled = enabledCheckbox.checked; + })); + disposables.add(DOM.addStandardDisposableListener(enabledLabel, 'click', () => { + setEnabled(!enabledCheckbox.checked); + })); + + return { + getPrompt: () => chatInput.inputEditor.getValue(), + getMode: () => chatInput.currentModeObs.get().id, + getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), + getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, + }; +} + +interface ITimeOption { + readonly hour: number; + readonly minute: number; + readonly label: string; +} + +function buildTimeOptions(): readonly ITimeOption[] { + const options: ITimeOption[] = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const period = hour < 12 ? 'AM' : 'PM'; + const hour12 = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour); + const minuteText = minute.toString().padStart(2, '0'); + options.push({ + hour, + minute, + label: `${hour12}:${minuteText} ${period}`, + }); + } + } + return options; +} + +function nearestTimeOptionIndex(hour: number, minute: number): number { + const safeHour = Math.max(0, Math.min(23, hour | 0)); + const safeMinute = Math.max(0, Math.min(59, minute | 0)); + const slot = Math.round(safeMinute / 15) % 4; + const carriedHour = safeMinute >= 53 && slot === 0 ? (safeHour + 1) % 24 : safeHour; + return carriedHour * 4 + slot; +} + +export function updateSaveButtonState( + saveButton: IButton | undefined, + state: IFormState, + validation: IValidationState, + form: HTMLElement, + getPrompt: () => string, +): void { + validation.nameError = state.name.trim() === '' + ? localize('automation.form.nameRequired', "Name is required.") + : undefined; + validation.promptError = getPrompt().trim() === '' + ? localize('automation.form.promptRequired', "Prompt is required.") + : undefined; + validation.folderError = !state.folderUri + ? localize('automation.form.folderRequired', "Workspace folder is required.") + : undefined; + + const valid = !validation.nameError && !validation.promptError && !validation.folderError; + if (saveButton) { + saveButton.enabled = valid; + } + form.classList.toggle('automation-form-invalid', !valid); +} + +// Local-only workspace picker: hides category tabs and non-local browse actions. +class AutomationsWorkspacePicker extends WorkspacePicker { + protected override _showTabs(): boolean { + return false; + } + + protected override _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] { + return super._getAllBrowseActions().filter(a => a.group === SESSION_WORKSPACE_GROUP_LOCAL); + } +} + +// Make Enter insert a newline in the dialog's editor (overrides ChatSubmitAction). +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.action.chat.automationsDialog.insertNewline', + weight: KeybindingWeight.EditorContrib + 100, + when: ContextKeyExpr.and( + EditorContextKeys.textInputFocus, + ChatContextKeys.inAutomationsDialog, + ), + primary: KeyCode.Enter, + handler: (accessor) => { + const editor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + editor?.trigger('keyboard', 'type', { text: '\n' }); + }, +}); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts new file mode 100644 index 00000000000..23a6bfc31a2 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { createWorkbenchDialogOptions } from '../../../../workbench/browser/parts/dialogs/dialog.js'; +import { IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { IFormState, IValidationState, isAutomationDialogPopupTarget, renderForm, updateSaveButtonState } from './automationDialog.js'; + +const $ = DOM.$; + +/** + * Owns the Automations create/edit dialog in the sessions layer, where the + * session-type provider it needs already lives. The workbench list widget + * depends only on {@link IAutomationDialogService}. + */ +export class AutomationDialogService implements IAutomationDialogService { + + declare readonly _serviceBrand: undefined; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILayoutService private readonly layoutService: ILayoutService, + @ILogService private readonly logService: ILogService, + @IProductService private readonly productService: IProductService, + @IHostService private readonly hostService: IHostService, + @IAutomationSessionTypeProvider private readonly sessionTypeProvider: IAutomationSessionTypeProvider, + ) { } + + async showAutomationDialog(options: IShowAutomationDialogOptions): Promise { + const disposables = new DisposableStore(); + + const initial = options.existing; + const isEdit = !!initial; + + const state: IFormState = { + name: initial?.name ?? '', + interval: initial?.schedule.interval ?? 'daily', + hour: initial?.schedule.scheduleHour ?? 9, + minute: initial?.schedule.scheduleMinute ?? 0, + day: initial?.schedule.scheduleDay ?? 1, + folderUri: initial?.folderUri, + providerId: initial?.providerId, + sessionTypeId: initial?.sessionTypeId, + isolationMode: initial?.isolationMode, + branch: initial?.branch, + enabled: initial?.enabled ?? true, + }; + + const validation: IValidationState = { nameError: undefined, promptError: undefined, folderError: undefined }; + + let saveButton: IButton | undefined; + let revalidate: () => void = () => { }; + let getPrompt: () => string = () => initial?.prompt ?? ''; + let getMode: () => string | undefined = () => initial?.mode; + let getPermissionLevel: () => string | undefined = () => initial?.permissionLevel; + let getModelId: () => string | undefined = () => initial?.modelId; + + const title = isEdit + ? localize('automation.dialog.editTitle', "Edit automation") + : localize('automation.dialog.createTitle', "New automation"); + + const buttonLabels = [ + isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"), + localize('automation.dialog.cancel', "Cancel"), + ]; + + const dialog = disposables.add(new Dialog( + this.layoutService.activeContainer, + title, + buttonLabels, + createWorkbenchDialogOptions({ + type: 'none', + extraClasses: ['automation-dialog'], + cancelId: 1, + isExternalFocusAllowed: isAutomationDialogPopupTarget, + // textLinkForeground stamps inline styles onto chat input picker chips. + dialogStyles: { ...defaultDialogStyles, textLinkForeground: undefined }, + buttonOptions: [ + { + styleButton: button => { + saveButton = button; + revalidate(); + }, + }, + ], + renderBody: container => { + container.classList.add('automation-dialog-body'); + + const titlebar = DOM.append(container, $('.automation-titlebar')); + titlebar.setAttribute('aria-hidden', 'true'); + titlebar.textContent = title; + + const description = DOM.append(container, $('.automation-description')); + description.textContent = isEdit + ? localize('automation.dialog.editDescription', "Update the schedule, prompt, or run target for this automation.") + : localize('automation.dialog.createDescription', "Define a prompt that Copilot will run on a schedule against the selected folder."); + + const formPane = DOM.append(container, $('.automation-form-pane')); + const form = DOM.append(formPane, $('.automation-form')); + const handle = renderForm(form, state, options, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.productService, this.sessionTypeProvider, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId); + getPrompt = handle.getPrompt; + getMode = handle.getMode; + getPermissionLevel = handle.getPermissionLevel; + getModelId = handle.getModelId; + revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt); + revalidate(); + }, + }, this.keybindingService, this.layoutService, this.hostService), + )); + + try { + const result = await dialog.show(); + if (result.button !== 0) { + return undefined; + } + // Guard against submit-with-Enter bypassing live validation. + revalidate(); + if (validation.nameError || validation.promptError || validation.folderError) { + return undefined; + } + if (!state.folderUri) { + return undefined; + } + + const schedule: IAutomationSchedule = { + interval: state.interval, + scheduleHour: state.hour, + scheduleMinute: state.minute, + scheduleDay: state.day, + }; + + const prompt = getPrompt(); + const mode = getMode(); + const permissionLevel = getPermissionLevel(); + const modelId = getModelId(); + + if (isEdit && initial) { + const patch: IUpdateAutomationOptions = { + name: state.name, + prompt, + schedule, + folderUri: state.folderUri, + providerId: state.providerId ?? null, + sessionTypeId: state.sessionTypeId ?? null, + modelId: modelId ?? null, + mode: mode ?? null, + permissionLevel: permissionLevel ?? null, + isolationMode: state.isolationMode ?? null, + branch: state.branch ?? null, + enabled: state.enabled, + }; + return { kind: 'update', id: initial.id, value: patch }; + } + + const create: ICreateAutomationOptions = { + name: state.name, + prompt, + schedule, + folderUri: state.folderUri, + providerId: state.providerId, + sessionTypeId: state.sessionTypeId, + modelId, + mode, + permissionLevel, + isolationMode: state.isolationMode, + branch: state.branch, + enabled: state.enabled, + }; + return { kind: 'create', value: create }; + } finally { + disposables.dispose(); + } + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts b/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts new file mode 100644 index 00000000000..9d78892b73d --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { IAutomationSessionTypeChoice, IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; + +/** + * Sessions-layer implementation of {@link IAutomationSessionTypeProvider}. + * Decouples the workbench UI from the Sessions layer. + * When multiple providers offer the same session type for a folder, adds a + * provider label as description so the user can tell them apart. + */ +export class AutomationSessionTypeProvider implements IAutomationSessionTypeProvider { + + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { } + + getSessionTypesForFolder(folderUri: URI): readonly IAutomationSessionTypeChoice[] { + const provided = this.sessionsManagementService.getSessionTypesForFolder(folderUri); + const providersForType = new Map(); + for (const entry of provided) { + providersForType.set(entry.sessionType.id, (providersForType.get(entry.sessionType.id) ?? 0) + 1); + } + const providers = this.sessionsProvidersService.getProviders(); + const providerLabel = (id: string): string | undefined => providers.find(p => p.id === id)?.label; + return provided.map(entry => { + const needsDescription = (providersForType.get(entry.sessionType.id) ?? 0) > 1; + return { + providerId: entry.providerId, + sessionTypeId: entry.sessionType.id, + label: entry.sessionType.label, + description: needsDescription ? providerLabel(entry.providerId) : undefined, + }; + }); + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts index dd33bbfda18..bc8c383f0c0 100644 --- a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -5,6 +5,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; @@ -14,20 +15,29 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { Registry } from '../../../../platform/registry/common/platform.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AutomationsAccessibilityHelp } from '../../../../workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IAutomationDialogService } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; import { publishAutomationToggled } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING, CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING, DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { AutomationDialogService } from './automationDialogService.js'; import { AutomationRunner } from './automationRunner.js'; import { AutomationScheduler } from './automationScheduler.js'; import { AutomationService } from './automationService.js'; +import { AutomationSessionTypeProvider } from './automationSessionTypeProvider.js'; registerSingleton(IAutomationService, AutomationService, InstantiationType.Delayed); registerSingleton(IAutomationRunner, AutomationRunner, InstantiationType.Delayed); +registerSingleton(IAutomationSessionTypeProvider, AutomationSessionTypeProvider, InstantiationType.Delayed); +registerSingleton(IAutomationDialogService, AutomationDialogService, InstantiationType.Delayed); registerWorkbenchContribution2(AutomationScheduler.ID, AutomationScheduler, WorkbenchPhase.Eventually); +AccessibleViewRegistry.register(new AutomationsAccessibilityHelp()); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'chat', properties: { diff --git a/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts new file mode 100644 index 00000000000..97b348334f5 --- /dev/null +++ b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derivedOpts, IObservable, IReaderWithStore, observableFromEvent } from '../../../../base/common/observable.js'; +import { equals } from '../../../../base/common/arrays.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { computePullRequestIconStatus } from '../../github/browser/pullRequestIconStatus.js'; + +/** + * Why a session is surfaced as "blocked" (i.e. needs the user's attention). + */ +export const enum BlockedSessionReason { + /** The session is waiting for the user to provide input or approve an action. */ + NeedsInput = 'needsInput', + /** The session's pull request has failing CI checks. */ + FailingCI = 'failingCI', + /** The session's pull request has unresolved review comments. */ + UnresolvedComments = 'unresolvedComments', +} + +/** A blocked session paired with the reason it needs attention. */ +export interface IBlockedSession { + readonly session: ISession; + readonly reason: BlockedSessionReason; +} + +/** + * Surfaces the set of "blocked" sessions — sessions that require the user's + * attention. A session is considered blocked when it: + * + * - needs input (`SessionStatus.NeedsInput`), or + * - has failing CI checks while not in progress, or + * - has unresolved pull request comments while not in progress. + * + * Archived (done) sessions are never reported as blocked. + */ +export class BlockedSessions extends Disposable { + + private readonly _allSessions: IObservable; + + /** The blocked sessions, most-recently-updated first. */ + readonly blockedSessions: IObservable; + + /** The blocked sessions paired with their reason, most-recently-updated first. */ + readonly blockedSessionsWithReasons: IObservable; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IGitHubService private readonly _gitHubService: IGitHubService, + ) { + super(); + + this._allSessions = observableFromEvent( + this, + this._sessionsManagementService.onDidChangeSessions, + () => this._sessionsManagementService.getSessions(), + ); + + // Structural equality keeps the deriveds from propagating when a recompute + // (e.g. an `updatedAt` tick that doesn't reorder, or a reason-only change) + // yields the same result, so downstream autoruns/renders don't churn. + this.blockedSessionsWithReasons = derivedOpts({ + owner: this, + equalsFn: (a, b) => equals(a, b, (x, y) => x.session.sessionId === y.session.sessionId && x.reason === y.reason), + }, reader => { + const blocked: IBlockedSession[] = []; + for (const session of this._allSessions.read(reader)) { + // `derivedOpts` under-types the store-backed reader as `IReader`; it is an `IDerivedReader` at runtime. + const reason = this._getBlockedReason(reader as IReaderWithStore, session); + if (reason !== undefined) { + blocked.push({ session, reason }); + } + } + return blocked.sort((a, b) => b.session.updatedAt.read(reader).getTime() - a.session.updatedAt.read(reader).getTime()); + }); + + this.blockedSessions = derivedOpts({ + owner: this, + equalsFn: (a, b) => equals(a, b, (x, y) => x.sessionId === y.sessionId), + }, reader => this.blockedSessionsWithReasons.read(reader).map(blocked => blocked.session)); + } + + private _getBlockedReason(reader: IReaderWithStore, session: ISession): BlockedSessionReason | undefined { + if (session.isArchived.read(reader)) { + return undefined; + } + + const status = session.status.read(reader); + if (status === SessionStatus.NeedsInput) { + return BlockedSessionReason.NeedsInput; + } + + // CI failures and pull request comments only count while the session is + // not actively in progress. + if (status === SessionStatus.InProgress) { + return undefined; + } + + const gitHubInfo = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; + } + + const prRef = reader.store.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); + const livePR = prRef.object.pullRequest.read(reader); + if (!livePR) { + return undefined; + } + + const prStatus = computePullRequestIconStatus(reader, this._gitHubService, gitHubInfo.owner, gitHubInfo.repo, livePR); + if (prStatus.hasFailingChecks) { + return BlockedSessionReason.FailingCI; + } + if (prStatus.hasUnresolvedComments) { + return BlockedSessionReason.UnresolvedComments; + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts new file mode 100644 index 00000000000..886ea713eff --- /dev/null +++ b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore, ImmortalReference, type IReference } from '../../../../../base/common/lifecycle.js'; +import { autorun, ISettableObservable, observableValue, type IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { GitHubPullRequestCIModel } from '../../../github/browser/models/githubPullRequestCIModel.js'; +import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../../github/browser/models/githubPullRequestReviewThreadsModel.js'; +import { GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IGitHubPullRequestReviewThread } from '../../../github/common/types.js'; +import { ISession, IGitHubInfo, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { BlockedSessionReason, BlockedSessions } from '../../browser/blockedSessions.js'; + +suite('BlockedSessions', () => { + + const store = new DisposableStore(); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(sessions: TestSession[], gitHubService: TestGitHubService): { service: BlockedSessions; management: TestSessionsManagementService } { + const management = new TestSessionsManagementService(sessions as unknown as ISession[]); + const service = store.add(new BlockedSessions(management as unknown as ISessionsManagementService, gitHubService as unknown as IGitHubService)); + // Keep the derived live so per-session model references are actually read. + store.add(autorun(reader => { service.blockedSessions.read(reader); })); + return { service, management }; + } + + function blockedIds(service: BlockedSessions): string[] { + return service.blockedSessions.get().map(s => s.sessionId); + } + + function blockedReasons(service: BlockedSessions): Array<[string, BlockedSessionReason]> { + return service.blockedSessionsWithReasons.get().map((b): [string, BlockedSessionReason] => [b.session.sessionId, b.reason]); + } + + test('session needing input is blocked', () => { + const session = new TestSession('s1', SessionStatus.NeedsInput); + const { service } = createService([session], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), ['s1']); + }); + + test('in-progress, completed (no PR) and archived sessions are not blocked', () => { + const inProgress = new TestSession('inprogress', SessionStatus.InProgress); + const completed = new TestSession('completed', SessionStatus.Completed); + const archived = new TestSession('archived', SessionStatus.NeedsInput, { archived: true }); + const { service } = createService([inProgress, completed, archived], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('completed session with failing CI checks is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 7, openPullRequest(7, 'sha7')); + gitHub.setCIStatus('owner', 'repo', 7, 'sha7', GitHubCIOverallStatus.Failure); + const session = new TestSession('ci', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 7 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), ['ci']); + }); + + test('completed session with unresolved PR comments is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 8, openPullRequest(8, 'sha8')); + gitHub.setReviewThreads('owner', 'repo', 8, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const session = new TestSession('comments', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 8 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), ['comments']); + }); + + test('in-progress session with failing CI is not blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 9, openPullRequest(9, 'sha9')); + gitHub.setCIStatus('owner', 'repo', 9, 'sha9', GitHubCIOverallStatus.Failure); + const session = new TestSession('busy', SessionStatus.InProgress, { pr: { owner: 'owner', repo: 'repo', number: 9 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('completed session with passing CI and resolved comments is not blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 10, openPullRequest(10, 'sha10')); + gitHub.setCIStatus('owner', 'repo', 10, 'sha10', GitHubCIOverallStatus.Success); + gitHub.setReviewThreads('owner', 'repo', 10, [{ isResolved: true } as IGitHubPullRequestReviewThread]); + const session = new TestSession('clean', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 10 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('blocked sessions update reactively when status changes', () => { + const session = new TestSession('reactive', SessionStatus.Completed); + const { service } = createService([session], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), []); + session.setStatus(SessionStatus.NeedsInput); + assert.deepStrictEqual(blockedIds(service), ['reactive']); + }); + + test('blocked sessions are sorted most-recently-updated first', () => { + const older = new TestSession('older', SessionStatus.NeedsInput, { updatedAt: new Date(1000) }); + const newer = new TestSession('newer', SessionStatus.NeedsInput, { updatedAt: new Date(5000) }); + const { service } = createService([older, newer], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), ['newer', 'older']); + }); + + test('reports the reason each session is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 20, openPullRequest(20, 'sha20')); + gitHub.setCIStatus('owner', 'repo', 20, 'sha20', GitHubCIOverallStatus.Failure); + gitHub.setPullRequest('owner', 'repo', 21, openPullRequest(21, 'sha21')); + gitHub.setReviewThreads('owner', 'repo', 21, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const needsInput = new TestSession('needsinput', SessionStatus.NeedsInput, { updatedAt: new Date(3000) }); + const failingCI = new TestSession('failingci', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 20 }, updatedAt: new Date(2000) }); + const comments = new TestSession('comments', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 21 }, updatedAt: new Date(1000) }); + const { service } = createService([needsInput, failingCI, comments], gitHub); + assert.deepStrictEqual(blockedReasons(service), [ + ['needsinput', BlockedSessionReason.NeedsInput], + ['failingci', BlockedSessionReason.FailingCI], + ['comments', BlockedSessionReason.UnresolvedComments], + ]); + }); + + test('failing CI takes precedence over unresolved comments', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 22, openPullRequest(22, 'sha22')); + gitHub.setCIStatus('owner', 'repo', 22, 'sha22', GitHubCIOverallStatus.Failure); + gitHub.setReviewThreads('owner', 'repo', 22, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const session = new TestSession('both', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 22 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedReasons(service), [['both', BlockedSessionReason.FailingCI]]); + }); +}); + +function openPullRequest(number: number, headSha: string): IGitHubPullRequest { + return { number, headSha, isDraft: false, state: GitHubPullRequestState.Open } as unknown as IGitHubPullRequest; +} + +interface ITestSessionOptions { + readonly archived?: boolean; + readonly pr?: { owner: string; repo: string; number: number }; + readonly updatedAt?: Date; +} + +class TestSession { + readonly sessionId: string; + readonly resource: URI; + readonly status: ISettableObservable; + readonly isArchived: IObservable; + readonly updatedAt: IObservable; + readonly workspace: IObservable; + + private readonly _status: ISettableObservable; + + constructor(id: string, status: SessionStatus, options: ITestSessionOptions = {}) { + this.sessionId = id; + this.resource = URI.parse(`test-session:/${id}`); + this._status = observableValue(`test.status.${id}`, status); + this.status = this._status; + this.isArchived = observableValue(`test.archived.${id}`, options.archived ?? false); + this.updatedAt = observableValue(`test.updatedAt.${id}`, options.updatedAt ?? new Date(0)); + + const gitHubInfo: IGitHubInfo | undefined = options.pr + ? { owner: options.pr.owner, repo: options.pr.repo, pullRequest: { number: options.pr.number, uri: URI.parse(`https://github.com/${options.pr.owner}/${options.pr.repo}/pull/${options.pr.number}`) } } + : undefined; + const gitHubInfoObs = observableValue(`test.gitHubInfo.${id}`, gitHubInfo); + this.workspace = observableValue(`test.workspace.${id}`, { + folders: [{ gitRepository: { gitHubInfo: gitHubInfoObs } }], + }); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } +} + +class TestSessionsManagementService extends mock() { + + private readonly _onDidChangeSessions = new Emitter(); + override readonly onDidChangeSessions = this._onDidChangeSessions.event; + + constructor(private readonly _sessions: ISession[]) { + super(); + } + + override getSessions(): ISession[] { + return this._sessions; + } + + override getSession(resource: URI): ISession | undefined { + return this._sessions.find(s => s.resource.toString() === resource.toString()); + } +} + +class TestGitHubService extends mock() { + + private readonly _prModels = new Map(); + private readonly _ciModels = new Map(); + private readonly _reviewThreadModels = new Map(); + + override createPullRequestModelReference(owner: string, repo: string, prNumber: number): IReference { + return new ImmortalReference(this._prModel(owner, repo, prNumber) as unknown as GitHubPullRequestModel); + } + + override createPullRequestCIModelReference(owner: string, repo: string, prNumber: number, headSha: string): IReference { + return new ImmortalReference(this._ciModel(owner, repo, prNumber, headSha) as unknown as GitHubPullRequestCIModel); + } + + override createPullRequestReviewThreadsModelReference(owner: string, repo: string, prNumber: number): IReference { + return new ImmortalReference(this._reviewThreadModel(owner, repo, prNumber) as unknown as GitHubPullRequestReviewThreadsModel); + } + + setPullRequest(owner: string, repo: string, prNumber: number, pullRequest: IGitHubPullRequest): void { + this._prModel(owner, repo, prNumber).set(pullRequest); + } + + setCIStatus(owner: string, repo: string, prNumber: number, headSha: string, status: GitHubCIOverallStatus): void { + this._ciModel(owner, repo, prNumber, headSha).set(status); + } + + setReviewThreads(owner: string, repo: string, prNumber: number, threads: readonly IGitHubPullRequestReviewThread[]): void { + this._reviewThreadModel(owner, repo, prNumber).set(threads); + } + + private _prModel(owner: string, repo: string, prNumber: number): TestPullRequestModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._prModels.get(key); + if (!model) { + model = new TestPullRequestModel(); + this._prModels.set(key, model); + } + return model; + } + + private _ciModel(owner: string, repo: string, prNumber: number, headSha: string): TestCIModel { + const key = `${owner}/${repo}/${prNumber}/${headSha}`; + let model = this._ciModels.get(key); + if (!model) { + model = new TestCIModel(); + this._ciModels.set(key, model); + } + return model; + } + + private _reviewThreadModel(owner: string, repo: string, prNumber: number): TestReviewThreadsModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._reviewThreadModels.get(key); + if (!model) { + model = new TestReviewThreadsModel(); + this._reviewThreadModels.set(key, model); + } + return model; + } +} + +class TestPullRequestModel { + private readonly _pullRequest = observableValue('test.pullRequest', undefined); + readonly pullRequest: IObservable = this._pullRequest; + set(pullRequest: IGitHubPullRequest): void { this._pullRequest.set(pullRequest, undefined); } +} + +class TestCIModel { + private readonly _overallStatus = observableValue('test.ciStatus', GitHubCIOverallStatus.Neutral); + readonly overallStatus: IObservable = this._overallStatus; + set(status: GitHubCIOverallStatus): void { this._overallStatus.set(status, undefined); } +} + +class TestReviewThreadsModel { + private readonly _reviewThreads = observableValue('test.reviewThreads', []); + readonly reviewThreads: IObservable = this._reviewThreads; + set(threads: readonly IGitHubPullRequestReviewThread[]): void { this._reviewThreads.set(threads, undefined); } +} diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts index f778f812f1e..bab5739bcc4 100644 --- a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts +++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; @@ -59,15 +60,16 @@ export class SessionBrowserViewController extends Disposable implements IWorkben this._register(this._browserViewService.registerContextualFilter({ include: (input, context) => { const tracked = this._trackedInputs.get(input.id); - // `owner.sessionId` is the session *resource* URI string (set by the - // browser tools from `sessionResource.toString()`), not the composite - // `ISession.sessionId` (`providerId:resource`). Compare resource-to-resource. - const sessionResource = input.model?.owner.sessionId ?? tracked?.session.resource.toString(); - if (!sessionResource) { + const ownerId = input.model?.owner.sessionId ?? tracked?.session.resource.toString(); + if (!ownerId) { return true; // no owning session known } - const activeSessionResource = context.activeSessionId ?? this._sessionsService.activeSession.read(undefined)?.resource.toString(); - return sessionResource === activeSessionResource; + const owningSession = this._resolveOwningSession(ownerId) ?? tracked?.session; + if (!owningSession) { + return true; // owning chat/session no longer known; don't hide it + } + const activeSession = context.activeSessionId ? this._resolveOwningSession(context.activeSessionId) : this._sessionsService.activeSession.read(undefined); + return activeSession?.sessionId === owningSession.sessionId; }, onDidChange: onDidChangeActiveSession.event, })); @@ -78,8 +80,12 @@ export class SessionBrowserViewController extends Disposable implements IWorkben if (!owner.sessionId) { return true; // no owning session known; open in the active session } - const activeSessionResource = this._sessionsService.activeSession.read(undefined)?.resource.toString(); - return owner.sessionId === activeSessionResource; + const owningSession = this._resolveOwningSession(owner.sessionId); + if (!owningSession) { + return true; // owning chat/session no longer known; open in the active session + } + const activeSession = this._sessionsService.activeSession.read(undefined); + return owningSession.sessionId === activeSession?.sessionId; }, })); @@ -102,6 +108,24 @@ export class SessionBrowserViewController extends Disposable implements IWorkben })); } + /** + * Resolves a browser view owner id (the *chat* resource string the + * browser tools stamp onto `IBrowserViewOwner.sessionId`) to the + * `ISession` that owns it. A session's browsers can be opened from any + * of its chats (main, peer, or subagent), so this looks up the owning + * session across all chats rather than comparing against the session's + * own resource directly. + */ + private _resolveOwningSession(ownerId: string): ISession | undefined { + let resource: URI; + try { + resource = URI.parse(ownerId); + } catch { + return undefined; + } + return this._sessionManagementService.getSessionForChatResource(resource)?.session ?? this._sessionManagementService.getSession(resource); + } + private _attachLifecycle(input: BrowserEditorInput): void { if (this._trackedInputs.has(input.id)) { return; diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index 219b164efcb..62d52b2703e 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -4,14 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; -import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { ChangesViewPane, SinglePaneChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { SessionChangesEditor } from './sessionChangesEditor.js'; +import { SessionChangesEditorInput, SessionChangesEditorSerializer } from './sessionChangesEditorInput.js'; import { IsPhoneLayoutContext, SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ISessionChangesService, SessionChangesService } from './sessionChangesService.js'; @@ -24,9 +30,43 @@ import { ChangesViewService } from './changesViewService.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SessionsChangesAccessibilityHelp } from './sessionsChangesAccessibilityHelp.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed); +/** + * Registers the custom single-pane Changes editor (multi-diff pane with the header + * toolbar) and its serializer, only when the single-pane layout is enabled. In the + * standard layout, changes open as a plain multi-diff editor instead. Registered at + * startup (before editor restore) so persisted Changes tabs can be deserialized. + */ +class SinglePaneChangesEditorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneChangesEditor'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create(SessionChangesEditor, SessionChangesEditor.ID, localize('sessionChangesEditor.label', "Changes")), + [new SyncDescriptor(SessionChangesEditorInput)] + )); + + this._register(Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + SessionChangesEditorInput.ID, + SessionChangesEditorSerializer + )); + } +} + +registerWorkbenchContribution2(SinglePaneChangesEditorContribution.ID, SinglePaneChangesEditorContribution, WorkbenchPhase.BlockStartup); + AccessibleViewRegistry.register(new SessionsChangesAccessibilityHelp()); @@ -58,18 +98,38 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); -viewsRegistry.registerViews([{ - id: CHANGES_VIEW_ID, - name: localize2('changes', 'Changes'), - containerIcon: changesViewIcon, - ctorDescriptor: new SyncDescriptor(ChangesViewPane), - canToggleVisibility: false, - canMoveView: false, - weight: 100, - order: 1, - when: ContextKeyExpr.and(IsPhoneLayoutContext.negate(), SessionHasWorkspaceContext), - windowEnablement: WindowEnablement.Sessions, -}], changesViewContainer); +/** + * Registers the Changes view with the layout-appropriate pane class: the single-pane + * {@link SinglePaneChangesViewPane} when the single-pane layout is enabled, otherwise + * the standard {@link ChangesViewPane}. Registered at startup (the setting is resolved + * once; toggling requires a window reload). + */ +class ChangesViewContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.changesView'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + const ctor = layoutService.isSinglePaneLayoutEnabled ? SinglePaneChangesViewPane : ChangesViewPane; + viewsRegistry.registerViews([{ + id: CHANGES_VIEW_ID, + name: localize2('changes', 'Changes'), + containerIcon: changesViewIcon, + ctorDescriptor: new SyncDescriptor(ctor), + canToggleVisibility: false, + canMoveView: false, + weight: 100, + order: 1, + when: ContextKeyExpr.and(IsPhoneLayoutContext.negate(), SessionHasWorkspaceContext), + windowEnablement: WindowEnablement.Sessions, + }], changesViewContainer); + } +} + +registerWorkbenchContribution2(ChangesViewContribution.ID, ChangesViewContribution, WorkbenchPhase.BlockStartup); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index ed90c751fba..9a565fb0a07 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -9,25 +9,28 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; import { SessionHasChangesContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionChangesetOperationScope, SessionChangesetOperationStatus } from '../../../services/sessions/common/session.js'; +import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IChangesViewService } from '../common/changesViewService.js'; -import { ChangesMultiDiffSourceResolver } from './changesMultiDiffSourceResolver.js'; +import { ChangesMultiDiffSourceResolver, SessionChangesFileResourceContext, SessionChangesReviewedFilesContext } from './changesMultiDiffSourceResolver.js'; import { ISessionChangesService } from './sessionChangesService.js'; +import { isEqual } from '../../../../base/common/resources.js'; // --- View All Changes action @@ -53,7 +56,6 @@ class ViewAllChangesAction extends Action2 { } override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { - const editorService = accessor.get(IEditorService); const sessionsService = accessor.get(ISessionsService); const sessionChangesService = accessor.get(ISessionChangesService); const changesViewService = accessor.get(IChangesViewService); @@ -71,13 +73,10 @@ class ViewAllChangesAction extends Action2 { // (a shared per-session resource) shows the same changes as the pill. changesViewService.setChangesetId(undefined); - // Open the multi-file diff editor in the editor part. The resource list is + // Open the session Changes editor in the editor part. The resource list is // resolved reactively via the `ChangesMultiDiffSourceResolver` registered as // a workbench contribution. - await editorService.openEditor({ - multiDiffSource: sessionChangesService.getChangesEditorResource(sessionResource), - label: localize('sessions.changes.title', 'Session Changes'), - }); + await sessionChangesService.openChangesEditor(sessionResource); } } registerAction2(ViewAllChangesAction); @@ -252,12 +251,41 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme static readonly ID = 'workbench.contrib.sessions.changesetOperationsActionController'; constructor( - @IChangesViewService private readonly _changesViewService: IChangesViewService + @IChangesViewService changesViewService: IChangesViewService, + @IContextKeyService contextKeyService: IContextKeyService ) { super(); + // Use to optimistically update the toolbars until the server confirms + // the state. As soon as the server confirms the state, the client array + // will be reset to `undefined` so that the server state takes precedence. + const clientReviewedFilesObs = observableValue(this, undefined); + + // Authoritative source of reviewed files. This will be updated + // when the state is saved on the server and confirmed back to + // the client + const agentHostReviewedFilesObs = observableValue(this, []); + this._register(autorun(reader => { - const changeset = this._changesViewService.activeSessionChangesetObs.read(reader); + const changes = changesViewService.activeSessionChangesObs.read(reader); + + const reviewedFiles = changes + .filter(change => change.reviewed) + .map(change => change.modifiedUri?.toString() ?? change.originalUri?.toString()) + .filter((uri: string | undefined) => uri !== undefined); + + transaction(tx => { + clientReviewedFilesObs.set(undefined, tx); + agentHostReviewedFilesObs.set(reviewedFiles, tx); + }); + })); + + this._register(bindContextKey(SessionChangesReviewedFilesContext, contextKeyService, reader => { + return clientReviewedFilesObs.read(reader) ?? agentHostReviewedFilesObs.read(reader); + })); + + this._register(autorun(reader => { + const changeset = changesViewService.activeSessionChangesetObs.read(reader); const resourceOperations = (changeset?.operations.read(reader) ?? []) .filter(op => op.scopes.includes(SessionChangesetOperationScope.Resource)); @@ -273,26 +301,66 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme title: operation.label, icon: operation.icon, f1: false, - precondition: operation.status === SessionChangesetOperationStatus.Disabled || operation.status === SessionChangesetOperationStatus.Running - ? ContextKeyExpr.false() - : ContextKeyExpr.true(), + toggled: ContextKeyExpr.in( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key), menu: { id: MenuId.MultiDiffEditorFileToolbar, - when: ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + // This is a temporary solution until the agent host protocol + // adds support to specify operations for each individual file + when: operation.group === 'review' + ? operation.id === 'mark-as-reviewed' + ? ContextKeyExpr.and( + ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + ContextKeyExpr.notIn( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key)) + : ContextKeyExpr.and( + ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + ContextKeyExpr.in( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key)) + : ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), group: 'navigation', order: 100 } }); } - async run(_accessor: ServicesAccessor, ...args: unknown[]): Promise { + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const activeEditorPane = accessor.get(IEditorService).activeEditorPane; + if (args.length === 0 || !(args[0] instanceof URI)) { return; } + const resource = args[0]; + + // Optimistic update the state + if (operation.id === 'mark-as-reviewed') { + // Update context key for the toolbar + const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined); + clientReviewedFilesObs.set([...agentHostReviewedFiles, resource.toString()], undefined); + + // Collapse multi-file diff editor item + if (activeEditorPane instanceof MultiDiffEditor) { + const viewModel = activeEditorPane.viewModel; + const item = viewModel?.items.read(undefined) + .find(i => isEqual(i.modifiedUri, resource) || isEqual(i.originalUri, resource)); + + if (item) { + viewModel!.collapse(item); + } + } + } else if (operation.id === 'mark-as-unreviewed') { + // Update context key for the toolbar + const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined); + clientReviewedFilesObs.set([...agentHostReviewedFiles.filter(f => f !== resource.toString())], undefined); + } + await changeset?.invokeOperation(operation.id, { kind: 'resource', - resource: args[0], + resource, }); } })); diff --git a/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts index 7dcd2d54246..eaa289efd78 100644 --- a/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts +++ b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts @@ -14,6 +14,23 @@ import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMul import { ISessionFileChange } from '../../../services/sessions/common/session.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { ISessionChangesService } from './sessionChangesService.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; + +/** + * Global context key holding the URIs (as strings) of every file the user has + * marked as reviewed in the active session. Combined with + * {@link SessionChangesFileResourceContext} via the `in` / `not in` operators, + * file toolbar menu items can toggle between "Mark as Reviewed" and + * "Unmark as Reviewed". + */ +export const SessionChangesReviewedFilesContext = new RawContextKey('sessions.changesReviewedFiles', []); + +/** + * Per-file context key set on each entry in the changes multi-diff editor. + * Holds the URI (as a string) of the file shown in that diff row, so it can be + * tested for membership in {@link SessionChangesReviewedFilesContext}. + */ +export const SessionChangesFileResourceContext = new RawContextKey('sessions.changesFileResource', undefined); function compareChanges(a: ISessionFileChange, b: ISessionFileChange): number { const aPath = isIChatSessionFileChange2(a) ? a.uri.fsPath : a.modifiedUri.fsPath; @@ -62,7 +79,9 @@ export class ChangesMultiDiffSourceResolver extends Disposable implements IMulti }, reader => { const changes = changesObs.read(reader); return [...changes].sort(compareChanges).map(change => - new MultiDiffEditorItem(change.originalUri, change.modifiedUri, change.modifiedUri)); + new MultiDiffEditorItem(change.originalUri, change.modifiedUri, change.modifiedUri, undefined, { + [SessionChangesFileResourceContext.key]: change.modifiedUri?.toString() ?? change.originalUri?.toString() ?? '', + })); }); return { resources: new ValueWithChangeEventFromObservable(resourcesObs) }; diff --git a/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts new file mode 100644 index 00000000000..27364ef64ed --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesSummaryWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { ISessionChangesSummary } from '../../../services/sessions/common/session.js'; +import { IChangesViewService } from '../common/changesViewService.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Throttler } from '../../../../base/common/async.js'; + +export class ChangesSummaryWidget extends Disposable { + private readonly _summaryObs: IObservable; + get summary() { return this._summaryObs; } + + constructor( + @IChangesViewService changesViewService: IChangesViewService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + const summaryRawObs = derivedObservableWithCache(this, (reader, lastValue) => { + const isLoading = changesViewService.activeSessionLoadingObs.read(reader); + if (isLoading) { + return lastValue; + } + + const entries = changesViewService.activeSessionChangesObs.read(reader); + if (entries.length === 0) { + return undefined; + } + + let additions = 0, deletions = 0; + for (const entry of entries) { + additions += entry.insertions; + deletions += entry.deletions; + } + + return { + additions, + deletions, + files: entries.length, + } satisfies ISessionChangesSummary; + }); + + this._summaryObs = derivedOpts({ + equalsFn: structuralEquals + }, reader => summaryRawObs.read(reader)); + } + + render(container: HTMLElement) { + const element = dom.$('div.changes-summary-widget'); + container.appendChild(element); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, element, { + prefix: '+', + direction: 'topToBottom', + cssClassName: 'changes-summary-lines-added', + count: derived(this, (reader) => { + return this._summaryObs.read(reader)?.additions; + }) + })); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, element, { + prefix: '-', + direction: 'bottomToTop', + cssClassName: 'changes-summary-lines-removed', + count: derived(this, (reader) => { + return this._summaryObs.read(reader)?.deletions; + }) + })); + } +} + +interface IAnimatedCounterWidgetOptions { + readonly prefix?: string; + readonly cssClassName?: string; + /** + * The direction of the animation when the count + * increases. The direction will be the opposite + * when the count decreases. + * */ + readonly direction?: 'topToBottom' | 'bottomToTop'; + readonly duration?: number; + readonly count: IObservable; +} + +class AnimatedCounterWidget extends Disposable { + private _element: HTMLElement; + private _count: number | undefined; + private readonly _animationOptions: KeyframeAnimationOptions; + private readonly _updateThrottler = this._register(new Throttler()); + + constructor( + container: HTMLElement, + private readonly _options: IAnimatedCounterWidgetOptions, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService + ) { + super(); + + const { cssClassName, duration } = _options; + + this._element = cssClassName + ? dom.$(`div.${cssClassName}`) + : dom.$('div'); + + this._element.appendChild(dom.$(`div`)); + container.appendChild(this._element); + + this._animationOptions = { + duration: duration ?? 240, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'both', + } satisfies KeyframeAnimationOptions; + + this._register(autorun(reader => { + const count = this._options.count.read(reader); + this._updateThrottler.queue(() => this._update(count)); + })); + } + + private async _update(count: number | undefined): Promise { + if (!this._element || this._element.children.length === 0) { + return; + } + + const outgoingElement = this._element.children[0]; + + if (count === undefined) { + outgoingElement.textContent = ''; + this._count = undefined; + return; + } + + // Create incoming element + const incomingElementText = `${this._options.prefix ?? ''}${count}`; + + // Skip the animation when it is disabled (duration of 0) or when + // the user prefers reduced motion, just update the text content. + if (this._options.duration === 0 || this._accessibilityService.isMotionReduced()) { + outgoingElement.textContent = incomingElementText; + this._count = count; + return; + } + + // Measure the current width before adding the incoming element so + // that a change in the number of digits can be animated smoothly. + const previousWidth = this._element.getBoundingClientRect().width; + + const incomingElement = dom.$(`div`, undefined, incomingElementText); + this._element?.appendChild(incomingElement); + + // The incoming element is content-sized, so its width is the width the + // container will have once the outgoing element is removed. Animate the + // container between the two widths for both growing and shrinking digit + // counts. + const nextWidth = incomingElement.getBoundingClientRect().width; + + if (Math.abs(previousWidth - nextWidth) > 0.5) { + this._element.animate([ + { width: `${previousWidth}px` }, + { width: `${nextWidth}px` }, + ], this._animationOptions); + } + + const directionOption = this._options.direction ?? 'topToBottom'; + const directionTopBottom = directionOption === 'topToBottom' + ? count > (this._count ?? 0) + : count < (this._count ?? 0); + + const enterFrom = directionTopBottom ? '-100%' : '100%'; + const exitTo = directionTopBottom ? '100%' : '-100%'; + + incomingElement.animate([ + { transform: `translateY(${enterFrom})`, opacity: 0 }, + { transform: 'translateY(0)', opacity: 1 }, + ], this._animationOptions); + + const exit = outgoingElement.animate([ + { transform: 'translateY(0)', opacity: 1 }, + { transform: `translateY(${exitTo})`, opacity: 0 }, + ], this._animationOptions); + + await new Promise(resolve => { + let didCleanup = false; + + const cleanup = () => { + if (didCleanup) { + return; + } + + didCleanup = true; + this._count = count; + this._element?.removeChild(outgoingElement); + resolve(); + }; + + exit.addEventListener('cancel', cleanup); + exit.addEventListener('finish', cleanup); + }); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 3fecf64f6aa..6fe070f68d3 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -13,8 +13,8 @@ import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tre import { ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; -import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { autorun, derived, derivedObservableWithCache, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; @@ -64,7 +64,7 @@ import { SessionFilesViewModel } from './sessionFilesViewModel.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISessionChangesetOperation, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus } from '../../../services/sessions/common/session.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; -import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; +import { IView, LayoutPriority, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; import { Color } from '../../../../base/common/color.js'; import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; @@ -76,18 +76,19 @@ import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from ' import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, IsolationMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js'; import { ResourceTree } from '../../../../base/common/resourceTree.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { ChangesSummaryWidget } from './changesSummaryWidget.js'; const $ = dom.$; // --- Constants const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; +const EMPTY_FILE_CHANGES_MIN_HEIGHT = 140; // --- ButtonBar widget @@ -349,6 +350,51 @@ class ChangesWorkbenchButtonBarWidget extends Disposable { } } +/** + * Renders the session changes action button-bar (e.g. "Create Pull Request") into + * a container, choosing the agent-host or git variant based on the active session. + * Used to host the actions in the single-pane Changes editor header. + */ +export class ChangesActionsBar extends Disposable { + constructor( + container: HTMLElement, + @IInstantiationService instantiationService: IInstantiationService, + @IChangesViewService changesViewService: IChangesViewService, + @ISessionsService sessionsService: ISessionsService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const hasGitOperationInProgressGlobalObs = observableFromEvent(contextKeyService.onDidChangeContext, () => + contextKeyService.getContextKeyValue('sessions.hasGitOperationInProgress') === true); + const hasGitOperationInProgressObs = derived(reader => { + if (hasGitOperationInProgressGlobalObs.read(reader)) { + return true; + } + return changesViewService.activeSessionStateObs.read(reader)?.hasGitOperationInProgress === true; + }); + + const isAgentHostSessionObs = derived(reader => { + const activeSession = sessionsService.activeSession.read(reader); + return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; + }); + + this._register(autorun(reader => { + dom.clearNode(container); + + const widget = isAgentHostSessionObs.read(reader) + ? instantiationService.createInstance(ChangesWorkbenchButtonBarWidget, container) + : instantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, container, hasGitOperationInProgressObs); + reader.store.add(widget); + })); + + this._register(autorun(reader => { + const status = sessionsService.activeSession.read(reader)?.status.read(reader); + dom.setVisibility(status !== SessionStatus.Untitled, container); + })); + } +} + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -368,6 +414,7 @@ export class ChangesViewPane extends ViewPane { private sessionFilesWidget: SessionFilesWidget | undefined; private splitView: SplitView | undefined; private splitViewContainer: HTMLElement | undefined; + private readonly treePaneSizeChange = this._register(new Emitter()); private readonly isMergeBaseBranchProtectedContextKey: IContextKey; private readonly isolationModeContextKey: IContextKey; @@ -498,32 +545,10 @@ export class ChangesViewPane extends ViewPane { updateHasFileIcons(); this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); - // Files header - this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header')); - - // Changesets toolbar - const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar')); - this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { - menuOptions: { shouldForwardArgs: true }, - actionViewItemProvider: (action) => { - if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) { - return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action); - } - return undefined; - }, - })); - - // File header right-aligned toolbar - this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar')); - this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, { - menuOptions: { shouldForwardArgs: true }, - actionViewItemProvider: (action, options) => { - if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) { - return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, options); - } - return undefined; - }, - })); + // Files header (Branch Changes dropdown + diff stats). In the single-pane + // redesign these live in the custom Changes editor instead, so the panel + // omits its header; otherwise (original layout) the header is shown here. + this.createFilesHeader(this.contentContainer); // Changes card progress bar const progressContainer = dom.append(this.contentContainer, $('.changes-progress')); @@ -540,7 +565,7 @@ export class ChangesViewPane extends ViewPane { const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); - // Session Files widget — middle pane (files edited outside the workspace) + // Other Files widget - middle pane (files edited outside the workspace) this.sessionFilesWidget = this._register(this.scopedInstantiationService.createInstance(SessionFilesWidget, this.splitViewContainer)); // CI Status widget — bottom pane @@ -555,27 +580,33 @@ export class ChangesViewPane extends ViewPane { // Shared constants for pane sizing const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT; const sessionFilesMinHeight = SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.MIN_BODY_HEIGHT; - const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT; + const getSessionFilesContentHeight = () => Math.max(SessionFilesWidget.HEADER_HEIGHT, this.sessionFilesWidget?.desiredHeight ?? 0); + const getSessionFilesMinimumHeight = () => this.sessionFilesWidget?.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Math.min(sessionFilesMinHeight, getSessionFilesContentHeight()); + const getSessionFilesPreferredHeight = () => Math.max(getSessionFilesMinimumHeight(), SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT); + const getCIContentHeight = () => Math.max(CIStatusWidget.HEADER_HEIGHT, this.ciStatusWidget?.desiredHeight ?? 0); + const getCIMinimumHeight = () => this.ciStatusWidget?.collapsed ? CIStatusWidget.HEADER_HEIGHT : Math.min(ciMinHeight, getCIContentHeight()); + const thisView = this; // Top pane: file tree const treePane: IView = { element: this.contentContainer, - minimumSize: treeMinHeight, - maximumSize: Number.POSITIVE_INFINITY, - onDidChange: Event.None, + get minimumSize() { return thisView.getTreePaneMinimumSize(); }, + get maximumSize() { return thisView.getTreePaneMaximumSize(); }, + onDidChange: this.treePaneSizeChange.event, layout: (height) => { this.contentContainer!.style.height = `${height}px`; this._layoutTreeInPane(height); }, }; - // Middle pane: session files + // Middle pane: other files const sessionFilesElement = this.sessionFilesWidget.element; const sessionFilesWidget = this.sessionFilesWidget; const sessionFilesPane: IView = { element: sessionFilesElement, - get minimumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : sessionFilesMinHeight; }, + get minimumSize() { return getSessionFilesMinimumHeight(); }, get maximumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, + priority: LayoutPriority.High, onDidChange: Event.map(this.sessionFilesWidget.onDidChangeHeight, () => undefined), layout: (height) => { sessionFilesElement.style.height = `${height}px`; @@ -589,9 +620,10 @@ export class ChangesViewPane extends ViewPane { const ciWidget = this.ciStatusWidget; const ciPane: IView = { element: ciElement, - get minimumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : ciMinHeight; }, - get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, - onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined), + get minimumSize() { return getCIMinimumHeight(); }, + get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : getCIContentHeight(); }, + priority: LayoutPriority.Low, + onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => getCIContentHeight()), layout: (height) => { ciElement.style.height = `${height}px`; const bodyHeight = Math.max(0, height - CIStatusWidget.HEADER_HEIGHT); @@ -611,15 +643,16 @@ export class ChangesViewPane extends ViewPane { updateSplitViewStyles(); this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles)); - // Initially hide the session files and CI panes until content arrives + // Initially hide the other files and CI panes until content arrives this.splitView.setViewVisible(1, false); this.splitView.setViewVisible(2, false); - // Session files pane (index 1) - this._wireSectionPane(this.sessionFilesWidget, 1, SessionFilesWidget.HEADER_HEIGHT, SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT); + // Other files pane (index 1) + this._wireSectionPane(this.sessionFilesWidget, 1, SessionFilesWidget.HEADER_HEIGHT, getSessionFilesPreferredHeight); + this._register(this.sessionFilesWidget.onDidChangeHeight(() => this.fireTreePaneSizeChange())); // CI checks pane (index 2) - this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT); + this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, getCIContentHeight); this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { @@ -688,21 +721,9 @@ export class ChangesViewPane extends ViewPane { // Bind context keys this._bindContextKeys(topLevelStats); - const isAgentHostSessionObs = derived(reader => { - const activeSession = this.sessionsService.activeSession.read(reader); - return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; - }); - - this.renderDisposables.add(autorun(reader => { - dom.clearNode(this.actionsContainer!); - - const isAgentHostSession = isAgentHostSessionObs.read(reader); - - const widget = isAgentHostSession - ? this.scopedInstantiationService.createInstance(ChangesWorkbenchButtonBarWidget, this.actionsContainer!) - : this.scopedInstantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, this.actionsContainer!, this.hasGitOperationInProgressObs); - reader.store.add(widget); - })); + // In the single-pane redesign the Create PR actions render in the Changes + // editor header instead of the detail panel. + this.createActionsButtonBar(); } const activeSessionStatusObs = derived(reader => { @@ -720,19 +741,17 @@ export class ChangesViewPane extends ViewPane { const activeSessionStatus = activeSessionStatusObs.read(reader); const isUntitled = activeSessionStatus === SessionStatus.Untitled; if (this.actionsContainer) { - dom.setVisibility(!isUntitled, this.actionsContainer); + dom.setVisibility(this.isActionsContainerVisible(isUntitled), this.actionsContainer); } - const hasGitRepository = this.changesViewService.activeSessionHasGitRepositoryObs.read(reader); - const stats = topLevelStats.read(reader); const hasEntries = stats !== undefined && stats.files > 0; - // Show the files header whenever the session is git-backed (so users - // can switch version modes) or there are session-provided entries to - // count (for non-git sessions like the local agent host). - dom.setVisibility(!isUntitled && (hasGitRepository || hasEntries), this.filesHeaderNode!); - + // Files header visibility (original layout only; absent in single-pane redesign). + if (this.filesHeaderNode) { + const hasGitRepository = this.changesViewService.activeSessionHasGitRepositoryObs.read(reader); + dom.setVisibility(!isUntitled && (hasGitRepository || hasEntries), this.filesHeaderNode); + } if (this.fileHeaderToolbarContainer) { dom.setVisibility(hasEntries, this.fileHeaderToolbarContainer); } @@ -740,6 +759,7 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(hasEntries, this.listContainer!); dom.setVisibility(!hasEntries, this.welcomeContainer!); + this.fireTreePaneSizeChange(); this.layoutSplitView(); })); @@ -752,8 +772,11 @@ export class ChangesViewPane extends ViewPane { if (this.tree) { const tree = this.tree; - // Re-layout when collapse state changes so the card height adjusts - this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView())); + // Re-layout when tree content changes so the card height adjusts + this.renderDisposables.add(tree.onDidChangeContentHeight(() => { + this.fireTreePaneSizeChange(); + this.layoutSplitView(); + })); this.renderDisposables.add(tree.onDidOpen((e) => { if (!e.element || !isChangesFileItem(e.element)) { @@ -762,13 +785,17 @@ export class ChangesViewPane extends ViewPane { logChangesViewFileSelect(this.telemetryService, e.element.changeType); - const modalEditorMode = this.configurationService.getValue('workbench.editor.useModal'); - if (modalEditorMode === 'all') { + if (this.shouldOpenModalDiff()) { const items = changesObs.get(); this._openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, items.length > 1); return; } + if (this.shouldRevealFileInMultiDiffEditor()) { + void this._openMultiFileDiffEditor(e.element.uri); + return; + } + // Holding Alt inverts the configured single/multi file diff behavior. const altKey = !!(e.browserEvent as MouseEvent | KeyboardEvent | undefined)?.altKey; const openSingleFileDiff = this.configurationService.getValue(SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING) !== altKey; @@ -792,7 +819,7 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel)); } - // Session files (files edited outside the workspace during the session) + // Other files (files edited outside the workspace during the session) if (this.sessionFilesWidget) { const sessionFilesViewModel = this.scopedInstantiationService.createInstance(SessionFilesViewModel); this.renderDisposables.add(sessionFilesViewModel); @@ -831,6 +858,7 @@ export class ChangesViewPane extends ViewPane { this.tree.setChildren(null, listChildren); } + this.fireTreePaneSizeChange(); this.layoutSplitView(); })); } @@ -880,13 +908,34 @@ export class ChangesViewPane extends ViewPane { return; } - // Subtract the files header height within the content container + // Subtract the files header height (present in the original layout only). const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; const treeHeight = Math.max(0, paneHeight - filesHeaderHeight); this.tree.layout(treeHeight, this.currentBodyWidth); this.tree.getHTMLElement().style.height = `${treeHeight}px`; } + private getTreePaneMinimumSize(): number { + if (this.listContainer?.style.display === 'none') { + return EMPTY_FILE_CHANGES_MIN_HEIGHT; + } + return 3 * ChangesTreeDelegate.ROW_HEIGHT; + } + + private getTreePaneMaximumSize(): number { + if (!this.sessionFilesWidget?.visible || this.sessionFilesWidget.collapsed) { + return Number.POSITIVE_INFINITY; + } + + const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; + const treeContentHeight = this.listContainer?.style.display === 'none' ? 0 : this.tree?.contentHeight ?? 0; + return Math.max(this.getTreePaneMinimumSize(), filesHeaderHeight + treeContentHeight); + } + + private fireTreePaneSizeChange(): void { + this.treePaneSizeChange.fire(undefined); + } + /** Layout the SplitView to fill available body space. */ private layoutSplitView(): void { if (!this.splitView || !this.splitViewContainer) { @@ -905,7 +954,7 @@ export class ChangesViewPane extends ViewPane { } /** - * Wires a collapsible section widget (CI checks / session files) to its + * Wires a collapsible section widget (CI checks / other files) to its * SplitView pane: toggling its header collapses/restores the pane, and * changes to its content show/hide the pane and re-layout. Both section * widgets share the same structural contract so this logic is reused. @@ -914,9 +963,9 @@ export class ChangesViewPane extends ViewPane { widget: { readonly collapsed: boolean; readonly visible: boolean; readonly onDidToggleCollapsed: Event; readonly onDidChangeHeight: Event }, paneIndex: number, headerHeight: number, - preferredHeight: number, + getPreferredHeight: () => number, ): void { - let savedPaneHeight = preferredHeight; + let savedPaneHeight = getPreferredHeight(); this._register(widget.onDidToggleCollapsed(collapsed => { if (!this.splitView) { @@ -944,6 +993,10 @@ export class ChangesViewPane extends ViewPane { const isCurrentlyVisible = this.splitView.isViewVisible(paneIndex); if (visible !== isCurrentlyVisible) { this.splitView.setViewVisible(paneIndex, visible); + if (visible && !widget.collapsed) { + savedPaneHeight = getPreferredHeight(); + this.splitView.resizeView(paneIndex, savedPaneHeight); + } } this.layoutSplitView(); })); @@ -1173,8 +1226,7 @@ export class ChangesViewPane extends ViewPane { return; } - const modalEditorMode = this.configurationService.getValue('workbench.editor.useModal'); - if (modalEditorMode === 'all') { + if (this.shouldOpenModalDiff()) { const changes = toIChangesFileItem(items); const changeToOpen = resource ? changes.find(c => isEqual(c.uri, resource)) : undefined; await this._openFileItem(changeToOpen ?? changes[0], changes, false, false, false, changes.length > 1); @@ -1185,6 +1237,86 @@ export class ChangesViewPane extends ViewPane { await this._openMultiFileDiffEditor(resource); } + /** + * Renders the files header (Branch Changes dropdown + diff stats) into the panel. + * Standard layout only; {@link SinglePaneChangesViewPane} overrides this to a no-op + * because the header lives in the custom Changes editor instead. + */ + protected createFilesHeader(contentContainer: HTMLElement): void { + this.filesHeaderNode = dom.append(contentContainer, $('.changes-files-header')); + + const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar')); + this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action) => { + if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) { + return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action); + } + return undefined; + }, + })); + + this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar')); + this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action, options) => { + if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) { + return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, options); + } + return undefined; + }, + })); + } + + /** + * Renders the Create-PR actions button bar into the actions container. Standard + * layout only; {@link SinglePaneChangesViewPane} overrides this to a no-op because + * the actions render in the Changes editor header instead. + */ + protected createActionsButtonBar(): void { + if (!this.actionsContainer) { + return; + } + + const isAgentHostSessionObs = derived(reader => { + const activeSession = this.sessionsService.activeSession.read(reader); + return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; + }); + + this.renderDisposables.add(autorun(reader => { + dom.clearNode(this.actionsContainer!); + + const isAgentHostSession = isAgentHostSessionObs.read(reader); + + const widget = isAgentHostSession + ? this.scopedInstantiationService.createInstance(ChangesWorkbenchButtonBarWidget, this.actionsContainer!) + : this.scopedInstantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, this.actionsContainer!, this.hasGitOperationInProgressObs); + reader.store.add(widget); + })); + } + + /** + * Whether the actions container should be shown for the given session state. + * Standard layout shows it for non-untitled sessions; {@link SinglePaneChangesViewPane} + * never shows it (the actions live in the Changes editor). + */ + protected isActionsContainerVisible(isUntitled: boolean): boolean { + return !isUntitled; + } + + /** + * Whether clicking a file opens the modal single-file diff (vs the multi-file + * diff editor). Standard layout honors the `workbench.editor.useModal` setting; + * {@link SinglePaneChangesViewPane} always opens the multi-file diff. + */ + protected shouldOpenModalDiff(): boolean { + return this.configurationService.getValue('workbench.editor.useModal') === 'all'; + } + + protected shouldRevealFileInMultiDiffEditor(): boolean { + return false; + } + /** * Reveal the CI checks section: expand it if collapsed and move keyboard * focus into it. No-op when there are no checks to show. @@ -1291,14 +1423,10 @@ export class ChangesViewPane extends ViewPane { } } - // Open the multi-diff editor using the sessions source URI. The resource - // list is resolved via `SessionsMultiDiffSourceResolver` and updates - // reactively as `activeSessionChangesObs` changes. - await this.editorService.openEditor({ - multiDiffSource: this.sessionChangesService.getChangesEditorResource(sessionResource), - label: localize('sessions.changes.title', 'Session Changes'), - options, - }); + // Open the session Changes editor using the sessions source URI. The + // resource list is resolved via `ChangesMultiDiffSourceResolver` and + // updates reactively as `activeSessionChangesObs` changes. + await this.sessionChangesService.openChangesEditor(sessionResource, options); } override dispose(): void { @@ -1307,6 +1435,35 @@ export class ChangesViewPane extends ViewPane { } } +/** + * Changes view for the single-pane layout: the files list lives in the docked + * detail panel while the Branch Changes header, Create-PR actions, and diffs are + * shown in the custom Changes editor. Overrides the standard hooks to omit the + * in-panel header/actions and always open the multi-file diff. + */ +export class SinglePaneChangesViewPane extends ChangesViewPane { + + protected override createFilesHeader(_contentContainer: HTMLElement): void { + // No in-panel header in single-pane; it lives in the Changes editor. + } + + protected override createActionsButtonBar(): void { + // No in-panel Create-PR actions in single-pane; they live in the Changes editor header. + } + + protected override isActionsContainerVisible(_isUntitled: boolean): boolean { + return false; + } + + protected override shouldOpenModalDiff(): boolean { + return false; + } + + protected override shouldRevealFileInMultiDiffEditor(): boolean { + return true; + } +} + export class ChangesViewPaneContainer extends ViewPaneContainer { constructor( @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @@ -1485,7 +1642,7 @@ class VersionsPickerAction extends Action2 { } registerAction2(VersionsPickerAction); -class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { +export class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { constructor( action: MenuItemAction, @IActionWidgetService actionWidgetService: IActionWidgetService, @@ -1594,44 +1751,23 @@ class RevealCIChecksAction extends Action2 { registerAction2(RevealCIChecksAction); class ChangesDiffStatsActionItem extends ActionViewItem { - private readonly diffStatsObs: IObservable<{ files: number; insertions: number; deletions: number } | undefined>; + protected readonly _widget: ChangesSummaryWidget; constructor( action: MenuItemAction, options: IActionViewItemOptions, - @IChangesViewService changesViewService: IChangesViewService + @IInstantiationService instantiationService: IInstantiationService, ) { - super(null, action, { ...options, icon: false, label: true }); + super(null, action, { ...options, icon: false, label: false }); - const diffStatsRawObs = derivedObservableWithCache<{ files: number; insertions: number; deletions: number } | undefined>(this, - (reader, lastValue) => { - const entries = changesViewService.activeSessionChangesObs.read(reader); - const isLoading = changesViewService.activeSessionLoadingObs.read(reader); - - if (isLoading) { - return lastValue; - } - - let insertions = 0, deletions = 0; - for (const entry of entries) { - insertions += entry.insertions; - deletions += entry.deletions; - } - - return { files: entries.length, insertions, deletions }; - }); - - this.diffStatsObs = derivedOpts<{ files: number; insertions: number; deletions: number } | undefined>({ - equalsFn: structuralEquals - }, reader => diffStatsRawObs.read(reader)); + this._widget = this._register(instantiationService.createInstance(ChangesSummaryWidget)); this._register(autorun(reader => { - const diffStats = this.diffStatsObs.read(reader); - if (diffStats === undefined) { + const changesSummary = this._widget.summary.read(reader); + if (changesSummary === undefined) { return; } - this.updateLabel(); this.updateTooltip(); })); } @@ -1639,34 +1775,78 @@ class ChangesDiffStatsActionItem extends ActionViewItem { override render(container: HTMLElement): void { super.render(container); container.classList.add('changes-diff-stats-action'); - } - protected override updateLabel(): void { if (!this.label) { return; } - const diffStats = this.diffStatsObs.get(); - if (diffStats === undefined) { - return; - } + this.renderLabelContents(this.label); + } - const { insertions, deletions } = diffStats; - - dom.reset( - this.label, - dom.$('span.working-set-lines-added', undefined, `+${insertions}`), - dom.$('span.working-set-lines-removed', undefined, `-${deletions}`) - ); + /** + * Renders the diff-stats content into the action label. The base shows the + * animated +/- summary; {@link SinglePaneChangesDiffStatsActionItem} overrides + * this to a richer "N files +X -Y" label for the single-pane editor header. + */ + protected renderLabelContents(label: HTMLElement): void { + this._widget.render(label); } protected override getTooltip(): string | undefined { - const diffStats = this.diffStatsObs.get(); - if (diffStats === undefined) { + const changesSummary = this._widget.summary.get(); + if (changesSummary === undefined) { return undefined; } - const { files, insertions, deletions } = diffStats; - return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, insertions, deletions); + const { files, additions, deletions } = changesSummary; + return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, additions, deletions); + } +} + +/** + * Diff-stats label for the single-pane Changes editor header: a richer + * "$(diff-multiple) N files +X -Y" rendering (the detail-panel header uses the + * compact animated base rendering). + */ +export class SinglePaneChangesDiffStatsActionItem extends ChangesDiffStatsActionItem { + + override render(container: HTMLElement): void { + this.element = container; + container.classList.add('changes-diff-stats-action'); + + const label = dom.append(container, dom.$('span.action-label')); + this.label = label; + this.renderLabelContents(label); + this.updateTooltip(); + } + + override onClick(event: dom.EventLike): void { + dom.EventHelper.stop(event, true); + } + + override focus(): void { } + + override setFocusable(_focusable: boolean): void { } + + protected override renderLabelContents(label: HTMLElement): void { + this._register(autorun(reader => { + const summary = this._widget.summary.read(reader); + if (summary === undefined) { + return; + } + + const { files, additions, deletions } = summary; + const filesLabel = files === 1 + ? localize('changesView.diffStats.file', "1 file") + : localize('changesView.diffStats.files', "{0} files", files); + + dom.reset( + label, + ...renderLabelWithIcons('$(diff-multiple)'), + dom.$('span.changes-diff-stats-files', undefined, filesLabel), + dom.$('span.working-set-lines-added', undefined, `+${additions}`), + dom.$('span.working-set-lines-removed', undefined, `-${deletions}`) + ); + })); } } diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index e10d3c6acca..c540ec182c3 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -14,12 +14,14 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; -import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ActiveEditorContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { SessionChangesEditor } from './sessionChangesEditor.js'; const openChangesViewActionOptions: IAction2Options = { id: 'workbench.action.agentSessions.openChangesView', @@ -107,6 +109,45 @@ class OpenPullRequestAction extends Action2 { registerAction2(OpenPullRequestAction); +const singlePaneChangesEditorActive = ContextKeyExpr.and( + IsSessionsWindowContext, + ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), + ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true) +); + +class CollapseAllSessionChangesDiffsAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.collapseAllDiffs'; + + constructor() { + super({ + id: CollapseAllSessionChangesDiffsAction.ID, + title: localize2('agentSessions.collapseAllDiffs', "Collapse All Diffs"), + icon: Codicon.collapseAll, + f1: false, + menu: { + id: MenuId.EditorTitle, + group: 'navigation', + order: 100, + when: ContextKeyExpr.and( + singlePaneChangesEditorActive, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext, + ContextKeyExpr.not('multiDiffEditorAllCollapsed')) + } + }); + } + + run(accessor: ServicesAccessor): void { + const activeEditorPane = accessor.get(IEditorService).activeEditorPane; + if (activeEditorPane instanceof SessionChangesEditor) { + activeEditorPane.collapseAllDiffs(); + } + } +} + +registerAction2(CollapseAllSessionChangesDiffsAction); + class OpenChangesAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.openChanges'; @@ -189,4 +230,3 @@ class OpenFileAction extends Action2 { registerAction2(OpenFileAction); - diff --git a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts index 46919b5b77d..5760ee4c65e 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts @@ -392,7 +392,12 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer operation.group !== 'review'); + const actions = operations.map(operation => toAction({ id: operation.id, label: operation.label, diff --git a/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css new file mode 100644 index 00000000000..04f98c64f7b --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.changes-summary-widget { + display: inline-flex; + gap: 4px; + + .changes-summary-lines-added, + .changes-summary-lines-removed { + position: relative; + display: inline-grid; + overflow: hidden; + justify-items: start; + + div { + grid-area: 1 / 1; + display: block; + will-change: transform, opacity; + } + } + + .changes-summary-lines-added { + color: var(--vscode-chat-linesAddedForeground); + } + + .changes-summary-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css new file mode 100644 index 00000000000..cb2d1b8346c --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-changes-editor { + display: flex; + flex-direction: column; + height: 100%; + background: var(--vscode-editor-background); + color: var(--vscode-agentsPanel-foreground); +} + +.session-changes-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 35px; + min-height: 35px; + min-width: 0; + padding: 0 var(--vscode-spacing-size80); + gap: var(--vscode-spacing-size80); +} + +.session-changes-editor-header-left, +.session-changes-editor-header-right { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.session-changes-editor-header-left { + flex: 1 1 auto; + gap: var(--vscode-spacing-size40); +} + +.session-changes-editor-header-left > .monaco-toolbar, +.session-changes-editor-header-left .monaco-action-bar, +.session-changes-editor-header-left .actions-container, +.session-changes-editor-header-left .action-item { + min-width: 0; + max-width: 100%; +} + +.session-changes-editor-header-left > .monaco-toolbar:first-child { + flex: 0 1 auto; +} + +.session-changes-editor-header-left > .monaco-toolbar:last-child { + flex: 1 1 auto; +} + +.session-changes-editor-header-left .action-label > span:not(.codicon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-changes-editor-header-left .action-label { + font-size: var(--vscode-agents-fontSize-label1); + align-items: center; + min-width: 0; + max-width: 100%; + + > span { + margin-left: 2px; + } + + > .codicon { + font-size: var(--vscode-codiconFontSize-compact) !important; + padding-left: var(--vscode-spacing-size40); + width: 12px; + height: 12px; + flex-shrink: 0; + } +} + +.session-changes-editor-header-left .changes-diff-stats-action { + flex: 1 1 auto; + min-width: 0; + cursor: default; +} + +.session-changes-editor-header-left .changes-diff-stats-action .action-label { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + padding: 0; + cursor: default; +} + +.monaco-workbench .session-changes-editor-header-left .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action .action-label:hover:not(.disabled), +.monaco-workbench .session-changes-editor-header-left .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action .action-label:active, +.monaco-workbench .session-changes-editor-header-left .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action.active .action-label { + background-color: transparent; + outline: none; +} + +/* Reset the picker-chevron overrides so the diff-stats icon/labels lay out via `gap`. */ +.session-changes-editor-header-left .changes-diff-stats-action .action-label > span { + margin-left: 0; + min-width: 0; +} + +.session-changes-editor-header-left .changes-diff-stats-action .action-label > .codicon { + font-size: var(--vscode-codiconFontSize-compact) !important; + padding-left: 0; + width: auto; + height: auto; + flex-shrink: 0; +} + +.session-changes-editor-header-left .changes-diff-stats-files { + color: var(--vscode-foreground); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-changes-editor-header-left .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); + flex-shrink: 0; +} + +.session-changes-editor-header-left .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + flex-shrink: 0; +} + +.session-changes-editor-header-right { + flex: 0 2 auto; + min-width: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: var(--vscode-spacing-size40); +} + +.session-changes-editor-header-right .monaco-button, +.session-changes-editor-header-right .monaco-button-dropdown, +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button { + width: auto; + min-width: 0; +} + +.session-changes-editor-header-right .monaco-button { + height: 26px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + box-sizing: border-box; + font-size: var(--vscode-agents-fontSize-label1); + line-height: 18px; + white-space: nowrap; +} + +.session-changes-editor-header-right > .monaco-button, +.session-changes-editor-header-right > .monaco-button-dropdown { + flex: 0 1 auto; +} + +.session-changes-editor-header-right > .monaco-button.secondary, +.session-changes-editor-header-right > .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { + flex: 0 0 auto; +} + +.session-changes-editor-header-right .monaco-button-dropdown { + display: flex; + align-items: center; + height: 26px; + overflow: hidden; +} + +.session-changes-editor-header-right .monaco-button > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button-dropdown-separator { + flex: 0 0 auto; + margin: 0; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button:not(.monaco-dropdown-button) { + flex: 1 1 auto; + overflow: hidden; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + flex: 0 0 auto; + padding: var(--vscode-spacing-size40); + min-width: 0; + border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; +} + +.session-changes-editor-header-right .monaco-button.secondary.monaco-text-button.codicon { + flex: 0 0 auto; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size60); + font-size: var(--vscode-codiconFontSize) !important; +} + +.session-changes-editor-body { + position: relative; + flex: 1; + min-height: 0; +} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css index 1122b8e32f6..96f6f3bc14b 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Session Files Widget - between the files list and the CI checks widget */ +/* Other Files widget - between the files list and the CI checks widget */ .session-files-widget { display: flex; flex-direction: column; @@ -125,7 +125,7 @@ display: flex; align-items: center; gap: 6px; - padding: 0 0 0 6px; + padding: 0; height: 100%; width: 100%; box-sizing: border-box; diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts new file mode 100644 index 00000000000..6dc3325ee62 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionChangesEditor.css'; +import { $, append, Dimension } from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { ResourceLabel } from '../../../../workbench/browser/labels.js'; +import { IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IResourceLabel, IWorkbenchUIElementFactory } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; +import { ActiveSessionContextKeys } from '../common/changes.js'; +import { IChangesViewService } from '../common/changesViewService.js'; +import { ChangesActionsBar, SinglePaneChangesDiffStatsActionItem, ChangesPickerActionItem } from './changesView.js'; +import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; + +const VERSIONS_PICKER_ACTION_ID = 'chatEditing.versionsPicker'; +const DIFF_STATS_ACTION_ID = 'workbench.changesView.action.viewChanges'; +const HEADER_HEIGHT = 35; + +class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { + + readonly headerClickToCollapse = true; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + createResourceLabel(element: HTMLElement): IResourceLabel { + const label = this.instantiationService.createInstance(ResourceLabel, element, {}); + return { + setUri(uri, options = {}) { + if (!uri) { + label.element.clear(); + } else { + label.element.setFile(uri, { strikethrough: options.strikethrough }); + } + }, + dispose() { + label.dispose(); + } + }; + } +} + +/** + * Changes editor for the Agents window: a "Branch Changes" versions dropdown and + * diff stats header sitting above an embedded multi-diff editor showing the + * session's file diffs. + */ +export class SessionChangesEditor extends EditorPane { + + static readonly ID = SessionChangesEditorInput.EDITOR_ID; + + private widget: MultiDiffEditorWidget | undefined; + private viewModel: MultiDiffEditorViewModel | undefined; + private bodyContainer: HTMLElement | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChangesViewService private readonly changesViewService: IChangesViewService, + ) { + super(SessionChangesEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + const root = append(parent, $('.session-changes-editor')); + + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(root)); + this._register(bindContextKey(ActiveSessionContextKeys.HasGitRepository, scopedContextKeyService, reader => + this.changesViewService.activeSessionHasGitRepositoryObs.read(reader))); + this._register(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, reader => + this.changesViewService.activeSessionChangesObs.read(reader).length > 0)); + const scopedInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + + const header = append(root, $('.session-changes-editor-header')); + + const leftToolbarContainer = append(header, $('.session-changes-editor-header-left')); + this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action: IAction) => { + if (action.id === VERSIONS_PICKER_ACTION_ID && action instanceof MenuItemAction) { + return scopedInstantiationService.createInstance(ChangesPickerActionItem, action); + } + return undefined; + }, + })); + + // Diff stats pill sits next to the Branch Changes dropdown. + this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action, options) => { + if (action.id === DIFF_STATS_ACTION_ID && action instanceof MenuItemAction) { + return scopedInstantiationService.createInstance(SinglePaneChangesDiffStatsActionItem, action, options); + } + return undefined; + }, + })); + + // Create Pull Request (and related) actions render on the right of the header row. + const rightToolbarContainer = append(header, $('.session-changes-editor-header-right')); + this._register(scopedInstantiationService.createInstance(ChangesActionsBar, rightToolbarContainer)); + + this.bodyContainer = append(root, $('.session-changes-editor-body')); + this.widget = this._register(scopedInstantiationService.createInstance( + MultiDiffEditorWidget, + this.bodyContainer, + scopedInstantiationService.createInstance(SessionChangesUIElementFactory), + )); + } + + override async setInput(input: SessionChangesEditorInput, options: IMultiDiffEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + const viewModel = await input.getViewModel(); + if (token.isCancellationRequested) { + return; + } + this.viewModel = viewModel; + this.widget?.setViewModel(viewModel, { preserveFocus: options?.preserveFocus }); + this._applyOptions(options); + } + + collapseAllDiffs(): void { + this.viewModel?.collapseAll(); + } + + override setOptions(options: IMultiDiffEditorOptions | undefined): void { + this._applyOptions(options); + } + + private _applyOptions(options: IMultiDiffEditorOptions | undefined): void { + const revealData = options?.viewState?.revealData; + if (!revealData) { + return; + } + this.widget?.reveal(revealData.resource, { + range: revealData.range ? Range.lift(revealData.range) : undefined, + highlight: true, + }); + } + + override clearInput(): void { + this.viewModel = undefined; + this.widget?.setViewModel(undefined); + super.clearInput(); + } + + override focus(): void { + super.focus(); + this.widget?.getActiveControl()?.focus(); + } + + override layout(dimension: Dimension): void { + const bodyHeight = Math.max(0, dimension.height - HEADER_HEIGHT); + this.widget?.layout(new Dimension(dimension.width, bodyHeight)); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts new file mode 100644 index 00000000000..ba3efbc66e5 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; + +/** + * Editor input for the Agents window Changes tab. It wraps the session's + * multi-diff source and exposes the resolved multi-diff view model so the + * {@link SessionChangesEditor} can render the diffs beneath its own header. + */ +export class SessionChangesEditorInput extends EditorInput { + + static readonly ID = 'workbench.input.agentSessions.sessionChanges'; + static readonly EDITOR_ID = 'workbench.editor.agentSessions.sessionChanges'; + + private _innerInput: MultiDiffEditorInput | undefined; + + constructor( + readonly multiDiffSource: URI, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + } + + override get resource(): URI { + return this.multiDiffSource; + } + + override get typeId(): string { + return SessionChangesEditorInput.ID; + } + + override get editorId(): string { + return SessionChangesEditorInput.EDITOR_ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + } + + override getName(): string { + return localize('sessionChangesEditor.name', "Changes"); + } + + override getIcon(): ThemeIcon { + return Codicon.diffMultiple; + } + + override getTitle(_verbosity?: Verbosity): string { + return this.getName(); + } + + private get innerInput(): MultiDiffEditorInput { + if (!this._innerInput) { + this._innerInput = this._register(MultiDiffEditorInput.fromResourceMultiDiffEditorInput({ + multiDiffSource: this.multiDiffSource, + label: this.getName(), + }, this.instantiationService)); + } + return this._innerInput; + } + + async getViewModel(): Promise { + return this.innerInput.getViewModel(); + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + if (this === otherInput) { + return true; + } + return otherInput instanceof SessionChangesEditorInput + && otherInput.multiDiffSource.toString() === this.multiDiffSource.toString(); + } +} + +interface ISerializedSessionChangesEditorInput { + readonly multiDiffSourceUri: string; +} + +export class SessionChangesEditorSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): editorInput is SessionChangesEditorInput { + return editorInput instanceof SessionChangesEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + if (!this.canSerialize(editorInput)) { + return undefined; + } + const data: ISerializedSessionChangesEditorInput = { multiDiffSourceUri: editorInput.multiDiffSource.toString() }; + return JSON.stringify(data); + } + + deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { + try { + const data = JSON.parse(serializedEditor) as ISerializedSessionChangesEditorInput; + return instantiationService.createInstance(SessionChangesEditorInput, URI.parse(data.multiDiffSourceUri)); + } catch { + return undefined; + } + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts index 4759993a5aa..77dd507e498 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts @@ -4,7 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { localize } from '../../../../nls.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; export const ISessionChangesService = createDecorator('sessionChangesService'); @@ -13,7 +20,7 @@ export const ISessionChangesService = createDecorator('s * the single source of truth for the `changes-multi-diff-source:` resource that * the multi-diff editor is opened with, so callers don't have to know the URI * shape: the session header action and the Changes view open the editor with - * {@link getChangesEditorResource}, the layout controller recognizes the active + * {@link openChangesEditor}, the layout controller recognizes the active * editor as a Changes editor with {@link getSessionResource}, and the * multi-diff source resolver uses both. */ @@ -34,6 +41,12 @@ export interface ISessionChangesService { * otherwise `undefined`. */ getSessionResource(editorResource: URI): URI | undefined; + + /** + * Open the Changes editor for a session. In the single-pane layout this opens + * the custom {@link SessionChangesEditorInput}; otherwise a plain multi-diff editor. + */ + openChangesEditor(sessionResource: URI, options?: IMultiDiffEditorOptions, group?: PreferredGroup): Promise; } const CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'changes-multi-diff-source'; @@ -46,6 +59,12 @@ export class SessionChangesService implements ISessionChangesService { declare readonly _serviceBrand: undefined; + constructor( + @IEditorService private readonly editorService: IEditorService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { } + getChangesEditorResource(sessionResource: URI): URI { return URI.from({ scheme: CHANGES_MULTI_DIFF_SOURCE_SCHEME, @@ -71,4 +90,25 @@ export class SessionChangesService implements ISessionChangesService { return URI.parse(fields.sessionResource); } + + async openChangesEditor(sessionResource: URI, options?: IMultiDiffEditorOptions, group?: PreferredGroup): Promise { + const multiDiffSource = this.getChangesEditorResource(sessionResource); + + // Read the setting directly (rather than via IAgentWorkbenchLayoutService) so this + // singleton also resolves in minimal environments — component fixtures / tests — that + // don't register the Agents-window layout service. The layout service remains the + // single source of truth for contributions that run only in the real window. + if (this.configurationService.getValue(DOCK_DETAIL_PANEL_SETTING) === true) { + const input = this.instantiationService.createInstance(SessionChangesEditorInput, multiDiffSource); + const pane = await this.editorService.openEditor(input, { ...options, pinned: true }, group); + return pane?.group; + } + + const pane = await this.editorService.openEditor({ + multiDiffSource, + label: localize('sessions.changes.title', 'Session Changes'), + options, + }, group); + return pane?.group; + } } diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts index cc25efd5808..ea8bc77fec8 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts @@ -12,7 +12,7 @@ import { ISessionFile } from '../../../services/sessions/common/session.js'; const EMPTY_SESSION_FILES: readonly ISessionFile[] = Object.freeze([]); /** - * View model backing the "Session Files" section in the changes view. Exposes + * View model backing the "Other Files" section in the changes view. Exposes * the files created/edited/deleted outside the workspace by the active session. */ export class SessionFilesViewModel extends Disposable { diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts index 4513cbeb71f..f88dbea6967 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts @@ -203,19 +203,19 @@ export class SessionFilesWidget extends Disposable { this._headerNode = dom.append(this._domNode, $('.session-files-widget-header')); this._titleNode = dom.append(this._headerNode, $('.session-files-widget-title')); this._titleLabelNode = dom.append(this._titleNode, $('.session-files-widget-title-label')); - this._titleLabelNode.textContent = localize('sessionFiles.label', "Session Files"); + this._titleLabelNode.textContent = localize('sessionFiles.label', "Other Files"); this._chevronNode = dom.append(this._headerNode, $('.group-chevron')); this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); this._headerNode.setAttribute('role', 'button'); - this._headerNode.setAttribute('aria-label', localize('sessionFiles.toggle', "Toggle Session Files")); + this._headerNode.setAttribute('aria-label', localize('sessionFiles.toggle', "Toggle Other Files")); this._headerNode.setAttribute('aria-expanded', 'true'); this._headerNode.tabIndex = 0; this._register(this._hoverService.setupManagedHover( getDefaultHoverDelegate('element'), this._headerNode, - localize('sessionFiles.hover', "Files created or edited outside the workspace during this session. These files are not part of the workspace and won't be committed."), + localize('sessionFiles.hover', "Files created, edited, or deleted outside the workspace during this session. These files are not part of the workspace and won't be committed."), )); // Register the gesture target so the toggle works on touch platforms @@ -251,7 +251,7 @@ export class SessionFilesWidget extends Disposable { multipleSelectionSupport: false, openOnSingleClick: true, accessibilityProvider: { - getWidgetAriaLabel: () => localize('sessionFiles.listAriaLabel', "Session Files"), + getWidgetAriaLabel: () => localize('sessionFiles.listAriaLabel', "Other Files"), getAriaLabel: item => localize('sessionFiles.fileAriaLabel', "{0}, {1}", basename(item.uri), getSessionFileOperationLabel(item.operation)), }, keyboardNavigationLabelProvider: { diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index ed60eea7687..b4ea1e5f28c 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -15,7 +15,7 @@ import { ChangesViewPane } from './changesView.js'; /** * Accessibility help dialog for the Changes view. Documents the file tree and - * the two collapsible sections beneath it — Session Files and Checks — and how + * the two collapsible sections beneath it - Other Files and Checks - and how * to operate them with the keyboard. */ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplementation { @@ -28,10 +28,10 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen const viewsService = accessor.get(IViewsService); const content: string[] = []; - content.push(localize('sessionsChanges.overview', "You are in the Changes view. It shows the files changed by the current session as a tree, followed by two collapsible sections: Session Files and Checks.")); + content.push(localize('sessionsChanges.overview', "You are in the Changes view. It shows the files changed by the current session as a tree, followed by two collapsible sections: Other Files and Checks.")); content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); - content.push(localize('sessionsChanges.sessionFiles', "The Session Files section lists files that were created, edited, or deleted outside the workspace during this session, such as configuration files in your home directory. These files are not part of the workspace and won't be committed.")); - content.push(localize('sessionsChanges.sessionFilesToggle', "The Session Files header is a button. Press Enter or Space to collapse or expand the list. When expanded, use the arrow keys to move through the files and press Enter to open one: created or deleted files open in an editor, while edited files open as a diff against their pre-session content.")); + content.push(localize('sessionsChanges.sessionFiles', "The Other Files section lists files that were created, edited, or deleted outside the workspace during this session, such as configuration files in your home directory. These files are not part of the workspace and won't be committed.")); + content.push(localize('sessionsChanges.sessionFilesToggle', "The Other Files header is a button. Press Enter or Space to collapse or expand the list. When expanded, use the arrow keys to move through the files and press Enter to open one: created or deleted files open in an editor, while edited files open as a diff against their pre-session content.")); content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '')); content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts new file mode 100644 index 00000000000..dafbc0235b2 --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { ActiveEditorContext, IsSessionsWindowContext, MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../../common/sessionConfig.js'; +import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; +import '../../browser/changesViewActions.js'; + +suite('Changes View Actions', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('collapse all diffs is contributed to the single-pane editor title bar', () => { + const item = MenuRegistry.getMenuItems(MenuId.EditorTitle) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.agentSessions.collapseAllDiffs'); + + assert.ok(item, 'expected collapse all diffs action on EditorTitle'); + const when = item.when?.serialize() ?? ''; + assert.deepStrictEqual({ + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasSinglePaneConfigGate: when.includes(`config.${DOCK_DETAIL_PANEL_SETTING}`), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + }, { + group: 'navigation', + order: 100, + icon: Codicon.collapseAll.id, + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index e40b9822ce0..6b8314d1810 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -24,7 +24,7 @@ import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFac import { NewChatWidget } from './newChatWidget.js'; import { NewChatInSessionWidget } from './newChatInSessionWidget.js'; import { SessionInputBanners } from '../../sessionInputBanners/browser/sessionInputBanners.js'; -import { SessionAgentsControl } from './sessionAgentsControl.js'; +import { SessionRunningSubagentsControl } from './sessionRunningSubagentsControl.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -106,7 +106,8 @@ export class ChatView extends AbstractChatView { /** Session banners (CI failures, created comments) shown above the chat input. */ private readonly _banners: SessionInputBanners; - private readonly _agentsControl: SessionAgentsControl; + /** Ephemeral chip above the input listing the active chat's running subagents. */ + private readonly _runningSubagents: SessionRunningSubagentsControl; /** Reference to the loaded chat model; disposing releases the model. */ private readonly _modelRef = this._register(new MutableDisposable()); @@ -168,8 +169,8 @@ export class ChatView extends AbstractChatView { this._banners = this._register(instantiationService.createInstance(SessionInputBanners)); this._banners.setActive(this._isActive); - // "Agents" dropdown above the input, listing the active chat's subagents. - this._agentsControl = this._register(instantiationService.createInstance(SessionAgentsControl)); + // Ephemeral running-subagents chip above the input (hidden while idle). + this._runningSubagents = this._register(instantiationService.createInstance(SessionRunningSubagentsControl)); this._ensureBannersMounted(); this._register(this.configurationService.onDidChangeConfiguration(e => { @@ -204,8 +205,8 @@ export class ChatView extends AbstractChatView { this._historyKey = historyKey; this._applyHistoryKey(); - // Surface the chat's subagents in the "Agents" dropdown above the input. - this._agentsControl.setChat(resource); + // Monitor this chat's running subagents in the ephemeral chip. + this._runningSubagents.setChat(resource); // Reflect read-only (non-interactive) chats: hide the composer and gate // mutating actions (Start Over / Restore Checkpoint) via the widget. Any @@ -299,20 +300,22 @@ export class ChatView extends AbstractChatView { } /** - * Mounts the session banners and the "Agents" dropdown above the chat input, - * as the first children of the input part (the Agents dropdown sits directly - * above the banners). Idempotent — re-runs cheaply on layout to recover if - * the chat widget rebuilds its input part DOM. + * Mounts the running-subagents chip and the session banners above the chat + * input, as the first children of the input part (the chip sits directly above + * the banners). Idempotent — re-runs cheaply on layout to recover if the chat + * widget rebuilds its input part DOM. */ private _ensureBannersMounted(): void { const inputPartElement = this._widget.inputPart.element; + const subagentsNode = this._runningSubagents.element; const bannersNode = this._banners.domNode; - if (inputPartElement.firstChild !== bannersNode) { - inputPartElement.insertBefore(bannersNode, inputPartElement.firstChild); + // Desired order at the top of the input part: [subagentsNode, bannersNode, ...]. + // Keyed off the chip first so this is a true no-op once the DOM has settled. + if (inputPartElement.firstChild !== subagentsNode) { + inputPartElement.insertBefore(subagentsNode, inputPartElement.firstChild); } - const agentsNode = this._agentsControl.element; - if (agentsNode.parentElement !== inputPartElement || agentsNode.nextSibling !== bannersNode) { - inputPartElement.insertBefore(agentsNode, bannersNode); + if (subagentsNode.nextSibling !== bannersNode) { + inputPartElement.insertBefore(bannersNode, subagentsNode.nextSibling); } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 0d1b5d2e522..381410febfe 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -431,32 +431,20 @@ .sessions-chat-picker-slot .action-label.warning { color: var(--vscode-problemsWarningIcon-foreground); - opacity: 0.75; } .sessions-chat-picker-slot .action-label.warning .codicon { color: var(--vscode-problemsWarningIcon-foreground) !important; } -.sessions-chat-picker-slot .action-label.warning:hover { - color: var(--vscode-problemsWarningIcon-foreground); - opacity: 1; -} - .sessions-chat-picker-slot .action-label.info { color: var(--vscode-problemsInfoIcon-foreground); - opacity: 0.75; } .sessions-chat-picker-slot .action-label.info .codicon { color: var(--vscode-problemsInfoIcon-foreground) !important; } -.sessions-chat-picker-slot .action-label.info:hover { - color: var(--vscode-problemsInfoIcon-foreground); - opacity: 1; -} - /* Tab bar styles live with the platform widget * (`platform/actionWidget/browser/tabbedActionListWidget.css`). The * `sessions-workspace-picker-tabbar` class is applied alongside the diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionAgentsControl.css b/src/vs/sessions/contrib/chat/browser/media/sessionAgentsControl.css deleted file mode 100644 index d3de8e16c19..00000000000 --- a/src/vs/sessions/contrib/chat/browser/media/sessionAgentsControl.css +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* "Agents" dropdown shown above the chat input. */ -.session-agents-control { - display: flex; - align-items: center; - padding: 4px 0; -} - -.session-agents-control.hidden { - display: none; -} - -/* Compact secondary-button pill, matching the session header meta pills. */ -.session-agents-control .monaco-button.session-agents-control-button { - display: inline-flex; - width: auto; - gap: 4px; - padding: 3px 8px; - white-space: nowrap; -} - -.session-agents-control .monaco-button.session-agents-control-button:focus { - outline-offset: 0 !important; -} diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css b/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css new file mode 100644 index 00000000000..66a0d69a549 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-running-subagents { + display: flex; + padding: 4px 0 2px 0; +} + +.session-running-subagents.hidden { + display: none; +} + +.session-running-subagents .session-running-subagents-button { + width: fit-content; + padding: 2px 8px; + font-size: var(--vscode-agents-fontSize-label2); +} + +.session-running-subagents .session-running-subagents-button .codicon { + font-size: var(--vscode-codiconFontSize-compact); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionAgentsControl.ts b/src/vs/sessions/contrib/chat/browser/sessionAgentsControl.ts deleted file mode 100644 index ff8fe1035e8..00000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionAgentsControl.ts +++ /dev/null @@ -1,96 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $ } from '../../../../base/browser/dom.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; -import './media/sessionAgentsControl.css'; - -/** - * A "Subagents" dropdown shown above the chat input that lists the subagent - * (worker) chats spawned by the currently-viewed chat. Activating it opens a - * menu of those subagents; selecting one reveals its read-only chat. The control - * hides when the viewed chat has no subagents. - */ -export class SessionAgentsControl extends Disposable { - - readonly element: HTMLElement; - private readonly _button: Button; - - private readonly _disposables = this._register(new MutableDisposable()); - private _session: IActiveSession | undefined; - private _agents: readonly IChat[] = []; - - constructor( - @ISessionsService private readonly sessionsService: ISessionsService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - ) { - super(); - this.element = $('.session-agents-control'); - this._button = this._register(new Button(this.element, { secondary: true, supportIcons: true, ...defaultButtonStyles })); - this._button.element.classList.add('session-agents-control-button'); - this._button.label = `$(${Codicon.commentDiscussion.id}) ${localize('sessionAgents.label', "Subagents")} $(${Codicon.chevronDown.id})`; - this._register(this._button.onDidClick(() => this._showMenu())); - this._setVisible(false); - } - - /** Track the currently-viewed chat; the dropdown lists its subagents. */ - setChat(chatResource: URI | undefined): void { - const store = new DisposableStore(); - this._disposables.value = store; - - if (!chatResource) { - this._update(undefined, []); - return; - } - - store.add(autorun(reader => { - const session = this.sessionsService.activeSession.read(reader); - const chats = session?.chats.read(reader) ?? []; - const agents = chats.filter(c => - c.origin?.kind === ChatOriginKind.Tool && - !!c.origin.parentChat && - isEqual(c.origin.parentChat, chatResource)); - this._update(session, agents); - })); - } - - private _update(session: IActiveSession | undefined, agents: readonly IChat[]): void { - this._session = session; - this._agents = agents; - this._setVisible(agents.length > 0); - } - - private _showMenu(): void { - const session = this._session; - if (!session || this._agents.length === 0) { - return; - } - const actions = this._agents.map(agent => toAction({ - id: `sessionAgents.open.${agent.resource.toString()}`, - label: agent.title.get(), - run: () => this.sessionsService.openChat(session, agent.resource), - })); - this.contextMenuService.showContextMenu({ - getAnchor: () => this._button.element, - getActions: () => actions, - }); - } - - private _setVisible(visible: boolean): void { - this.element.classList.toggle('hidden', !visible); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts b/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts new file mode 100644 index 00000000000..c347b8ad247 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { ChatOriginKind, IChat, SessionStatus } from '../../../services/sessions/common/session.js'; +import './media/sessionRunningSubagentsControl.css'; + +/** Max characters of a subagent's current-step text before it is ellipsized. */ +const STEP_MAX_LENGTH = 48; + +interface IRunningSubagent { + readonly chat: IChat; + /** The subagent's display title. */ + readonly title: string; + /** Short "current step" text (the subagent's live status description), if any. */ + readonly step: string | undefined; +} + +/** + * An ephemeral status chip shown above the chat input while the currently-viewed + * chat has **running** subagents. It gives an at-a-glance view of in-flight + * background workers. Activating it opens a menu of those subagents; selecting + * one reveals its read-only chat. The chip hides entirely when no subagent is + * running, so it adds no chrome while idle. + */ +export class SessionRunningSubagentsControl extends Disposable { + + readonly element: HTMLElement; + private readonly _button: Button; + + private readonly _disposables = this._register(new MutableDisposable()); + private _session: IActiveSession | undefined; + private _subagents: readonly IRunningSubagent[] = []; + + constructor( + @ISessionsService private readonly sessionsService: ISessionsService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(); + this.element = $('.session-running-subagents'); + this._button = this._register(new Button(this.element, { secondary: true, supportIcons: true, ...defaultButtonStyles })); + this._button.element.classList.add('session-running-subagents-button'); + this._register(this._button.onDidClick(() => this._showMenu())); + this._setVisible(false); + } + + /** Track the currently-viewed chat; the chip monitors its running subagents. */ + setChat(chatResource: URI | undefined): void { + const store = new DisposableStore(); + this._disposables.value = store; + + if (!chatResource) { + this._update(undefined, []); + return; + } + + store.add(autorun(reader => { + const session = this._findOwningSession(chatResource, reader); + const subagents = session ? this._collectRunningSubagents(session, chatResource, reader) : []; + this._update(session, subagents); + })); + } + + private _findOwningSession(chatResource: URI, reader: IReader): IActiveSession | undefined { + for (const session of this.sessionsService.visibleSessions.read(reader)) { + if (session?.chats.read(reader).some(c => isEqual(c.resource, chatResource))) { + return session; + } + } + const active = this.sessionsService.activeSession.read(reader); + return active?.chats.read(reader).some(c => isEqual(c.resource, chatResource)) ? active : undefined; + } + + private _collectRunningSubagents(session: IActiveSession, chatResource: URI, reader: IReader): IRunningSubagent[] { + return session.chats.read(reader) + .filter(c => + c.origin?.kind === ChatOriginKind.Tool && + !!c.origin.parentChat && + isEqual(c.origin.parentChat, chatResource) && + c.status.read(reader) === SessionStatus.InProgress) + .map(chat => ({ + chat, + title: chat.title.read(reader) || localize('runningSubagents.untitled', "Subagent"), + step: this._stepText(chat, reader), + })); + } + + private _stepText(chat: IChat, reader: IReader): string | undefined { + const description = chat.description.read(reader)?.value.trim(); + if (!description) { + return undefined; + } + return description.length > STEP_MAX_LENGTH ? `${description.slice(0, STEP_MAX_LENGTH - 1)}\u2026` : description; + } + + private _update(session: IActiveSession | undefined, subagents: readonly IRunningSubagent[]): void { + this._session = session; + this._subagents = subagents; + + const count = subagents.length; + if (count === 1) { + // A single subagent: name it and surface its live progress inline. + const only = subagents[0]; + const label = only.step + ? localize('runningSubagents.singleWithStep', "Running subagent: {0} \u2014 {1}", only.title, only.step) + : localize('runningSubagents.single', "Running subagent: {0}", only.title); + this._button.label = `$(${Codicon.loading.id}~spin) ${label}`; + } else { + this._button.label = `$(${Codicon.loading.id}~spin) ${localize('runningSubagents.running', "{0} subagents running", count)}`; + } + this._setVisible(count > 0); + } + + private _showMenu(): void { + const session = this._session; + if (!session || this._subagents.length === 0) { + return; + } + const actions = this._subagents.map(({ chat, title, step }) => toAction({ + id: `runningSubagents.open.${chat.resource.toString()}`, + label: step ? `${title} \u2014 ${step}` : title, + class: ThemeIcon.asClassName(Codicon.loading), + run: () => this.sessionsService.openChat(session, chat.resource), + })); + this.contextMenuService.showContextMenu({ + getAnchor: () => this._button.element, + getActions: () => actions, + }); + } + + private _setVisible(visible: boolean): void { + this.element.classList.toggle('hidden', !visible); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index fc9d2b493b7..e8a09fc82f4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -10,7 +10,7 @@ import { IAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { disposableTimeout } from '../../../../base/common/async.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { basename } from '../../../../base/common/resources.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -143,7 +143,14 @@ export class WorkspacePicker extends Disposable { */ private readonly _connectionStatusWatch = this._register(new MutableDisposable()); + /** + * "Primary" trigger. This is the most recently created entry. Preserved for subclass + * read access (e.g. {@link WebWorkspacePicker} anchors its mobile sheet here) and for + * {@link showPicker} calls that do not supply an anchor. + */ protected _triggerElement: HTMLElement | undefined; + /** All live trigger elements. Label updates fan out to every entry. */ + private readonly _triggerElements = new Set(); private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _tabbedWidget: TabbedActionListWidget; private readonly _pickerGroupContext: IContextKey; @@ -266,51 +273,103 @@ export class WorkspacePicker extends Disposable { /** * Renders the project picker trigger button into the given container. * Returns the container element. + * + * This is the single-trigger entry point. Calling it again replaces the + * trigger created by the previous {@link render} call. For multi-trigger + * use (e.g. mirroring the same picker into two surfaces) call + * {@link renderTrigger} instead. */ render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); this._renderDisposables.add({ dispose: () => slot.remove() }); - const trigger = dom.append(slot, dom.$('a.action-label')); - trigger.tabIndex = 0; - trigger.role = 'button'; - trigger.setAttribute('aria-haspopup', 'listbox'); - trigger.setAttribute('aria-expanded', 'false'); - this._triggerElement = trigger; - // Onboarding spotlight target — id is referenced by the "new session" tour - // in vs/sessions/contrib/onboardingTours. - this._renderDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); - - this._updateTriggerLabel(); - - this._renderDisposables.add(touch.Gesture.addTarget(trigger)); - [dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => { - this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { - dom.EventHelper.stop(e, true); - this.showPicker(); - })); - }); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { - if (e.key === 'Enter' || e.key === ' ') { - dom.EventHelper.stop(e, true); - this.showPicker(); - } - })); + this._renderDisposables.add(this._addTrigger(slot)); return slot; } /** - * Shows the workspace picker dropdown anchored to the trigger element. + * Adds an additional trigger anchored to {@link container}. Unlike + * {@link render}, calling this does NOT remove triggers from earlier + * calls. Each trigger is independent and disposed via its own returned + * disposable. All live triggers share this picker's selection state and + * receive label updates from {@link _updateTriggerLabel}. + * + * Clicking any trigger anchors the popup to that specific trigger. + */ + renderTrigger(container: HTMLElement): IDisposable { + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); + const triggerDisposables = new DisposableStore(); + triggerDisposables.add({ dispose: () => slot.remove() }); + triggerDisposables.add(this._addTrigger(slot)); + return triggerDisposables; + } + + /** + * Shared trigger-creation core for both {@link render} and + * {@link renderTrigger}. Wires up the click / keyboard / touch handlers + * and the per-trigger lifecycle. + */ + private _addTrigger(slot: HTMLElement): IDisposable { + const triggerDisposables = new DisposableStore(); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); + + this._triggerElements.add(trigger); + this._triggerElement = trigger; + this._renderTriggerLabel(trigger); + // Onboarding spotlight target — id is referenced by the "new session" tour + // in vs/sessions/contrib/onboardingTours. + triggerDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); + + triggerDisposables.add(touch.Gesture.addTarget(trigger)); + [dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => { + triggerDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(false, trigger); + })); + }); + triggerDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(false, trigger); + } + })); + + triggerDisposables.add({ + dispose: () => { + this._triggerElements.delete(trigger); + if (this._triggerElement === trigger) { + // Demote to any other live trigger so subclasses that read + // `_triggerElement` (e.g. WebWorkspacePicker's mobile sheet + // path) don't dereference a removed node. + this._triggerElement = this._triggerElements.values().next().value; + } + }, + }); + + return triggerDisposables; + } + + /** + * Shows the workspace picker dropdown anchored to a trigger element. * * @param force When true, re-show even if the picker is already visible. * Used internally when swapping items in place after a tab * change. + * @param anchor The specific trigger element to anchor the popup to. When + * omitted, defaults to the most-recently rendered trigger. + * Pass through when more than one trigger is live and the + * popup should align with the one the user actually clicked. */ - showPicker(force = false): void { - if (!this._triggerElement) { + showPicker(force = false, anchor?: HTMLElement): void { + const triggerElement = anchor ?? this._triggerElement; + if (!triggerElement) { return; } const alreadyVisible = this.actionWidgetService.isVisible || this._tabbedWidget.isVisible; @@ -336,10 +395,10 @@ export class WorkspacePicker extends Disposable { const tabbed = tabs.length > 1; if (tabbed) { - this._showTabbedPicker(tabs); + this._showTabbedPicker(tabs, triggerElement); } else { this._activeTab = undefined; - this._showFlatPicker(); + this._showFlatPicker(triggerElement); } } @@ -409,11 +468,10 @@ export class WorkspacePicker extends Disposable { * `IActionWidgetService` so we benefit from its keybindings, focus * tracking and submenu chrome. */ - private _showFlatPicker(): void { + private _showFlatPicker(triggerElement: HTMLElement): void { // Tear down any previous tabbed popup before delegating to the // shared service — the two presentations don't co-exist. this._tabbedWidget.hide(); - const triggerElement = this._triggerElement!; const items = this._buildItems(); const delegate = this._buildDelegate(triggerElement, () => this._hidePicker()); triggerElement.setAttribute('aria-expanded', 'true'); @@ -439,8 +497,7 @@ export class WorkspacePicker extends Disposable { * platform `TabbedActionListWidget`; this picker only owns the data * and selection logic. */ - private _showTabbedPicker(tabs: readonly ITabDescriptor[]): void { - const triggerElement = this._triggerElement!; + private _showTabbedPicker(tabs: readonly ITabDescriptor[], triggerElement: HTMLElement): void { // Hide the flat picker if it's visible — the two presentations // don't co-exist. if (this.actionWidgetService.isVisible) { @@ -864,23 +921,25 @@ export class WorkspacePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { - return; + for (const trigger of this._triggerElements) { + this._renderTriggerLabel(trigger); } + } - dom.clearNode(this._triggerElement); + private _renderTriggerLabel(trigger: HTMLElement): void { + dom.clearNode(trigger); const workspace = this._selectedResolved?.workspace; const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); const icon = workspace ? workspace.icon : Codicon.project; - this._triggerElement.setAttribute('aria-label', workspace + trigger.setAttribute('aria-label', workspace ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); - dom.append(this._triggerElement, renderIcon(icon)); - const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + dom.append(trigger, renderIcon(icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; - dom.append(this._triggerElement, renderIcon(Codicon.chevronDownCompact)).classList.add('sessions-chat-dropdown-chevron'); + dom.append(trigger, renderIcon(Codicon.chevronDownCompact)).classList.add('sessions-chat-dropdown-chevron'); } /** diff --git a/src/vs/sessions/contrib/editor/browser/addTabActions.ts b/src/vs/sessions/contrib/editor/browser/addTabActions.ts new file mode 100644 index 00000000000..25ff3115f3a --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/addTabActions.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './emptyFileEditor.contribution.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; + +export const NEW_FILE_TAB_COMMAND_ID = 'workbench.action.agentSessions.newFileTab'; +export const NEW_BROWSER_TAB_COMMAND_ID = 'workbench.action.agentSessions.newBrowserTab'; + +// The add-tab actions are only registered in the single-pane layout, so the +// `when` clauses don't need to gate on the setting. +const addTabActionWhen = ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated()); + +const addTabLayoutWhen = ContextKeyExpr.and( + addTabActionWhen, + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext); + +export class NewFileTabAction extends Action2 { + + constructor() { + super({ + id: NEW_FILE_TAB_COMMAND_ID, + title: localize2('newFileTab', "New File"), + category: SessionsCategories.Sessions, + icon: Codicon.newFile, + f1: true, + precondition: addTabActionWhen, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: addTabActionWhen, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyB), + }, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 0, + when: addTabLayoutWhen + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const instantiationService = accessor.get(IInstantiationService); + + await editorService.openEditor(instantiationService.createInstance(EmptyFileEditorInput), { pinned: true }); + } +} + +export class NewBrowserTabAction extends Action2 { + + constructor() { + super({ + id: NEW_BROWSER_TAB_COMMAND_ID, + title: localize2('newBrowserTab', "New Browser"), + category: SessionsCategories.Sessions, + icon: Codicon.globe, + f1: true, + precondition: addTabActionWhen, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: addTabActionWhen, + primary: KeyChord(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK, KeyCode.KeyB), + }, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 1, + when: addTabLayoutWhen + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + const editorService = accessor.get(IEditorService); + const browserInput = browserViewWorkbenchService.getOrCreateLazy(generateUuid(), {}); + + await editorService.openEditor(browserInput); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index 77a788b9157..31ef41d57c1 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -3,17 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { NewBrowserTabAction, NewFileTabAction } from './addTabActions.js'; import { localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ActiveEditorContext, EditorPartModalContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; +import { ActiveEditorContext, AuxiliaryBarVisibleContext, EditorPartModalContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { EditorMaximizedContext } from '../../../common/contextkeys.js'; +import { EditorMaximizedContext, SinglePaneDetailChangesOrFilesActiveContext } from '../../../common/contextkeys.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; @@ -32,10 +35,43 @@ import { TEXT_FILE_EDITOR_ID } from '../../../../workbench/contrib/files/common/ import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { SessionsCategories } from '../../../common/categories.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; import { IChangesViewService } from '../../changes/common/changesViewService.js'; const terminalPanelHiddenForMaximizedEditor = new WeakSet(); +// The pop-out-to-modal and close-editor-area buttons do not apply to the single-pane +// redesign, so they are hidden when the setting is enabled (original layout keeps them). +const singlePaneDetailPanel = ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true); +const notSinglePaneDetailPanel = singlePaneDetailPanel.negate(); + +const editorTitleActionsWhen = ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext); +const singlePaneEditorTitleMaximizeOrder = 1000000; +const singlePaneEditorTitleHideEditorOrder = 999999; + +class SinglePaneAddTabContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneAddTab'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(registerAction2(NewFileTabAction)); + this._register(registerAction2(NewBrowserTabAction)); + } +} + +registerWorkbenchContribution2(SinglePaneAddTabContribution.ID, SinglePaneAddTabContribution, WorkbenchPhase.BlockStartup); + class MaximizeMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; @@ -45,16 +81,20 @@ class MaximizeMainEditorPartAction extends Action2 { title: localize2('maximizeMainEditorPart', "Maximize Editor Area"), icon: Codicon.screenFull, f1: false, - menu: { - id: MenuId.EditorTitleLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext, - EditorMaximizedContext.negate()) - } + menu: [ + { + id: MenuId.EditorTitle, + group: 'navigation', + order: singlePaneEditorTitleMaximizeOrder, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext.negate(), singlePaneDetailPanel, MainEditorAreaVisibleContext) + }, + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 99, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext.negate(), notSinglePaneDetailPanel) + } + ] }); } @@ -90,16 +130,20 @@ class RestoreMainEditorPartAction extends Action2 { icon: Codicon.screenNormal, f1: false, toggled: EditorMaximizedContext, - menu: { - id: MenuId.EditorTitleLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext, - EditorMaximizedContext) - } + menu: [ + { + id: MenuId.EditorTitle, + group: 'navigation', + order: singlePaneEditorTitleMaximizeOrder, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext, singlePaneDetailPanel, MainEditorAreaVisibleContext) + }, + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 99, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext, notSinglePaneDetailPanel) + } + ] }); } @@ -119,6 +163,42 @@ class RestoreMainEditorPartAction extends Action2 { registerAction2(RestoreMainEditorPartAction); +class HideMainEditorPartAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.hideMainEditorPart'; + + constructor() { + super({ + id: HideMainEditorPartAction.ID, + title: localize2('hideMainEditorPart', "Hide Editor"), + icon: Codicon.chevronRight, + f1: false, + menu: { + id: MenuId.EditorTitle, + group: 'navigation', + order: singlePaneEditorTitleHideEditorOrder, + when: ContextKeyExpr.and( + editorTitleActionsWhen, + singlePaneDetailPanel, + EditorMaximizedContext.negate(), + AuxiliaryBarVisibleContext, + SinglePaneDetailChangesOrFilesActiveContext, + MainEditorAreaVisibleContext) + } + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IAgentWorkbenchLayoutService); + layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + layoutService.setPartHidden(true, Parts.EDITOR_PART); + // Closing the editor area frees horizontal space, so bring the sessions + // list back (it may have been auto-collapsed when details was opened). + layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + } +} + +registerAction2(HideMainEditorPartAction); + class CloseMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.closeMainEditorPart'; @@ -135,7 +215,8 @@ class CloseMainEditorPartAction extends Action2 { when: ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext) + IsTopRightEditorGroupContext, + notSinglePaneDetailPanel) } }); } @@ -163,7 +244,8 @@ class OpenEditorInModalEditorAction extends Action2 { order: 1, when: ContextKeyExpr.and( IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated() + IsAuxiliaryWindowContext.toNegated(), + notSinglePaneDetailPanel ) } }); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts new file mode 100644 index 00000000000..dfa7f9f81f9 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { EmptyFileEditor } from './emptyFileEditor.js'; +import { EmptyFileEditorInput, EmptyFileEditorSerializer } from './emptyFileEditorInput.js'; + +/** + * Registers the empty-file editor (the "Select a file or search with " placeholder pane) and + * its serializer, but only in the single-pane layout where the "New File" add-tab flow uses it. + * Registered at startup (before editor restore) so persisted empty-file tabs can be deserialized. + * Opening it is owned by `addTabActions.ts`'s "New File" action. + */ +class SinglePaneEmptyFileEditorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneEmptyFileEditor'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + EmptyFileEditor, + EmptyFileEditor.ID, + localize('emptyFileEditor.label', "File") + ), + [new SyncDescriptor(EmptyFileEditorInput)] + )); + + this._register(Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + EmptyFileEditorInput.ID, + EmptyFileEditorSerializer + )); + } +} + +registerWorkbenchContribution2(SinglePaneEmptyFileEditorContribution.ID, SinglePaneEmptyFileEditorContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts new file mode 100644 index 00000000000..7e2efa2e680 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/emptyFileEditor.css'; +import { $, addDisposableListener, Dimension, EventType } from '../../../../base/browser/dom.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; + +const QUICK_OPEN_COMMAND_ID = 'workbench.action.quickOpen'; + +export class EmptyFileEditor extends EditorPane { + + static readonly ID = EmptyFileEditorInput.EDITOR_ID; + + private container: HTMLElement | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(EmptyFileEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + const keybindingLabel = this.keybindingService.lookupKeybinding(QUICK_OPEN_COMMAND_ID)?.getLabel() + ?? localize('emptyFileEditor.quickOpenFallback', "Quick Open"); + const placeholder = localize('emptyFileEditor.placeholder', "Select a file or search with {0}", keybindingLabel); + + this.container = $('div.empty-file-editor', { + role: 'button', + tabindex: 0, + 'aria-label': placeholder + }); + + const message = $('span.empty-file-editor-placeholder'); + message.textContent = placeholder; + this.container.appendChild(message); + parent.appendChild(this.container); + + // Support touch (iOS): register a gesture target so `Tap` fires, and handle + // both click and tap to open the picker (see sessionTypePicker/sessionFilesWidget). + this._register(Gesture.addTarget(this.container)); + for (const eventType of [EventType.CLICK, TouchEventType.Tap]) { + this._register(addDisposableListener(this.container, eventType, () => this.openQuickOpen())); + } + this._register(addDisposableListener(this.container, EventType.KEY_DOWN, e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openQuickOpen(); + } + })); + } + + override focus(): void { + this.container?.focus(); + } + + override layout(_dimension: Dimension): void { } + + private openQuickOpen(): void { + void this.commandService.executeCommand(QUICK_OPEN_COMMAND_ID, ''); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts new file mode 100644 index 00000000000..5bf68e49484 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; + +export class EmptyFileEditorInput extends EditorInput { + + static readonly ID = 'workbench.editors.agentSessions.emptyFile'; + static readonly EDITOR_ID = 'workbench.editor.agentSessions.emptyFile'; + + override get resource(): URI | undefined { + return undefined; + } + + override get typeId(): string { + return EmptyFileEditorInput.ID; + } + + override get editorId(): string { + return EmptyFileEditorInput.EDITOR_ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; + } + + override getName(): string { + return localize('emptyFileEditor.name', "Files"); + } + + override getIcon(): ThemeIcon { + return Codicon.files; + } + + override getTitle(_verbosity?: Verbosity): string { + return this.getName(); + } + + override canReopen(): boolean { + return true; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof EmptyFileEditorInput; + } +} + +export class EmptyFileEditorSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): editorInput is EmptyFileEditorInput { + return editorInput instanceof EmptyFileEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + return this.canSerialize(editorInput) ? '' : undefined; + } + + deserialize(instantiationService: IInstantiationService, _serializedEditor: string): EditorInput { + return instantiationService.createInstance(EmptyFileEditorInput); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css new file mode 100644 index 00000000000..2873c930a71 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.empty-file-editor { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-agentsPanel-foreground); + background: var(--vscode-editor-background); + cursor: pointer; + touch-action: manipulation; +} + +.empty-file-editor:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.empty-file-editor-placeholder { + color: var(--vscode-descriptionForeground); +} diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index d0cfe129ff4..0bd797b0ae1 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -8,10 +8,15 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { NewFileTabAction } from '../../browser/addTabActions.js'; +import { EmptyFileEditorInput } from '../../browser/emptyFileEditorInput.js'; // Import editor contribution to trigger action registration. import '../../browser/editor.contribution.js'; @@ -19,6 +24,27 @@ import '../../browser/editor.contribution.js'; suite('Sessions - Editor Contribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('new file tab action opens pinned empty file editor', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const opened: { editor: EditorInput; options: IEditorOptions | undefined }[] = []; + instantiationService.set(IEditorService, new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + const editor = args[0]; + if (editor instanceof EditorInput) { + opened.push({ editor: store.add(editor), options: args[1] as IEditorOptions | undefined }); + } + return undefined; + } + }); + + await new NewFileTabAction().run(instantiationService); + + assert.deepStrictEqual(opened.map(({ editor, options }) => ({ + isEmptyFileEditor: editor instanceof EmptyFileEditorInput, + pinned: options?.pinned + })), [{ isEmptyFileEditor: true, pinned: true }]); + }); + test('maximize editor hides the terminal panel before maximizing', async () => { const instantiationService = store.add(new TestInstantiationService()); const layoutService = new class extends mock() { diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md index a8ed8bd34ae..c2ac4a79ae1 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md @@ -59,14 +59,18 @@ default layout instead of stale state. Open editors are still preserved. - **Panel [B1]** — `_syncPanelVisibility(resource)` restores the record (default hidden); a live `onDidChangePartVisibility` listener for `PANEL_PART` updates it (suppressed while multiple sessions are visible). -- **Working sets [B2]** — active only when `workbench.editor.useModal !== 'all'` (`_useModalConfigObs`). - `activeSessionForWorkingSet` (`derivedObservableWithCache`) holds back the new session until the - workspace folders reflect its working directory. Save/apply on switch via a serializing `Sequencer`; - initial restore applies a saved set under `suppressEditorPartAutoVisibility()` only. `_saveWorkingSet` - also records the editor part's hidden state per session (`_editorPartHiddenBySession`, only while a - single session is visible — the editor area is shared in multi-session mode) so a switch-back - `_applyWorkingSet` skips the editor-part reveal for a session whose editor part was left hidden. - Cleanup on `onDidChangeSessions` (`_deleteWorkingSet` drops only the working set, never view state). +- **Working sets [B2]** — always active, regardless of `workbench.editor.useModal`: browser editors + dock in the shared grid editor part even when `useModal` is `'all'` (they except themselves from the + modal part), so their tabs still need per-session capture/restore in that mode. `_useModalConfigObs` + is only consulted inside `_applyWorkingSet` to decide whether to auto-reveal the editor part (skipped + in modal mode, since modal editors manage their own visibility). `activeSessionForWorkingSet` + (`derivedObservableWithCache`) holds back the new session until the workspace folders reflect its + working directory. Save/apply on switch via a serializing `Sequencer`; initial restore applies a saved + set under `suppressEditorPartAutoVisibility()` only. `_saveWorkingSet` also records the editor part's + hidden state per session (`_editorPartHiddenBySession`, only while a single session is visible — the + editor area is shared in multi-session mode) so a switch-back `_applyWorkingSet` skips the editor-part + reveal for a session whose editor part was left hidden. Cleanup on `onDidChangeSessions` + (`_deleteWorkingSet` drops only the working set, never view state). - **Persistence & migration [B3]** — per-session state is keyed by session `URI` and persisted to the workspace-scoped storage key `sessions.layoutState` (`StorageTarget.MACHINE`). `_loadState` restores on construction and drops corrupt data defensively; if the key is absent it migrates once from the diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts index 8fb83f586ea..5456c3c3fe7 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts @@ -17,8 +17,9 @@ import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILifecycleService } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -37,6 +38,7 @@ import { Menus } from '../../../browser/menus.js'; import { SessionsWelcomeVisibleContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { logSidePanelToggle } from '../../../common/sessionsTelemetry.js'; import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; +import { IChangesViewService } from '../../changes/common/changesViewService.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; @@ -135,10 +137,14 @@ export abstract class BaseLayoutController extends Disposable { @IStorageService protected readonly _storageService: IStorageService, @IConfigurationService protected readonly _configurationService: IConfigurationService, @IEditorService protected readonly _editorService: IEditorService, - @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IEditorGroupsService protected readonly _editorGroupsService: IEditorGroupsService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionChangesService protected readonly _sessionChangesService: ISessionChangesService, + @IChangesViewService protected readonly _changesViewService: IChangesViewService, @IViewDescriptorService protected readonly _viewDescriptorService: IViewDescriptorService, + @IContextKeyService protected readonly _contextKeyService: IContextKeyService, + @IInstantiationService protected readonly _instantiationService: IInstantiationService, + @ILifecycleService protected readonly _lifecycleService: ILifecycleService, ) { super(); @@ -230,36 +236,34 @@ export abstract class BaseLayoutController extends Disposable { return activeSession; }); - this._register(autorun(reader => { - const useModalConfig = this._useModalConfigObs.read(reader); - if (useModalConfig === 'all') { - return; + // Working sets are always active: browser editors dock in the shared grid + // editor part even when `workbench.editor.useModal` is `'all'` (they + // deliberately except themselves from the modal part), so their tabs + // still need to be captured/restored per session in that mode. + + // [B2] Session changed (save, apply) + this._register(runOnChange(activeSessionForWorkingSet, (session, previousSession) => { + // Save working set for previous session (skip for untitled sessions) + if (previousSession && previousSession.status.read(undefined) !== SessionStatus.Untitled) { + this._saveWorkingSet(previousSession.resource); } - // [B2] Session changed (save, apply) - reader.store.add(runOnChange(activeSessionForWorkingSet, (session, previousSession) => { - // Save working set for previous session (skip for untitled sessions) - if (previousSession && previousSession.status.read(undefined) !== SessionStatus.Untitled) { - this._saveWorkingSet(previousSession.resource); - } + // Apply working set for current session. + // On initial load (no previous session), only apply if we have a saved working set — + // skip applying 'empty' to avoid closing editors that are being restored. + if (previousSession || (session && this._workingSets.has(session.resource))) { + this._withSessionLayoutRestore(() => this._applyWorkingSet(session?.resource, { isInitialRestore: !previousSession })); + } + })); - // Apply working set for current session. - // On initial load (no previous session), only apply if we have a saved working set — - // skip applying 'empty' to avoid closing editors that are being restored. - if (previousSession || (session && this._workingSets.has(session.resource))) { - this._withSessionLayoutRestore(() => this._applyWorkingSet(session?.resource, { isInitialRestore: !previousSession })); - } - })); - - // [B2] Session state changed (archive, delete) - reader.store.add(this._sessionManagementService.onDidChangeSessions(e => { - const archivedSessions = e.changed.filter(session => session.isArchived.read(undefined)); - for (const session of [...e.removed, ...archivedSessions]) { - this._deleteWorkingSet(session.resource); - this._viewStateBySession.delete(session.resource); - this._editorPartHiddenBySession.delete(session.resource); - } - })); + // [B2] Session state changed (archive, delete) + this._register(this._sessionManagementService.onDidChangeSessions(e => { + const archivedSessions = e.changed.filter(session => session.isArchived.read(undefined)); + for (const session of [...e.removed, ...archivedSessions]) { + this._deleteWorkingSet(session.resource); + this._viewStateBySession.delete(session.resource); + this._editorPartHiddenBySession.delete(session.resource); + } })); // Side-pane toggle UI (menu item, keybinding, command-palette entry). @@ -267,8 +271,19 @@ export abstract class BaseLayoutController extends Disposable { // Platform-specific auxiliary bar / view-state management. this._registerViewStateManagement(); + + // Layout-specific auxiliary controllers (e.g. single-pane detail/tab + // controllers), created and owned by the layout controller so they share + // its lifecycle and coordinate through it. + this._registerAuxiliaryControllers(); } + /** + * Hook for a layout controller to create and own its auxiliary controllers. + * The base implementation does nothing. + */ + protected _registerAuxiliaryControllers(): void { } + /** * Registers the `Toggle Side Panel` action (menu item, keybinding, * command-palette entry). The action delegates straight to `toggleSidePane()`, @@ -351,6 +366,7 @@ export abstract class BaseLayoutController extends Disposable { */ toggleSidePane(): boolean { this._togglingSidePane = true; + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); try { // Treat the side pane as visible when *either* part is visible so the // toggle always closes both, instead of just revealing the auxiliary @@ -367,9 +383,10 @@ export abstract class BaseLayoutController extends Disposable { this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); this._layoutService.setPartHidden(true, Parts.EDITOR_PART); } else { - // Restore only the parts that were visible before hiding (default to - // both when there is no remembered state, e.g. after a reload). - const restore = this._lastVisibleSidePaneParts ?? { editor: true, auxiliaryBar: true }; + // Restore only the parts that were visible before hiding (falling back + // to the layout's default parts when there is no remembered state, + // e.g. after a reload). + const restore = this._lastVisibleSidePaneParts ?? this._defaultReopenSidePaneParts(); const hasEditors = this._editorGroupsService.groups.some(group => !group.isEmpty); const hasAuxViewContainers = this._hasActiveAuxViewContainers(); if (restore.editor && hasEditors) { @@ -396,6 +413,7 @@ export abstract class BaseLayoutController extends Disposable { return !isCurrentlyVisible; } finally { + suppressEditorPartAutoVisibility.dispose(); this._togglingSidePane = false; } } @@ -410,6 +428,15 @@ export abstract class BaseLayoutController extends Disposable { */ protected _onSidePaneToggled(_collapsed: boolean, _previousAuxiliaryBarVisible: boolean): void { } + /** + * The parts to reveal when re-opening the side pane with no remembered state + * (e.g. after a reload). The base default shows both the editor and the + * auxiliary bar; subclasses can specialize per layout / session type. + */ + protected _defaultReopenSidePaneParts(): { readonly editor: boolean; readonly auxiliaryBar: boolean } { + return { editor: true, auxiliaryBar: true }; + } + /** * [B4] Hook that lets a subclass snapshot the active session's view state when * state is about to be persisted. The base implementation does nothing. @@ -423,16 +450,27 @@ export abstract class BaseLayoutController extends Disposable { */ protected _withSessionLayoutRestore(work: () => void | Promise): void { this._restoringSessionLayoutDepth++; + // In single-pane mode a session-switch restore closes/opens editors (empty + // working-set apply, managed-tab reconciliation). Suppress editor-part + // auto-visibility for the whole restore so those layout-driven closes don't + // close the side pane or get mistaken for a user dismissing a managed tab. + const suppression = this._layoutService.isSinglePaneLayoutEnabled + ? this._layoutService.suppressEditorPartAutoVisibility() + : undefined; let settledSync = true; try { const result = work(); if (isThenable(result)) { settledSync = false; - Promise.resolve(result).catch(() => undefined).finally(() => this._restoringSessionLayoutDepth--); + Promise.resolve(result).catch(() => undefined).finally(() => { + this._restoringSessionLayoutDepth--; + suppression?.dispose(); + }); } } finally { if (settledSync) { this._restoringSessionLayoutDepth--; + suppression?.dispose(); } } } @@ -578,9 +616,29 @@ export abstract class BaseLayoutController extends Disposable { } const isModal = this._useModalConfigObs.get() === 'all'; + const singlePane = this._layoutService.isSinglePaneLayoutEnabled; + // The user may have hidden the editor part for this session (e.g. by + // closing the Side Panel while keeping editors open). Restore it as + // left instead of forcing the editor part back open on switch. + const editorPartHidden = sessionResource ? this._editorPartHiddenBySession.get(sessionResource) === true : false; + // In single-pane mode the docked editor lives in the grid (not the modal + // part) even when `useModal` is `'all'`, and a created session shows the + // docked Changes editor by default (Editor-only). So reveal the editor + // part for a created session unless it was explicitly hidden — otherwise + // it stays hidden and the side pane looks closed. New-session views keep + // their editor closed (R1), so they are excluded. + const isCreatedSession = this._sessionsService.activeSession.get()?.isCreated.get() ?? false; + const revealEditorPart = !editorPartHidden + && !options?.isInitialRestore + && (singlePane ? isCreatedSession : !isModal); if (workingSet === 'empty') { await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); + // A created single-pane session with no saved editors still shows its + // managed Changes editor, so reveal the editor part for it here. + if (singlePane && revealEditorPart && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._revealEditorPartForWorkingSet(); + } return; } @@ -596,17 +654,12 @@ export abstract class BaseLayoutController extends Disposable { return; } - // The user may have hidden the editor part for this session (e.g. by - // closing the Side Panel while keeping editors open). Restore it as - // left instead of forcing the editor part back open on switch. - const editorPartHidden = sessionResource ? this._editorPartHiddenBySession.get(sessionResource) === true : false; - - if (!isModal && !editorPartHidden && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (revealEditorPart && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._revealEditorPartForWorkingSet(); } const result = await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); - if (!isModal && !editorPartHidden && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (revealEditorPart && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._revealEditorPartForWorkingSet(); } }); diff --git a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md index b25a7ce2bf1..f173e7b6dd2 100644 --- a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md +++ b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md @@ -40,8 +40,8 @@ the **first** time (no remembered choice yet) and again after the whole side pan including after a reload (the pane is restored closed, but opening Changes re-reveals it). If you explicitly closed just the side pane (while keeping the editor open), it stays closed on later opens (and across reloads). An already-open side pane is left on whatever view you chose. Skipped while -the editor is maximized (D5) or multiple sessions are visible, where the side pane is managed by other -rules. +the editor is maximized (D5), multiple sessions are visible, or single-pane detail-panel mode is enabled, +where the side pane is managed by other rules. #### D9 — Closing the whole side pane is not an aux-bar choice The "side pane" is the editor area together with the auxiliary bar. Closing it (e.g. from the side @@ -79,7 +79,9 @@ A few transitions intentionally ignore the remembered state above. #### D4 — Submitting a new session When a new session becomes created (`isCreated` changes from false to true) while staying active, the side pane stays as you left it: if it was open it stays open and switches to **Changes**; if it was -closed it stays closed, but opening it later shows **Changes**. +closed it stays closed, but opening it later shows **Changes**. In single-pane detail-panel mode, the +submit transition also keeps the editor content closed because managed Changes/File tab opens are +suppressed; the open side pane shows the Changes detail. #### D5 — Maximizing the editor While the editor area is maximized, the side pane always shows **Changes**, regardless of the session's @@ -103,13 +105,37 @@ where the Changes and Files containers are hidden — so the aux bar would other When the auxiliary bar has **no active view container** (nothing to show), its part is kept **hidden** instead of showing an empty column, and the chat takes the space. This updates reactively as the active session flips (a container being gated off hides the part; a container becoming active again lets the -normal restore rules D3/D8 reveal it). The controller only ever *hides* an empty aux bar — it never -reveals one. Correspondingly, **Toggle Side Panel** only affects the part that has content: it reveals -the editor when it has editors and the aux bar is empty, and never reveals an empty aux bar (when -neither side has content the toggle-open is a no-op). For a **quick chat** — which has no side pane at -all (workspace-less, so the aux bar stays hidden and the chat is full-width) — the **Toggle Side Panel** -command is **disabled** outright (`precondition: IsQuickChatSessionContext.negate()`), so its menu item, -keybinding, and command-palette entry are inert. +normal restore rules D3/D8 reveal it) and also when the **part itself becomes visible** without any +container-/descriptor-change signal firing — a bare detail toggle that shows the column before a container +opens, or a restore that shows it while its containers are gated off — so the detail toggle can never read +"on" over a blank panel. Reconciling away an empty column runs under `suppressEditorPartAutoVisibility()` +so it never resurrects the editor as a side effect (editor visibility stays with D3/D8). The controller +only ever *hides* an empty aux bar — it never reveals one. Correspondingly, the docked host +(`setAuxiliaryBarHidden`) never force-opens a `hideIfEmpty` container with no active views when the aux bar +is shown, so a show can never present a blank docked panel; and **Toggle Side Panel** only affects the part +that has content: it reveals the editor when it has editors and the aux bar is empty, and never reveals an +empty aux bar (when neither side has content the toggle-open is a no-op). Together these guarantee the +invariant that `partVisibility.auxiliaryBar` (⇒ `AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true +iff the docked detail panel is rendered with an active view container. For a **quick chat** — which has no +side pane at all (workspace-less, so the aux bar stays hidden and the chat is full-width) — the **Toggle +Side Panel** command is **disabled** outright (`precondition: IsQuickChatSessionContext.negate()`), so its +menu item, keybinding, and command-palette entry are inert. + +#### D11 — Single-pane new-session views are Files-only +In single-pane detail-panel mode only, while a new (uncreated) workspace session view is active, the +editor content is hidden by default so the editor tab bar and Files detail panel remain without showing +editor content. The hide is **transition-triggered**: `_registerNewSessionRules` (a no-op base hook +overridden by `SinglePaneDesktopSessionLayoutController`) hides the editor part when it **just became +visible**, or when the new-session view was **just entered** with the editor already visible (an +inherited-visible editor from the previous session), and only while the active editor is not real content +(the managed empty tab or none). It leaves the editor alone once a real file/browser editor is active. +Switching to a managed tab (e.g. the Files placeholder) while the editor is **already** visible does not +hide it — only a visibility transition or entering the view does. An explicit reveal sticks — opening a +file reveals the editor before the file becomes the active editor (Task 4), and toggling the detail panel +off reveals the empty editor so the side pane does not vanish (Task 2); entering the view always resets to +editor-closed, so a stale cross-session explicit reveal cannot carry over. A momentary width-based reveal +(e.g. a sash drag) is re-hidden by the same rule. The shared D3b/D9b new-session side-pane visibility state +is unchanged: once the user hides the side pane for a new session, it stays hidden for later new sessions. ### Scenario: a cramped (small) window On a small window there isn't room for the sessions sidebar, the editor, and the side pane all at once. @@ -125,6 +151,13 @@ responsive state instead of reacting to it, so only in-session layout changes dr whole behaviour is gated by the experimental setting `sessions.layout.autoCollapseSessionsSidebar`, which defaults **on** in non-stable builds (Insiders / exploration) and **off** in stable. +**Single-pane override.** In single-pane detail-panel mode `_registerResponsiveSidebar` is replaced with an +explicit-details rule: the sessions sidebar is auto-hidden **only** when the user opens the details pane via +the **Toggle Details** action (`workbench.action.toggleAuxiliaryBar`), freeing horizontal space for the +editor area, and restored when the details pane is closed via the same action. It is not window-size driven, +ignores automatic details opens (submit, session restore), is suspended while multiple sessions are visible, +and hands control back once the user reopens the sessions sidebar manually. + --- ## Implementation notes @@ -150,7 +183,9 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta `_isAuxiliaryBarContainerPinned` implement D3c/D3d. - **New-session submit [D4]** — `_onNewSessionSubmitted` keeps the aux bar as left, switches an open one to Changes, and records Changes as the active container even when hidden so opening the side pane - later starts on Changes. + later starts on Changes. In single-pane mode, the tab opens that accompany this transition are managed + by `ChangesTabController` under editor-auto-visibility suppression, so they do not reveal editor + content. - **Editor maximized [D5]** — driven from the sync autorun via `editorMaximizedObs` (`IAgentWorkbenchLayoutService.isEditorMaximized()`); forced visibility is never captured (D2). The sessions workbench (`setEditorMaximized` in `browser/workbench.ts`) snapshots the editor part size + @@ -162,10 +197,16 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta (base; `IViewDescriptorService.getViewContainersByLocation(AuxiliaryBar)` filtered by `IViewsService.isViewContainerActive`) on container add/remove (`onDidChangeViewContainers`/`onDidChangeContainerLocation`), each aux container model's - `onDidChangeActiveViewDescriptors` (the `when`-gating signal), and aux-bar - `onDidChangeViewContainerVisibility`. `_syncAuxiliaryBarPartVisibility` hides `AUXILIARYBAR_PART` (via - `_hideAuxiliaryBarForRestore`, so [D2] doesn't record it) when there are no active containers, and - never reveals it — reveals stay with D3/D8. The base `toggleSidePane` re-open branch guards the aux-bar + `onDidChangeActiveViewDescriptors` (the `when`-gating signal), aux-bar + `onDidChangeViewContainerVisibility`, and the aux-bar part becoming visible + (`onDidChangePartVisibility` with `partId === AUXILIARYBAR_PART && visible`). + `_syncAuxiliaryBarPartVisibility` hides `AUXILIARYBAR_PART` (via + `_hideAuxiliaryBarForRestore`, so [D2] doesn't record it) when there are no active containers, wrapped in + `suppressEditorPartAutoVisibility()` so the hide never triggers the docked swap-reveal of the editor, and + never reveals it — reveals stay with D3/D8. Symmetrically, `Workbench.setAuxiliaryBarHidden` gates its + docked "show last/default composite" fallback on `_isAuxViewContainerActive(id)` (mirroring + `IViewsService.isViewContainerActive`) so it never force-opens an empty `hideIfEmpty` container. The base + `toggleSidePane` re-open branch guards the aux-bar un-hide with `_hasActiveAuxViewContainers()` symmetric to the editor's `hasEditors` guard, and the "ensure a visible effect" fallback prefers the editor and only falls back to the aux bar when it has active containers. The `Toggle Side Panel` command itself (`workbench.action.agentToggleSidePanel`, @@ -187,6 +228,11 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta visible. A hide that came from collapsing the whole side pane (D9) sets `auxiliaryBarHiddenByCollapse: true`, so opening a Changes editor re-reveals the aux bar even across a reload (the pane is still restored closed). The reveal flows through the [D2] listener, which records `{ auxiliaryBarVisible: true }`. + In single-pane detail-panel mode these listeners are not registered; the DetailPanelController maps the + active editor to a container and reveals the matching Files/Explorer or Changes detail panel when a File + or Changes editor becomes active. That reveal is edge-triggered on editor activation, so explicitly + hiding the detail panel is respected while the same editor remains active; Browser tabs still hide the + panel transiently and switching back re-opens it. - **Side-pane close [D9]** — the side-pane toggle lives on the controller (`toggleSidePane`). Its UI entry point (the `Toggle Side Panel` action: menu item, keybinding, command-palette entry, toggled icon) is registered by the base controller in its constructor and calls `toggleSidePane` directly. @@ -200,6 +246,9 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta an existing marker while the aux bar stays hidden and never fabricates one, so an explicit aux-bar hide on another session is never mistaken for a collapse. On reload the side pane is restored closed, yet opening Changes (D8) re-reveals it because the marker is present. + In single-pane mode, hiding the docked detail panel via `workbench.action.toggleAuxiliaryBar` reveals + editor content first when it was hidden, so the detail toggle swaps detail → editor instead of closing + the whole side pane; `Toggle Side Panel` remains the action that can hide both parts. - **New-session / side-pane close [D9b]** — the base `toggleSidePane` calls the `_onSidePaneToggled(collapsed, previousAuxiliaryBarVisible)` hook at the end (still inside the `_togglingSidePane` window). The desktop controller overrides it (skipped while multi-session / @@ -210,6 +259,14 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta collapsing an already editor-only state) just captures the resulting state, so an explicit aux-bar hide — including one whose editor-only state is restored when the pane re-opens — is never turned into a collapse. +- **Single-pane new-session rule [D11]** — `LayoutController` exposes `_registerNewSessionRules` as a + no-op hook. `SinglePaneDesktopSessionLayoutController` overrides it with an `autorun` over the active + session, multi-session state, editor maximized state, the active editor, and **the editor-part + visibility**; on an uncreated workspace session that is not a quick chat, it hides `EDITOR_PART` (under + `suppressEditorPartAutoVisibility()`) when the editor just became visible or the view was just entered + with the editor visible, tracking the previous editor-visible / in-new-session-view values across runs so + a mere active-editor change (e.g. switching to the Files placeholder) does not retrigger the hide. D3b + continues to open the Files container unless the shared new-session side-pane state says it is hidden. - **Responsive sidebar [D7]** — `_registerResponsiveSidebar` derives `spaceConstrained = enabled && small && editor visible && aux-bar visible && !multipleSessionsVisible` from the experimental setting `sessions.layout.autoCollapseSessionsSidebar` (`observableConfigValue`, default `product.quality !== diff --git a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts index 42c18710869..67be4b76175 100644 --- a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts @@ -45,7 +45,7 @@ export const RESPONSIVE_SIDEBAR_SETTING = 'sessions.layout.autoCollapseSessionsS * {@link BaseLayoutController}, it manages the per-session auxiliary bar * visibility and active view container. * - * Its behaviour is enumerated as rules **D1-D9** in + * Its behaviour is enumerated as rules **D1-D11** in * [desktopSessionLayoutController.md](./desktopSessionLayoutController.md). */ export class LayoutController extends BaseLayoutController { @@ -58,10 +58,10 @@ export class LayoutController extends BaseLayoutController { */ private _newSessionViewState: INewSessionViewState | undefined; - /** [D7] `true` while the sidebar is hidden because the controller auto-hid it (space constrained); only such hides are auto-reverted. */ - private _sidebarAutoHidden = false; + /** [D7] `true` while the sidebar is hidden because the controller auto-hid it; only such hides are auto-reverted. */ + protected _sidebarAutoHidden = false; /** [D7] Guards the manual-toggle listener while the controller itself toggles the sidebar. */ - private _applyingAutoSidebar = false; + protected _applyingAutoSidebar = false; /** [D7] Last computed space-constrained state, so the autorun only acts on real transitions. */ private _previousSpaceConstrained = false; @@ -159,6 +159,12 @@ export class LayoutController extends BaseLayoutController { if (this._hidingAuxiliaryBarForRestore) { return; } + // While restoring a session's layout (e.g., working-set apply in progress), + // visibility changes triggered by the single-pane detail-panel logic must + // not overwrite the session's intended state. + if (this._isRestoringSessionLayout) { + return; + } if (this.multipleSessionsVisibleObs.get()) { return; } @@ -181,6 +187,14 @@ export class LayoutController extends BaseLayoutController { } })); + this._registerChangesAutoReveal(); + + this._registerResponsiveSidebar(); + this._registerAuxiliaryBarPartVisibility(); + this._registerNewSessionRules(); + } + + protected _registerChangesAutoReveal(): void { // [D8] Reveal the Changes view in the side pane the first time a Changes // editor is opened for an existing session; afterwards respect the // remembered per-session choice (D1/D2/D3). @@ -195,11 +209,10 @@ export class LayoutController extends BaseLayoutController { this._revealChangesViewOnFirstOpen(); } })); - - this._registerResponsiveSidebar(); - this._registerAuxiliaryBarPartVisibility(); } + protected _registerNewSessionRules(): void { } + /** * [D10] Keep the auxiliary-bar part hidden when it has no active view * containers (e.g. a workspace-less quick chat where Changes+Files are gated @@ -224,6 +237,16 @@ export class LayoutController extends BaseLayoutController { this._syncAuxiliaryBarPartVisibility(); } })); + // The aux part can become visible without any container-/descriptor-change + // signal firing (e.g. a bare detail toggle that shows the part before any + // container is opened, or a restore that shows it while its containers are + // gated off). React to the part itself becoming visible so an empty column + // is reconciled away and the toggle never reads "on" over a blank panel. + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.AUXILIARYBAR_PART && e.visible) { + this._syncAuxiliaryBarPartVisibility(); + } + })); rewire(); } @@ -232,8 +255,30 @@ export class LayoutController extends BaseLayoutController { if (this._hasActiveAuxViewContainers()) { return; } + // No active aux view containers. This is only a genuine "empty column" for a + // workspace-less quick chat (Changes+Files permanently gated off). For a + // workspace-backed session it is a transient startup/activation state (the + // Files/Changes views gate on `SessionHasWorkspaceContext`, set async after + // the session activates), and during early reload there is no active session + // yet at all. Hiding in those transient cases collapses the restored-visible + // side pane and, since this method only ever hides, it stays closed — the + // reload flicker (opens then closes) and "Files not shown". So hide ONLY for + // an actual quick chat; a real quick-chat switch still fires + // `onDidChangeActiveViewDescriptors`, which re-runs this and hides then. + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession?.isQuickChat?.get() !== true) { + return; + } if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - this._hideAuxiliaryBarForRestore(); + // Removing an empty column must not, as a side effect, pop the editor + // open: the editor's visibility is governed by its own rules ([D3]/[D8]), + // not by this cleanup. Suppress the docked swap-reveal for the hide. + const suppression = this._layoutService.suppressEditorPartAutoVisibility(); + try { + this._hideAuxiliaryBarForRestore(); + } finally { + suppression.dispose(); + } } } @@ -300,7 +345,7 @@ export class LayoutController extends BaseLayoutController { * visible and never triggered by session navigation. Gated by the experimental * `sessions.layout.autoCollapseSessionsSidebar` setting. */ - private _registerResponsiveSidebar(): void { + protected _registerResponsiveSidebar(): void { const enabledObs = observableConfigValue(RESPONSIVE_SIDEBAR_SETTING, product.quality !== 'stable', this._configurationService); const smallWindowObs = observableFromEvent(this, @@ -377,7 +422,7 @@ export class LayoutController extends BaseLayoutController { } /** Returns `true` when the sidebar visibility was actually changed. */ - private _setSidebarAutoHidden(hidden: boolean): boolean { + protected _setSidebarAutoHidden(hidden: boolean): boolean { if (this._layoutService.isVisible(Parts.SIDEBAR_PART) === !hidden) { return false; } @@ -472,7 +517,11 @@ export class LayoutController extends BaseLayoutController { } // [D3] Restore the auxiliary bar in strict priority order. - private _syncAuxiliaryBarVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isCreated: boolean): void | Promise { + // Note: This method is intentionally synchronous (void return). View-opening calls are + // fire-and-forget so that _isRestoringSessionLayout ends immediately after sync operations. + // This allows D2 to capture user actions that happen after the sync restore but before + // working-set apply, while still skipping single-pane detail-panel reveals during working-set apply. + private _syncAuxiliaryBarVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isCreated: boolean): void { // [D3a] No resource / no workspace → do nothing. if (!sessionResource || !hasWorkspace) { return; @@ -484,7 +533,8 @@ export class LayoutController extends BaseLayoutController { this._hideAuxiliaryBarForRestore(); return; } - return this._openDefaultAuxiliaryBarContainer(false); + void this._openDefaultAuxiliaryBarContainer(false); + return; } const savedState = this._viewStateBySession.get(sessionResource); @@ -498,10 +548,11 @@ export class LayoutController extends BaseLayoutController { // [D3c] Restore the user's last explicit choice, but only if that pane is still pinned. const savedContainerId = savedState.auxiliaryBarActiveViewContainerId; if (savedContainerId && this._isAuxiliaryBarContainerPinned(savedContainerId)) { - return this._viewsService.openViewContainer(savedContainerId, false); + void this._viewsService.openViewContainer(savedContainerId, false); + return; } - return this._openDefaultAuxiliaryBarContainer(true); + void this._openDefaultAuxiliaryBarContainer(true); } /** [D3d] Prefer Changes for created sessions and Files for new sessions. */ diff --git a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts index e1c2a86dc94..e2ffa892df1 100644 --- a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts +++ b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts @@ -6,23 +6,43 @@ import { isMobile, isWeb } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { LayoutController, RESPONSIVE_SIDEBAR_SETTING } from './desktopSessionLayoutController.js'; import { MobileLayoutController } from './mobileSessionLayoutController.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { SinglePaneDesktopSessionLayoutController } from './singlePaneDesktopSessionLayoutController.js'; -// Contribute the layout controller for the current platform. The web bundle -// serves both the web desktop and the web phone layouts, so the choice is made -// at runtime; the native desktop bundle always uses the desktop controller. -// Registered at `BlockRestore` so the controller is in place before the window -// restores its UI, getting the side-pane layout right without a visible flash. -if (isWeb && isMobile) { - registerWorkbenchContribution2(MobileLayoutController.ID, MobileLayoutController, WorkbenchPhase.BlockRestore); -} else { - registerWorkbenchContribution2(LayoutController.ID, LayoutController, WorkbenchPhase.BlockRestore); +class SessionsLayoutContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsLayoutContribution'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + if (isWeb && isMobile) { + this._register(instantiationService.createInstance(MobileLayoutController)); + return; + } + + if (configurationService.getValue(DOCK_DETAIL_PANEL_SETTING)) { + this._register(instantiationService.createInstance(SinglePaneDesktopSessionLayoutController)); + return; + } + + this._register(instantiationService.createInstance(LayoutController)); + } } +registerWorkbenchContribution2(SessionsLayoutContribution.ID, SessionsLayoutContribution, WorkbenchPhase.BlockRestore); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', properties: { @@ -32,5 +52,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis default: product.quality !== 'stable', tags: ['experimental'], }, + [DOCK_DETAIL_PANEL_SETTING]: { + type: 'boolean', + markdownDescription: localize('sessions.layout.singlePaneDetailPanel', "Controls whether the Agents window docks the detail panel inside the editor so a single editor tab bar spans across the editor and the detail panel. Requires a window reload to take effect."), + default: false, + tags: ['experimental'], + }, }, }); diff --git a/src/vs/sessions/contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts new file mode 100644 index 00000000000..5f27a7cc1bf --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePaneDesktopSessionLayoutController.ts @@ -0,0 +1,634 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { Sequencer } from '../../../../base/common/async.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, IReader, observableFromEvent, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { isEqual, isEqualOrParent } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { EditorActivation, IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { ContextKeyExpr, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { SinglePaneDetailChangesOrFilesActiveContext } from '../../../common/contextkeys.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import type { ISessionWorkspace } from '../../../services/sessions/common/session.js'; +import { CHANGES_VIEW_CONTAINER_ID } from '../../changes/common/changes.js'; +import { EmptyFileEditorInput } from '../../editor/browser/emptyFileEditorInput.js'; +import { SESSIONS_FILES_CONTAINER_ID } from '../../files/browser/files.contribution.js'; +import { LayoutController } from './desktopSessionLayoutController.js'; + +/** Command that toggles the single-pane detail panel (auxiliary bar) from the editor title bar. */ +export const TOGGLE_DETAILS_COMMAND_ID = 'workbench.action.agentSessions.toggleDetails'; +const singlePaneEditorTitleDetailsOrder = 1000001; + +const changesEditorOptions: IEditorOptions = { + pinned: true, + index: 0, + preserveFocus: true, + isExplicit: false, +}; + +const fileTabOptions: IEditorOptions = { + pinned: true, + inactive: true, + preserveFocus: true, + activation: EditorActivation.PRESERVE, + isExplicit: false, +}; + +interface IManagedTabTargetState { + changesSessionResource: URI | undefined; + ensureFileTab: boolean; +} + +const enum DetailPanelTarget { + Hidden, + BrowserHidden, + Changes, + ChangesForced, + Files, + FilesForced, + Preserve +} + +/** + * Dedicated controller for the single-pane detail-panel layout. In addition to + * the base layout rules it owns the two single-pane-specific behaviours directly + * (rather than through separate controllers or a shared service): + * - managed docked tabs: the pinned Changes multi-diff tab and the empty Files + * placeholder tab, kept in sync with the active session's changes; + * - detail panel mapping: mapping the active editor to its detail container + * (Changes / Files) and revealing/hiding the auxiliary bar accordingly. + * + * Because both live on this controller they coordinate through it: a + * session-switch restore is signalled by {@link _isRestoringSessionLayout}, so a + * restore-driven editor change is never mistaken for a user action. + */ +export class SinglePaneDesktopSessionLayoutController extends LayoutController { + + // --- Managed tabs state --- + private readonly _tabSyncSequencer = new Sequencer(); + private _tabSyncGeneration = 0; + /** Managed tab kinds the user explicitly closed; not re-ensured until the session changes or the side pane is reopened. */ + private readonly _dismissedManagedTabs = new Set<'changes' | 'files'>(); + /** Editors the controller itself is closing, so their close is not mistaken for a user dismissal. */ + private readonly _internallyClosingEditors = new Set(); + private _lastSyncedSessionKey: string | undefined; + private _sidePaneWasVisible = false; + + // --- Detail panel state --- + private _changesOrFilesActiveContext: IContextKey | undefined; + private readonly _detailSequencer = new Sequencer(); + private _detailGeneration = 0; + private _hiddenByBrowser = false; + + /** Single-pane maps the active editor to its container (detail panel) instead of D8 auto-reveal. */ + protected override _registerChangesAutoReveal(): void { } + + /** + * With no remembered state, a created session re-opens to the Changes editor + * with the detail panel closed; a new-session view re-opens to the Files detail + * (its editor content stays hidden by R1). + */ + protected override _defaultReopenSidePaneParts(): { readonly editor: boolean; readonly auxiliaryBar: boolean } { + if (this._sessionsService.activeSession.get()?.isCreated.get() === false) { + return { editor: false, auxiliaryBar: true }; + } + return { editor: true, auxiliaryBar: false }; + } + + /** + * Registers the single-pane managed-tab and detail-panel behaviours once + * editors are restored, so the managed tabs are reconciled on top of the + * restored group rather than racing it. + */ + protected override _registerAuxiliaryControllers(): void { + this._lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (this._store.isDisposed) { + return; + } + this._registerManagedTabs(); + this._registerDetailPanel(); + }); + } + + // --- Managed docked tabs (Changes + Files placeholder) --- + + private _registerManagedTabs(): void { + // Re-sync the managed tabs when the session state changes, and also when the + // side pane (editor part or aux bar) visibility or the group's editors + // change. Tracking the aux bar too is essential: reopening the side pane in + // the new-session view only reveals the aux bar (the editor part stays + // hidden by R1), so without it the managed Files tab would never be + // re-ensured after the side pane was closed. + const sidePaneVisibleObs = observableFromEvent(this, this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + const editorsChangedSignal = observableSignalFromEvent(this, Event.any(this._editorService.onDidActiveEditorChange, this._editorService.onDidEditorsChange)); + + this._register(autorun(reader => { + const targetState = this._readManagedTabTargetState(reader); + sidePaneVisibleObs.read(reader); + editorsChangedSignal.read(reader); + const generation = ++this._tabSyncGeneration; + void this._tabSyncSequencer.queue(() => this._syncManagedTabs(targetState, generation)).catch(onUnexpectedError); + })); + + // A user-initiated close of a managed tab is remembered so the sync does not + // immediately re-create it. + this._register(this._editorService.onDidCloseEditor(e => this._handleManagedTabClosed(e.editor))); + } + + private _handleManagedTabClosed(editor: EditorInput): void { + // Ignore layout-driven closes (working-set apply on session switch): only a + // genuine user close should dismiss a managed tab. The controller's own + // reconciliation closes are tracked via `_internallyClosingEditors`. + if (this._internallyClosingEditors.has(editor) || this._isRestoringSessionLayout) { + return; + } + if (editor instanceof EmptyFileEditorInput) { + this._dismissedManagedTabs.add('files'); + } else if (this._getChangesEditorResource(editor) !== undefined) { + this._dismissedManagedTabs.add('changes'); + } + } + + private _readManagedTabTargetState(reader: IReader): IManagedTabTargetState { + const session = this._sessionsService.activeSession.read(reader); + if (!session) { + return { changesSessionResource: undefined, ensureFileTab: false }; + } + + const isCreated = session.isCreated.read(reader); + const isQuickChat = session.isQuickChat?.read(reader) ?? false; + const workspace = session.workspace.read(reader); + if (isQuickChat || !workspace) { + return { changesSessionResource: undefined, ensureFileTab: false }; + } + + return { changesSessionResource: isCreated ? session.resource : undefined, ensureFileTab: true }; + } + + private async _syncManagedTabs(state: IManagedTabTargetState, generation: number): Promise { + if (generation !== this._tabSyncGeneration) { + return; + } + + // Clear user-dismissed managed tabs on a session change or when the side + // pane is reopened from fully closed, so the tabs re-populate then while an + // in-session close stays respected. + const sessionKey = this._sessionsService.activeSession.get()?.resource.toString(); + const sidePaneVisible = this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (sessionKey !== this._lastSyncedSessionKey || (sidePaneVisible && !this._sidePaneWasVisible)) { + this._dismissedManagedTabs.clear(); + } + this._lastSyncedSessionKey = sessionKey; + this._sidePaneWasVisible = sidePaneVisible; + + const group = this._editorGroupsService.mainPart.activeGroup; + const changesResource = state.changesSessionResource ? this._sessionChangesService.getChangesEditorResource(state.changesSessionResource) : undefined; + + // Reconciling the managed tabs can transiently empty the group (e.g. + // closing a stale Changes tab before the Files tab is ensured, or before + // the workspace resolves on reload). Suppress editor-part auto-visibility + // across the whole reconciliation so a transient empty group is never + // mistaken for the user closing all tabs (which would close the side pane). + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + await this._closeInactiveChangesEditors(group, changesResource); + if (generation !== this._tabSyncGeneration) { + return; + } + + if (state.changesSessionResource && changesResource && !this._dismissedManagedTabs.has('changes')) { + this._changesViewService.setChangesetId(undefined); + + let changesEditor = this._findChangesEditor(group, changesResource); + if (!changesEditor) { + await this._sessionChangesService.openChangesEditor(state.changesSessionResource, changesEditorOptions, group); + if (generation !== this._tabSyncGeneration) { + return; + } + changesEditor = this._findChangesEditor(group, changesResource); + } + + if (changesEditor) { + this._ensureFirst(group, changesEditor); + } + } + + if (generation !== this._tabSyncGeneration || !state.ensureFileTab) { + return; + } + + // The managed Files tab is only removed when the user explicitly closes + // it (tracked via `_dismissedManagedTabs`); it is never auto-removed + // based on editor-area visibility, which caused a transient removal on + // reload that emptied the group and closed the whole side pane. + if (this._dismissedManagedTabs.has('files') && group.editors.some(editor => editor instanceof EmptyFileEditorInput)) { + this._dismissedManagedTabs.delete('files'); + } + + if (!this._dismissedManagedTabs.has('files')) { + await this._ensureDefaultFileTab(group); + } + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } + + private async _ensureDefaultFileTab(group: IEditorGroup): Promise { + if (group.editors.some(editor => editor instanceof EmptyFileEditorInput)) { + return; + } + + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + await this._editorService.openEditor(this._instantiationService.createInstance(EmptyFileEditorInput), fileTabOptions, group); + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } + + private async _closeInactiveChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined): Promise { + const editorsToClose = group.editors.filter(editor => { + const resource = this._getChangesEditorResource(editor); + return resource && (!activeChangesResource || !isEqual(resource, activeChangesResource)); + }); + + if (editorsToClose.length > 0) { + editorsToClose.forEach(editor => this._internallyClosingEditors.add(editor)); + try { + await this._editorService.closeEditors(editorsToClose.map(editor => ({ groupId: group.id, editor })), { preserveFocus: true }); + } finally { + editorsToClose.forEach(editor => this._internallyClosingEditors.delete(editor)); + } + } + } + + private _findChangesEditor(group: IEditorGroup, changesResource: URI): EditorInput | undefined { + return group.editors.find(editor => { + const resource = this._getChangesEditorResource(editor); + return !!resource && isEqual(resource, changesResource); + }); + } + + private _getChangesEditorResource(editor: EditorInput): URI | undefined { + const resource = editor.resource; + return resource && this._sessionChangesService.getSessionResource(resource) ? resource : undefined; + } + + private _ensureFirst(group: IEditorGroup, editor: EditorInput): void { + if (!group.isPinned(editor)) { + group.pinEditor(editor); + } + + if (group.getIndexOfEditor(editor) !== 0) { + group.moveEditor(editor, group, changesEditorOptions); + } + } + + // --- Detail panel (active editor -> detail container) --- + + private _registerDetailPanel(): void { + this._changesOrFilesActiveContext = SinglePaneDetailChangesOrFilesActiveContext.bindTo(this._contextKeyService); + const activeEditorObs = observableFromEvent(this, this._editorService.onDidActiveEditorChange, () => this._editorService.activeEditor); + const mainPartEmptyObs = observableFromEvent(this, Event.any(this._editorService.onDidActiveEditorChange, this._editorService.onDidEditorsChange, this._editorService.onDidCloseEditor), () => this._isMainPartEmpty()); + const auxBarVisibleObs = observableFromEvent(this, this._layoutService.onDidChangePartVisibility, () => this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + const editorMaximizedObs = observableFromEvent(this, this._layoutService.onDidChangeEditorMaximized, () => this._layoutService.isEditorMaximized()); + + this._register(autorun(reader => { + const activeEditor = activeEditorObs.read(reader); + const target = this._computeDetailTarget(reader, activeEditor, mainPartEmptyObs, editorMaximizedObs); + const isChangesOrFilesTarget = target === DetailPanelTarget.Changes || target === DetailPanelTarget.ChangesForced || target === DetailPanelTarget.Files || target === DetailPanelTarget.FilesForced; + this._changesOrFilesActiveContext!.set(isChangesOrFilesTarget); + const auxBarVisible = auxBarVisibleObs.read(reader); + const generation = ++this._detailGeneration; + void this._detailSequencer.queue(() => this._syncDetailTarget(target, auxBarVisible, generation)).catch(onUnexpectedError); + })); + } + + private _computeDetailTarget(reader: IReader, activeEditor: EditorInput | undefined, mainPartEmptyObs: IObservable, editorMaximizedObs: IObservable): DetailPanelTarget { + const activeSession = this._sessionsService.activeSession.read(reader); + const isQuickChat = activeSession?.isQuickChat?.read(reader) ?? false; + const workspace = activeSession?.workspace.read(reader); + if (isQuickChat || !workspace) { + return DetailPanelTarget.Hidden; + } + + // For a created session an empty editor group means the whole side pane was + // closed, so hide the detail. In the new-session (uncreated) view the Files + // detail is open by default and owned by the layout controller (D3b); its + // editor group is transiently empty while the Files tab is (re)ensured, so + // don't hide the detail here — that transient hide would otherwise be + // captured (D2) as the new-session preference and stick across cmd+n. + if (mainPartEmptyObs.read(reader) && (activeSession?.isCreated.read(reader) ?? true)) { + return DetailPanelTarget.Hidden; + } + + if (editorMaximizedObs.read(reader)) { + return DetailPanelTarget.Changes; + } + + if (!activeEditor) { + return activeSession?.isCreated.read(reader) ? DetailPanelTarget.Changes : DetailPanelTarget.Files; + } + + if (activeEditor instanceof BrowserEditorInput) { + return DetailPanelTarget.BrowserHidden; + } + + if (this._isChangesEditor(activeEditor)) { + return DetailPanelTarget.ChangesForced; + } + + if (this._isFileEditor(activeEditor, workspace)) { + return DetailPanelTarget.FilesForced; + } + + return DetailPanelTarget.Preserve; + } + + private _isMainPartEmpty(): boolean { + for (const group of this._editorGroupsService.mainPart.groups) { + if (!group.isEmpty) { + return false; + } + } + return true; + } + + private async _syncDetailTarget(target: DetailPanelTarget, auxBarVisible: boolean, generation: number): Promise { + if (generation !== this._detailGeneration) { + return; + } + + switch (target) { + case DetailPanelTarget.Hidden: + if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.BrowserHidden: + if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + this._hiddenByBrowser = true; + return; + case DetailPanelTarget.Changes: + if (!auxBarVisible && this._hiddenByBrowser) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + auxBarVisible = true; + } + // Only switch the active container while the detail panel is visible so the + // user can hide it; toggling it back on then shows the contextual container. + if (!auxBarVisible) { + return; + } + await this._viewsService.openViewContainer(CHANGES_VIEW_CONTAINER_ID, false); + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.ChangesForced: + await this._syncForcedDetailTarget(CHANGES_VIEW_CONTAINER_ID, auxBarVisible); + return; + case DetailPanelTarget.Files: + if (!auxBarVisible && this._hiddenByBrowser) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + auxBarVisible = true; + } + if (!auxBarVisible) { + return; + } + await this._viewsService.openViewContainer(SESSIONS_FILES_CONTAINER_ID, false); + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.FilesForced: + await this._syncForcedDetailTarget(SESSIONS_FILES_CONTAINER_ID, auxBarVisible); + return; + case DetailPanelTarget.Preserve: + this._hiddenByBrowser = false; + return; + } + } + + private async _syncForcedDetailTarget(viewContainerId: string, auxBarVisible: boolean): Promise { + if (!auxBarVisible) { + // The detail panel is hidden. A created session defaults to the Changes + // editor with the detail closed, and an explicit / per-session hide is + // respected — so a Changes/file editor becoming active never + // force-reveals the detail. The one exception is restoring the detail + // after a *transient* browser-tab hide (`_hiddenByBrowser`). Never reveal + // while the whole side pane is closed (the editor content is also hidden) + // or during a session-switch layout restore. + if (!this._hiddenByBrowser + || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) + || this._isRestoringSessionLayout) { + return; + } + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + await this._viewsService.openViewContainer(viewContainerId, false); + this._hiddenByBrowser = false; + } + + private _isChangesEditor(editor: EditorInput): boolean { + const resource = editor.resource; + return !!resource && this._sessionChangesService.getSessionResource(resource) !== undefined; + } + + private _isFileEditor(editor: EditorInput, workspace: ISessionWorkspace): boolean { + if (editor instanceof EmptyFileEditorInput) { + return true; + } + const resource = editor instanceof FileEditorInput ? editor.resource : undefined; + return !!resource && workspace.folders.some(folder => + isEqualOrParent(resource, folder.root) || isEqualOrParent(resource, folder.workingDirectory)); + } + + // --- [D7 single-pane] Responsive sessions-list auto-hide --- + + /** + * [D7 single-pane] Auto-hide the sessions list when the user needs more room + * for the side pane: opening the details pane via the Toggle Details action, + * or opening a real file/diff into the editor area (Scenario 8). The list is + * restored when details is explicitly closed. Unlike the base responsive rule + * this is not window-size driven and never reacts to automatic details opens + * (submit, session restore). + */ + protected override _registerResponsiveSidebar(): void { + // The Toggle Details action toggles the detail panel and, as part of the + // same gesture, auto-hides / restores the sessions list. It is a dedicated + // command owned by this controller rather than a listener on the core + // aux-bar toggle command. + this._register(this._registerToggleDetailsAction()); + + // [Scenario 8] Opening a real file/browser editor from the Files or Changes + // view needs editor-area room, so auto-hide the sessions list — but only in + // an existing (created) session and only when the editor area is currently + // closed (this open will reveal it). Managed tabs (the Changes multi-diff + // and the empty Files placeholder) are not FileEditorInput/BrowserEditorInput + // so they never trigger this; a session-switch restore is excluded too. + this._register(this._editorService.onWillOpenEditor(e => { + if (this._isRestoringSessionLayout || this.multipleSessionsVisibleObs.get() || this._layoutService.isEditorMaximized()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession?.isCreated.get() || this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + return; + } + if (!(e.editor instanceof FileEditorInput || e.editor instanceof BrowserEditorInput)) { + return; + } + if (this._setSidebarAutoHidden(true)) { + this._sidebarAutoHidden = true; + } + })); + + // A manual sessions-sidebar toggle hands control back to the user. + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId !== Parts.SIDEBAR_PART || this._applyingAutoSidebar) { + return; + } + this._sidebarAutoHidden = false; + })); + } + + /** + * Toggle the detail panel (auxiliary bar) and, in the same gesture, auto-hide + * the sessions list to free room when opening it (restoring the list when + * closing). Returns whether the detail panel is now visible. + */ + toggleDetails(): boolean { + const nowVisible = !this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this._layoutService.setPartHidden(!nowVisible, Parts.AUXILIARYBAR_PART); + + if (!this.multipleSessionsVisibleObs.get()) { + if (nowVisible) { + if (this._setSidebarAutoHidden(true)) { + this._sidebarAutoHidden = true; + } + } else if (this._sidebarAutoHidden) { + this._setSidebarAutoHidden(false); + this._sidebarAutoHidden = false; + } + } + return nowVisible; + } + + private _registerToggleDetailsAction(): IDisposable { + const that = this; + return registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_DETAILS_COMMAND_ID, + title: localize2('toggleDetails', "Toggle Details"), + icon: Codicon.listSelection, + f1: false, + toggled: AuxiliaryBarVisibleContext, + menu: { + id: MenuId.EditorTitle, + group: 'navigation', + order: singlePaneEditorTitleDetailsOrder, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true), + MainEditorAreaVisibleContext) + } + }); + } + + run(): void { + that.toggleDetails(); + } + }); + } + + // --- [R1] Keep the editor content closed in the new-session view --- + + /** + * [R1] Keep the editor content closed by default in the new-session view. Hides + * the editor when it is revealed (or when the view is entered with the editor + * visible) from a non-explicit source with no real content. Explicit reveals + * (opening a file, toggling details off) are recorded by the workbench and + * stick; automatic reveals (working-set restore, layout races, an + * inherited-visible editor from a previous session) are re-hidden. Switching to + * a managed tab (e.g. the Files placeholder) while the editor is *already* + * visible does not hide it — only a visibility transition or entering the view + * does. + */ + protected override _registerNewSessionRules(): void { + const editorMaximizedObs = observableFromEvent(this, + this._layoutService.onDidChangeEditorMaximized, + () => this._layoutService.isEditorMaximized()); + const editorVisibleObs = observableFromEvent(this, + this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); + const activeEditorObs = observableFromEvent(this, + this._editorService.onDidActiveEditorChange, + () => this._editorService.activeEditor); + + let previousEditorVisible = false; + let previousInNewSessionView = false; + this._register(autorun(reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + const inNewSessionView = !!activeSession + && !this.multipleSessionsVisibleObs.read(reader) + && !editorMaximizedObs.read(reader) + && !activeSession.isCreated.read(reader) + && activeSession.isQuickChat?.read(reader) !== true + && activeSession.workspace.read(reader)?.folders?.[0]?.root !== undefined; + + // A real user-opened editor: an actual file or the integrated browser. + // The managed empty landing tab (EmptyFileEditorInput) and "no active + // editor" are not real content, so the editor content stays hidden. + const activeEditor = activeEditorObs.read(reader); + const hasRealContent = activeEditor instanceof FileEditorInput || activeEditor instanceof BrowserEditorInput; + + const editorVisible = editorVisibleObs.read(reader); + // Hide only when the editor just *became* visible, or when the + // new-session view was just entered with the editor already visible + // (an inherited-visible editor from the previous session). Switching to + // a managed tab while the editor is already visible must not hide it. + const editorJustRevealed = editorVisible && !previousEditorVisible; + const justEnteredNewSessionView = inNewSessionView && !previousInNewSessionView; + previousEditorVisible = editorVisible; + previousInNewSessionView = inNewSessionView; + + if (!inNewSessionView || hasRealContent || !editorVisible) { + return; + } + + // Re-hide the editor from a non-explicit reveal. Entering the new-session + // view always resets to editor-closed (a stale explicit reveal from a + // previous session must not carry over). An in-session reveal is re-hidden + // only when it was automatic — an explicit reveal (opening a file, + // toggling details off, which reveals the empty editor so the side pane + // does not vanish) is respected. + const shouldHide = justEnteredNewSessionView || (editorJustRevealed && !this._layoutService.isEditorRevealedExplicitly()); + if (shouldHide) { + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + this._layoutService.setPartHidden(true, Parts.EDITOR_PART); + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } + })); + } +} diff --git a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts index 3c712a56632..0ec761bd2a3 100644 --- a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts @@ -151,6 +151,43 @@ suite('BaseLayoutController', () => { assert.strictEqual(entry.editorPartHidden, undefined, 'editor part hidden state must not be captured while multiple sessions are visible'); }); + test('[B2] restores the working set on switch without forcing the editor part visible in modal mode', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + + // `useModal: 'all'` — editors are otherwise forced modal, but browser + // tabs still dock in the shared grid editor part, so working sets must + // still be captured/restored per session on switch. + createController({ useModal: 'all', workspaceFolders }); + + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + harness.visibleEditorsList = [{}]; + harness.activeSessionObs.set(session1, undefined); + await timeout(0); + + // Switch away (captures session 1's working set)… + harness.activeSessionObs.set(session2, undefined); + await timeout(0); + + // …and back: the working set is restored, but the editor part is never + // force-revealed in modal mode (modal editors manage their own visibility). + harness.applyWorkingSetCalls = []; + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(session1, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.applyWorkingSetCalls, + [{ id: `session-working-set:${session1.resource.toString()}`, name: `session-working-set:${session1.resource.toString()}` }], + 'working set should be restored on switch in modal mode' + ); + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'editor part should not be revealed in modal mode' + ); + }); + // --- [B3] Persistence & migration / [B4] Save --- test('[B3] migrates legacy sessions.workingSets key and [B4] persists to sessions.layoutState', () => { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 5c10c285ee4..f29facf5c92 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -5,19 +5,30 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; import { StorageScope, WillSaveStateReason } from '../../../../../platform/storage/common/storage.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { ViewContainerLocation } from '../../../../../workbench/common/views.js'; import { ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { SinglePaneDetailChangesOrFilesActiveContext } from '../../../../common/contextkeys.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IEditorWillOpenEvent } from '../../../../../workbench/common/editor.js'; import { LayoutController } from '../../browser/desktopSessionLayoutController.js'; +import { SinglePaneDesktopSessionLayoutController, TOGGLE_DETAILS_COMMAND_ID } from '../../browser/singlePaneDesktopSessionLayoutController.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../../../changes/common/changes.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; -import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession } from './layoutControllerTestUtils.js'; +import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession, TestStubEditorInput } from './layoutControllerTestUtils.js'; suite('LayoutController (desktop)', () => { @@ -30,11 +41,23 @@ suite('LayoutController (desktop)', () => { } } + class TestSinglePaneController extends SinglePaneDesktopSessionLayoutController { + /** Runs `work` while a session-switch layout restore is held (see `_withSessionLayoutRestore`). */ + runWithRestore(work: () => void | Promise): void { + this._withSessionLayoutRestore(work); + } + } + function createController(options: ICreateOptions = {}): TestLayoutController { harness = createTestHarness(store, options); return store.add(harness.instaService.createInstance(TestLayoutController)); } + function createSinglePaneController(options: ICreateOptions = {}): TestSinglePaneController { + harness = createTestHarness(store, options); + return store.add(harness.instaService.createInstance(TestSinglePaneController)); + } + teardown(() => store.clear()); ensureNoDisposablesAreLeakedInTestSuite(); @@ -189,6 +212,275 @@ suite('LayoutController (desktop)', () => { ); }); + test('[D3c/single-pane] restores aux-bar hidden state even when external reveal fires during working-set apply', async () => { + // Scenario 4: Session A (created) has detail closed (aux-bar hidden, editor visible). + // Session B (created) has both visible. Switch A->B, then B->A. External component + // (the single-pane detail panel) reveals aux-bar during working-set restore. A's + // hidden state must still be restored. + createSinglePaneController(); + const sessionA = makeSession(URI.parse('session:a')); + const sessionB = makeSession(URI.parse('session:b')); + + // Session A active, user hides the detail panel (aux-bar) while editor is open. + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.partVisibility.set(Parts.EDITOR_PART, true); + + // Switch to session B (both editor and aux-bar visible). + harness.activeSessionObs.set(sessionB, undefined); + harness.visibleSessionsObs.set([sessionB], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + // Simulate the single-pane detail panel revealing the aux-bar during + // working-set restore (while _isRestoringSessionLayout is true). + harness.onApplyWorkingSet = () => { + if (!harness.partVisibility.get(Parts.AUXILIARYBAR_PART)) { + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + } + }; + + // Switch back to session A. + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + + // Clean up hook. + harness.onApplyWorkingSet = undefined; + + // The aux-bar should be hidden to match session A's saved state. The external + // reveal during working-set apply must NOT overwrite the per-session state. + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar should be hidden when returning to session A (detail-closed state)' + ); + }); + + test('[single-pane] restores the detail panel after a browser tab hides it', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + const isChangesOrFilesActive = () => harness.contextKeyService.getContextKeyValue(SinglePaneDetailChangesOrFilesActiveContext.key); + + assert.strictEqual(isChangesOrFilesActive(), false, 'hidden target should clear the editor chevron context'); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + assert.strictEqual(isChangesOrFilesActive(), true, 'changes target should enable the editor chevron context'); + + const browserEditor = Object.create(BrowserEditorInput.prototype) as BrowserEditorInput; + Object.defineProperty(browserEditor, 'resource', { value: URI.parse('browser://test') }); + + harness.activeEditorInput = browserEditor; + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(isChangesOrFilesActive(), false, 'browser target should clear the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'browser tabs should hide the detail panel' + ); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(isChangesOrFilesActive(), true, 'files target should enable the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'file tabs should restore the detail panel after browser hides it' + ); + assert.ok( + harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + 'file tabs should reopen the Files container after browser hides it' + ); + }); + + test('[single-pane] hides the detail panel when the main editor part is empty and keeps it closed on tab open', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + const isChangesOrFilesActive = () => harness.contextKeyService.getContextKeyValue(SinglePaneDetailChangesOrFilesActiveContext.key); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + assert.strictEqual(isChangesOrFilesActive(), true, 'non-empty no-active-editor fallback should keep contextual detail active'); + + harness.setPartHiddenCalls = []; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.editorGroupsHaveContent = false; + harness.activeEditorInput = undefined; + harness.onDidEditorsChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + isChangesOrFilesActive: isChangesOrFilesActive(), + hiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true).length, + }, { + isChangesOrFilesActive: false, + hiddenCalls: 1, + }); + + // A tab re-opens: the context key flips back on, but the detail is NOT + // force-revealed (a created session defaults to the editor with the detail closed). + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.editorGroupsHaveContent = true; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidEditorsChange.fire(); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + isChangesOrFilesActive: isChangesOrFilesActive(), + reveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }, { + isChangesOrFilesActive: true, + reveals: 0, + openedFiles: false, + }); + }); + + test('[cmd+n] keeps the detail panel visible for a new-session view with a transiently empty editor group', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + // The Files tab is being (re)ensured, so the editor group is transiently empty. + harness.editorGroupsHaveContent = false; + harness.activeEditorInput = undefined; + harness.onDidEditorsChange.fire(); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + // The detail must NOT be hidden for the new-session view (unlike a created + // session, where an empty group means the whole side pane was closed). + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true).length, + 0); + }); + + test('[single-pane] keeps the detail panel closed by default when a file/changes editor is active', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Detail closed (the created-session default, not a browser-tab hide). + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + await timeout(0); + + // A file tab becomes active: the detail must stay closed (no force-reveal). + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + reveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }, { + reveals: 0, + openedFiles: false, + }); + }); + + test('[per-session detail] does not force-reveal the detail on editor activation, during or after a restore', async () => { + const controller = createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Session's detail is closed (the created-session default) with its editor visible. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.partVisibility.set(Parts.EDITOR_PART, true); + await timeout(0); + + // Hold a session-switch restore open. The restore makes the Files editor + // active; that editor change must NOT reveal the detail. + let releaseRestore!: () => void; + const restoreGate = new Promise(resolve => { releaseRestore = resolve; }); + controller.runWithRestore(() => restoreGate); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0, + 'the detail must stay closed during a session-switch restore'); + + // After the restore ends, a plain editor activation still does not reveal + // the detail (a created session defaults to the editor with the detail closed). + releaseRestore(); + await restoreGate; + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0, + 'the detail stays closed by default after the restore'); + }); + + test('[Scenario C] does not re-reveal the detail on reload when the whole side pane was closed', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Whole side pane closed (as persisted across a reload): both the editor + // content and the detail are hidden. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + + // The restored managed tab becomes active; the detail must NOT re-reveal. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0); + }); + // --- [D4] New-session submit --- test('[D4] keeps the open side pane and shows Changes when a new session is submitted', () => { @@ -563,6 +855,41 @@ suite('LayoutController (desktop)', () => { assert.ok(harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), 'aux bar should be restored'); }); + test('[reopen default single-pane] a created session opens the side pane to the editor with the detail closed', () => { + const controller = createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.editorGroupsHaveContent = true; + + // The side pane starts fully closed with no remembered parts (e.g. after a reload). + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.setPartHiddenCalls = []; + + controller.toggleSidePane(); + + assert.deepStrictEqual({ + editorRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + detailRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + }, { editorRevealed: true, detailRevealed: false }); + }); + + test('[reopen default single-pane] a new-session view opens the side pane to the Files detail', () => { + const controller = createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + harness.editorGroupsHaveContent = true; + + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.setPartHiddenCalls = []; + + controller.toggleSidePane(); + + assert.deepStrictEqual({ + editorRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + detailRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + }, { editorRevealed: false, detailRevealed: true }); + }); + test('[D8] does not reveal the Changes view for an untitled session', () => { createController(); const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled }); @@ -575,6 +902,250 @@ suite('LayoutController (desktop)', () => { assert.ok(!harness.openedViews.includes(CHANGES_VIEW_ID), 'untitled sessions are governed by D3b/D4, not D8'); }); + test('[R1] single-pane hides the editor on entering a new-session view but keeps an explicit in-session reveal', async () => { + createSinglePaneController(); + const untitled1 = makeSession(URI.parse('session:untitled1'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + const untitled2 = makeSession(URI.parse('session:untitled2'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled1, undefined); + await timeout(0); + + const firstReveal = { + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }; + + // An *explicit* editor reveal in the same new-session view (opening a file, + // toggling details off) must stick. + harness.setPartHiddenCalls = []; + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + const explicitRevealEditorHiddenCalls = harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden); + + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.editorRevealedExplicitly = false; + + harness.activeSessionObs.set(untitled2, undefined); + await timeout(0); + + assert.deepStrictEqual({ + firstReveal, + explicitRevealEditorHiddenCalls, + secondRevealEditorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + firstReveal: { + editorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + openedFiles: true, + }, + explicitRevealEditorHiddenCalls: [], + secondRevealEditorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + }); + }); + + test('[R1] single-pane re-hides the editor on an automatic reveal in a new-session view', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // An automatic reveal (working-set restore, an inherited-visible editor, a + // layout race) is not explicit, so R1 re-hides it. + harness.editorRevealedExplicitly = false; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [{ part: Parts.EDITOR_PART, hidden: true }]); + }); + + test('[R1] single-pane hides the editor when the managed empty File tab is the active editor', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + editorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + }); + }); + + test('[R1/T2] single-pane does not hide the editor when a real file is the active editor', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/file.ts') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + // A spurious visibility signal must still not re-hide while real content is active. + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + editorHiddenCalls: [], + }); + }); + + test('[R1/T4] single-pane keeps the editor open when a file is opened before it becomes active', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // New-session view starts with the managed empty tab active and the editor hidden. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // Opening a file reveals the editor (onWillOpenEditor) *before* the file + // becomes the active editor, marking the reveal explicit. R1 must not undo it. + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + const beforeActiveEditor = harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden); + + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/package.json') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + beforeActiveEditor, + afterActiveEditor: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + beforeActiveEditor: [], + afterActiveEditor: [], + editorVisible: true, + }); + }); + + test('[R1] single-pane keeps the editor open when switching to the Files tab while the editor is already visible', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // New-session view; the user opens a file, so the editor is revealed and visible. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + harness.editorRevealedExplicitly = true; + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/package.json') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + harness.setPartHiddenCalls = []; + + // The user switches to the managed Files placeholder tab. The editor is + // already visible, so switching tabs must NOT hide the editor area — even + // though the reveal is no longer flagged explicit (the flag is cleared when + // the reveal-sync suppression is re-armed for non-real content). + harness.editorRevealedExplicitly = false; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorHiddenCalls: [], + editorVisible: true, + }); + }); + + test('[R1/T2] single-pane keeps the editor open when details is toggled off in a new-session view', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // Toggling details off reveals the empty editor (so the side pane does not + // vanish). The active editor stays the managed empty tab, but the reveal is + // explicit; R1 must not re-hide the editor it was just asked to show. + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorHiddenCalls: [], + editorVisible: true, + }); + }); + + test('[R1] single-pane hides the editor when entering a new-session view with an inherited-visible editor', async () => { + createSinglePaneController(); + const existing = makeSession(URI.parse('session:existing')); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // Start on a created session with the editor visible and left explicitly revealed. + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.editorRevealedExplicitly = true; + harness.setPartHiddenCalls = []; + + // Entering the new-session view must reset to editor-closed, even though the + // inherited editor was explicitly revealed for the previous session. + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [{ part: Parts.EDITOR_PART, hidden: true }]); + }); + + test('[D3b] standard controller does not hide the editor on new-session side-pane reveal', async () => { + createController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [] + ); + }); + test('[D8] does not reveal the Changes view while multiple sessions are visible', () => { createController(); const a = makeSession(URI.parse('session:a')); @@ -727,6 +1298,53 @@ suite('LayoutController (desktop)', () => { ); }); + test('[single-pane] reveals the editor part for a created session on switch, even with useModal all (Editor-only default)', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createSinglePaneController({ singlePaneLayoutEnabled: true, workspaceFolders }); + const untitled = makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + + // On the new-session view the editor part is hidden. + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + // Navigating to the existing (created) session reveals the editor part to + // show the managed Changes editor (the side pane is no longer left closed). + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'editor part should be revealed for the created session' + ); + }); + + test('[single-pane] does not reveal the editor part for a created session whose editor was explicitly hidden', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createSinglePaneController({ + singlePaneLayoutEnabled: true, + workspaceFolders, + layoutState: [{ sessionResource: 'session:existing', editorPartHidden: true }], + }); + const untitled = makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'the editor part must stay hidden for a session whose editor was explicitly hidden' + ); + }); + // --- [B4] + [D1] Persistence --- test('[B4] persists aux-bar view state to sessions.layoutState key', () => { @@ -1109,26 +1727,232 @@ suite('LayoutController (desktop)', () => { assert.deepStrictEqual(sidebarHiddenCalls(), []); }); + // --- [D7 single-pane] Auto-hide the sessions list only on explicit details open --- + + test('[D7 single-pane] hides the sessions list when details is opened via the toggle action', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), [true]); + }); + + test('[D7 single-pane] restores the sessions list when details is closed via the toggle action', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + controller.toggleDetails(); + harness.setPartHiddenCalls = []; + + // Details now open -> toggling again closes it and restores the auto-hidden list. + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), [false]); + }); + + test('[D7 single-pane] does not touch the sessions list on automatic details opens', () => { + createSinglePaneController(); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + // A programmatic aux-bar visibility change (submit/restore) is not the + // toggle action, so the sessions list stays as-is. + setPartVisible(Parts.AUXILIARYBAR_PART, true); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] does not manage the sessions list while multiple sessions are visible', () => { + const controller = createSinglePaneController(); + harness.visibleSessionsObs.set([ + makeSession(URI.parse('session:1')), + makeSession(URI.parse('session:2')), + ], undefined); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] does not restore a sessions list the user reopened manually', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + controller.toggleDetails(); + + // User manually reopens the sessions list -> control handed back. + setPartVisible(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + // Closing details must now not touch the sessions list. + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] contributes the Toggle Details command to the editor title', () => { + createSinglePaneController(); + + const items = MenuRegistry.getMenuItems(MenuId.EditorTitle) + .filter(isIMenuItem) + .filter(item => item.command.id === TOGGLE_DETAILS_COMMAND_ID); + + assert.strictEqual(items.length, 1, 'exactly one Toggle Details item on the editor title'); + const when = items[0].when?.serialize() ?? ''; + assert.deepStrictEqual({ + icon: ThemeIcon.isThemeIcon(items[0].command.icon) ? items[0].command.icon.id : undefined, + order: items[0].order, + hasToggled: !!items[0].command.toggled, + gatedOnEditorArea: when.includes(MainEditorAreaVisibleContext.key), + }, { + icon: Codicon.listSelection.id, + order: 1000001, + hasToggled: true, + gatedOnEditorArea: true, + }); + }); + + // --- [Scenario 8] Auto-hide the sessions list when opening a file --- + + function openEditor(editor: EditorInput): void { + const event: IEditorWillOpenEvent = { groupId: 1, editor }; + harness.onWillOpenEditor.fire(event); + } + + test('[Scenario 8] hides the sessions list when a real file is opened in a created session with the editor closed', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), [true]); + }); + + test('[Scenario 8] does not hide the sessions list in a new (uncreated) session', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list when the editor area is already open', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, true); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list when a managed empty tab is opened', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(store.add(new EmptyFileEditorInput())); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list on file open while multiple sessions are visible', () => { + createSinglePaneController(); + harness.visibleSessionsObs.set([ + makeSession(URI.parse('session:1')), + makeSession(URI.parse('session:2')), + ], undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + // --- [D10] Auxiliary bar part hidden when it has no active view containers --- - test('[D10] hides the aux-bar part when its view containers are gated off', () => { + test('[D10] hides the aux-bar part for a quick chat when its view containers are gated off', async () => { createController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.activeAuxViewContainerIds = []; harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); harness.setPartHiddenCalls = []; // A quick chat gates off Changes + Files, so the aux bar has no active // view containers — the part must hide instead of showing an empty column. - harness.activeAuxViewContainerIds = []; harness.onDidChangeActiveViewDescriptors.fire(); assert.ok( harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), - 'aux-bar part should hide when it has no active view containers' + 'aux-bar part should hide when a quick chat has no active view containers' ); }); - test('[D10] never reveals an empty aux-bar part', () => { + test('[D10] does not hide the aux bar during early reload when there is no active session yet', () => { createController({ activeAuxViewContainerIds: [] }); + // Startup/reload: aux restored visible (persisted) but no active session yet; + // its containers are transiently inactive. Hiding here is the reload flicker + // (opens then closes) — D10 must leave it alone until a session settles. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + [], + 'aux-bar part must not be hidden by D10 while there is no active session' + ); + }); + + test('[D10] does not hide the aux bar for a workspace session with transiently empty containers', async () => { + createController({ activeAuxViewContainerIds: [] }); + // A real workspace session whose Files/Changes context keys have not settled + // yet (containers transiently inactive). D10 must not collapse its side pane. + harness.activeSessionObs.set(makeSession(URI.parse('session:ws')), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + [], + 'aux-bar part must not be hidden by D10 for a workspace session with transiently empty containers' + ); + }); + + test('[D10] never reveals an empty aux-bar part', async () => { + createController({ activeAuxViewContainerIds: [] }); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); harness.setPartHiddenCalls = []; @@ -1140,10 +1964,12 @@ suite('LayoutController (desktop)', () => { ); }); - test('[D10] re-hides the aux-bar part if a session switch left it visible with no containers', () => { + test('[D10] re-hides the aux-bar part if a switch to a quick chat left it visible with no containers', async () => { createController({ activeAuxViewContainerIds: [] }); // Mirror a switch to a workspace-less quick chat where D3a returned early // (no workspace) and left a previously-visible aux bar showing. + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); harness.setPartHiddenCalls = []; @@ -1151,7 +1977,7 @@ suite('LayoutController (desktop)', () => { assert.ok( harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), - 'aux-bar part should be hidden reactively when it has no active view containers' + 'aux-bar part should be hidden reactively when a quick chat has no active view containers' ); }); @@ -1170,6 +1996,39 @@ suite('LayoutController (desktop)', () => { ); }); + test('[D10] hides the aux-bar part when a quick chat becomes visible with no active containers', async () => { + createController({ activeAuxViewContainerIds: [] }); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + // The part became visible (e.g. a bare detail toggle that shows the column + // before any container is opened) without any container-/descriptor-change + // signal firing. For a quick chat D10 must still reconcile the empty column + // away so the toggle/context key never reads "on" over a blank panel. + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar part should hide when a quick chat becomes visible with no active view containers' + ); + }); + + test('[D10] leaves the aux-bar part visible when it becomes visible with active containers', () => { + createController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART), + [], + 'aux-bar part should stay visible when it becomes visible with active view containers' + ); + }); + // --- [D10] Toggle Side Panel with an empty aux bar --- test('[D10] toggling the side pane with no aux containers reveals the editor, not an empty aux bar', () => { @@ -1207,4 +2066,138 @@ suite('LayoutController (desktop)', () => { 'toggle should reveal nothing when there is no content on either side' ); }); + + // --- Single-pane managed docked tabs (Changes + Files placeholder) --- + + async function settle(): Promise { + for (let i = 0; i < 6; i++) { + await timeout(0); + } + } + + function hasFilesTab(): boolean { + return harness.activeGroupEditors.some(e => e instanceof EmptyFileEditorInput); + } + + function hasChangesTab(): boolean { + return harness.activeGroupEditors.some(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined); + } + + test('[managed tabs] ensures the Changes and Files tabs for a created session under suppression', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + + assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: true, hasFilesTab: true }); + }); + + test('[managed tabs / Scenario 9] shows only the Files tab for a new-session view', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + await settle(); + + assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: false, hasFilesTab: true }); + }); + + test('[managed tabs / Scenario 9] keeps the Files tab when a real editor opens (only removed on explicit close)', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + assert.strictEqual(hasFilesTab(), true); + + // A real file opens into a visible editor area. + harness.activeGroupEditors.push(store.add(new TestStubEditorInput(URI.file('/repo/a.ts')))); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'the Files tab is not auto-removed based on editor visibility'); + }); + + test('[managed tabs / Change 2] does not re-ensure a managed tab after the user closes it', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + assert.ok(fileTab); + + // User closes the Files tab. + const index = harness.activeGroupEditors.indexOf(fileTab); + harness.activeGroupEditors.splice(index, 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.strictEqual(hasFilesTab(), false, 'the dismissed Files tab stays closed'); + }); + + test('[managed tabs / Change 2] re-ensures a dismissed tab after switching sessions', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + const index = harness.activeGroupEditors.indexOf(fileTab); + harness.activeGroupEditors.splice(index, 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.onDidEditorsChange.fire(); + await settle(); + assert.strictEqual(hasFilesTab(), false); + + // Switching sessions clears dismissals and re-populates. + harness.activeSessionObs.set(makeSession(URI.parse('session:2')), undefined); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'a dismissed tab is re-ensured for the new session'); + }); + + test('[managed tabs / reload] closing a stale Changes tab happens under editor-visibility suppression', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + // A stale Changes tab for a previous session is restored into the group. + const staleChangesResource = harness.sessionChangesService.getChangesEditorResource(URI.parse('session:stale')); + harness.activeGroupEditors.push(store.add(new TestStubEditorInput(staleChangesResource))); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + + const staleClosed = harness.closedEditors.some(e => e.resource && isEqual(e.resource, staleChangesResource)); + const allClosesSuppressed = harness.closeSuppressionFlags.every(flag => flag); + assert.deepStrictEqual({ staleClosed, allClosesSuppressed }, { staleClosed: true, allClosesSuppressed: true }); + }); + + test('[managed tabs / Issue 1] re-ensures the Files tab when the side pane is reopened via the aux bar alone', async () => { + createSinglePaneController({ activateAux: true, initialPartVisibility: new Map([[Parts.EDITOR_PART, false], [Parts.AUXILIARYBAR_PART, true]]) }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + assert.ok(fileTab); + + // User closes the Files tab; the whole side pane closes (aux hidden). + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + await settle(); + assert.strictEqual(hasFilesTab(), false); + + // Reopen the side pane by revealing ONLY the aux bar (editor stays hidden). + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'reopening via the aux bar re-ensures the Files tab'); + }); }); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 5a055eef2aa..1b79287324f 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -4,23 +4,28 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { IDimension } from '../../../../../base/browser/dom.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IViewContainerModel, IViewDescriptorService, ViewContainer, ViewContainerLocation } from '../../../../../workbench/common/views.js'; -import { IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IPaneComposite } from '../../../../../workbench/common/panecomposite.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IEditorWillOpenEvent } from '../../../../../workbench/common/editor.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatInteractivity, IChat, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; @@ -29,16 +34,26 @@ import { CHANGES_VIEW_CONTAINER_ID } from '../../../changes/common/changes.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { ILifecycleService } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IChangesViewService } from '../../../changes/common/changesViewService.js'; export function makeChange(filePath: string): ISessionFileChange { return { uri: URI.file(filePath), insertions: 1, deletions: 0 }; } +/** A minimal editor input for tests, identified only by its resource. */ +export class TestStubEditorInput extends EditorInput { + constructor(private readonly _resource: URI) { super(); } + override get typeId(): string { return 'test.stubEditor'; } + override get resource(): URI { return this._resource; } +} + export function makeSession(resource: URI, opts?: { status?: SessionStatus; isCreated?: boolean; changes?: readonly ISessionFileChange[]; workspace?: ISessionWorkspace; + isQuickChat?: boolean; }): IActiveSession { const status = observableValue('status', opts?.status ?? SessionStatus.Completed); const chat: IChat = { @@ -104,6 +119,7 @@ export function makeSession(resource: URI, opts?: { lastClosedChat: undefined, visibleChatTabs: constObservable([chat]), shouldShowChatTabs: constObservable(false), + isQuickChat: constObservable(opts?.isQuickChat ?? false), }; } @@ -123,6 +139,10 @@ export interface ICreateOptions { readonly initialPartVisibility?: ReadonlyMap; /** IDs of aux-bar view containers active at construction (defaults to Changes + Files). Empty ⇒ no active aux containers (e.g. a quick chat). */ readonly activeAuxViewContainerIds?: readonly string[]; + /** When set, resolves the lifecycle `Restored` phase so a single-pane controller's managed-tab / detail-panel behaviour activates. */ + readonly activateAux?: boolean; + /** When true, the layout service reports single-pane layout enabled (drives base single-pane branches). */ + readonly singlePaneLayoutEnabled?: boolean; } /** @@ -139,6 +159,9 @@ export interface ITestLayoutHarness { onDidChangePartVisibility: Emitter; onDidChangeEditorMaximized: Emitter; onDidActiveEditorChange: Emitter; + onWillOpenEditor: Emitter; + onDidCloseEditor: Emitter<{ editor: EditorInput }>; + onDidEditorsChange: Emitter; onDidLayoutMainContainer: Emitter; onDidChangeViewContainerVisibility: Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>; onDidChangeActiveViewDescriptors: Emitter; @@ -150,13 +173,35 @@ export interface ITestLayoutHarness { openedViewContainers: string[]; openedViews: string[]; setPartHiddenCalls: { hidden: boolean; part: Parts }[]; + /** Value returned by the layout service's `isEditorRevealedExplicitly()` mock. */ + editorRevealedExplicitly: boolean; + /** Current suppression depth for `suppressEditorPartAutoVisibility()`. */ + editorPartAutoVisibilitySuppressionDepth: number; + /** Whether the lifecycle `Restored` phase has resolved (activates single-pane managed-tab / detail-panel behaviour). */ + activateAux: boolean; + /** Editors in the main part's active group (drives the single-pane managed-tab logic). */ + activeGroupEditors: EditorInput[]; + /** Records editors closed via `IEditorService.closeEditors`. */ + closedEditors: EditorInput[]; + /** Records the depth-at-close for each `closeEditors` call, to assert layout-driven closes happen while suppressed. */ + closeSuppressionFlags: boolean[]; activePaneCompositeId: string | undefined; pinnedAuxiliaryBarContainerIds: string[]; visibleEditorsList: readonly unknown[]; activeEditorResource: URI | undefined; /** Whether the editor groups have content (drives `hasEditors` in `toggleSidePane`). */ editorGroupsHaveContent: boolean; + /** Records every `applyWorkingSet` call made by the controller. */ + applyWorkingSetCalls: (IEditorWorkingSet | 'empty')[]; + /** + * Optional callback invoked synchronously during `applyWorkingSet`, allowing + * tests to simulate external visibility changes (e.g. the single-pane detail + * panel) while `_isRestoringSessionLayout` is true. + */ + onApplyWorkingSet?: () => void; readonly sessionChangesService: ISessionChangesService; + readonly contextKeyService: MockContextKeyService; + activeEditorInput?: EditorInput; } export function createTestHarness(store: DisposableStore, options: ICreateOptions = {}): ITestLayoutHarness { @@ -178,6 +223,8 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption configService.setUserConfiguration('workbench.editor.useModal', options.useModal ?? 'all'); configService.setUserConfiguration('sessions.layout.autoCollapseSessionsSidebar', options.responsiveSidebar ?? true); instaService.stub(IConfigurationService, configService); + const contextKeyService = store.add(new MockContextKeyService()); + instaService.stub(IContextKeyService, contextKeyService); const harness: ITestLayoutHarness = { instaService, @@ -188,6 +235,9 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption onDidChangePartVisibility: store.add(new Emitter()), onDidChangeEditorMaximized: store.add(new Emitter()), onDidActiveEditorChange: store.add(new Emitter()), + onWillOpenEditor: store.add(new Emitter()), + onDidCloseEditor: store.add(new Emitter<{ editor: EditorInput }>()), + onDidEditorsChange: store.add(new Emitter()), onDidLayoutMainContainer: store.add(new Emitter()), onDidChangeViewContainerVisibility: store.add(new Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>()), onDidChangeActiveViewDescriptors: store.add(new Emitter()), @@ -203,12 +253,32 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption openedViewContainers: [], openedViews: [], setPartHiddenCalls: [], + editorRevealedExplicitly: false, + editorPartAutoVisibilitySuppressionDepth: 0, + activateAux: options.activateAux ?? false, + activeGroupEditors: [], + closedEditors: [], + closeSuppressionFlags: [], activePaneCompositeId: undefined, pinnedAuxiliaryBarContainerIds: [SESSIONS_FILES_CONTAINER_ID, CHANGES_VIEW_CONTAINER_ID], visibleEditorsList: [], activeEditorResource: undefined, + activeEditorInput: undefined, editorGroupsHaveContent: true, - sessionChangesService: new SessionChangesService(), + applyWorkingSetCalls: [], + sessionChangesService: new SessionChangesService(new class extends mock() { }, instaService, configService), + contextKeyService, + }; + + const testActiveGroup: IEditorGroup = new class extends mock() { + override readonly id = 1; + override get editors() { return harness.activeGroupEditors as IEditorGroup['editors']; } + override get count() { return harness.activeGroupEditors.length; } + override get isEmpty() { return harness.activeGroupEditors.length === 0; } + override isPinned() { return true; } + override pinEditor() { } + override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } + override moveEditor() { return true; } }; instaService.stub(ISessionsManagementService, new class extends mock() { @@ -220,7 +290,25 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override readonly visibleSessions = harness.visibleSessionsObs; }); - instaService.stub(ISessionChangesService, harness.sessionChangesService); + instaService.stub(ISessionChangesService, new class extends mock() { + override getChangesEditorResource(sessionResource: URI): URI { return harness.sessionChangesService.getChangesEditorResource(sessionResource); } + override getSessionResource(editorResource: URI): URI | undefined { return harness.sessionChangesService.getSessionResource(editorResource); } + override async openChangesEditor(sessionResource: URI): Promise { + const resource = harness.sessionChangesService.getChangesEditorResource(sessionResource); + if (!harness.activeGroupEditors.some(e => e.resource && isEqual(e.resource, resource))) { + harness.activeGroupEditors.push(store.add(new TestStubEditorInput(resource))); + } + return testActiveGroup; + } + }); + instaService.stub(IChangesViewService, new class extends mock() { + override setChangesetId(): void { } + }); + instaService.stub(ILifecycleService, new class extends mock() { + // Resolves only when a test opts in via `activateAux`, so the single-pane + // managed-tab / detail-panel behaviour is not spun up otherwise. + override when(): Promise { return harness.activateAux ? Promise.resolve() : new Promise(() => { }); } + }); instaService.stub(IWorkbenchLayoutService, new class extends mock() { override isVisible(part: Parts): boolean { @@ -236,9 +324,14 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } } override hasFocus(_part: Parts): boolean { return false; } - suppressEditorPartAutoVisibility(): IDisposable { return Disposable.None; } + suppressEditorPartAutoVisibility(): IDisposable { + harness.editorPartAutoVisibilitySuppressionDepth++; + return toDisposable(() => harness.editorPartAutoVisibilitySuppressionDepth--); + } + isEditorRevealedExplicitly(): boolean { return harness.editorRevealedExplicitly; } override readonly onDidChangePartVisibility = harness.onDidChangePartVisibility.event; isEditorMaximized(): boolean { return harness.editorMaximized; } + get isSinglePaneLayoutEnabled(): boolean { return options.singlePaneLayoutEnabled ?? false; } readonly onDidChangeEditorMaximized = harness.onDidChangeEditorMaximized.event; override readonly onDidLayoutMainContainer = harness.onDidLayoutMainContainer.event; override get mainContainerDimension(): IDimension { return { width: harness.mainContainerWidth, height: 1000 }; } @@ -305,19 +398,53 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption instaService.stub(IEditorService, new class extends mock() { override get visibleEditors() { return harness.visibleEditorsList as IEditorService['visibleEditors']; } override readonly onDidActiveEditorChange = harness.onDidActiveEditorChange.event; + override readonly onWillOpenEditor = harness.onWillOpenEditor.event; + override readonly onDidCloseEditor = harness.onDidCloseEditor.event as unknown as IEditorService['onDidCloseEditor']; + override readonly onDidEditorsChange = harness.onDidEditorsChange.event as unknown as IEditorService['onDidEditorsChange']; override get activeEditor() { + if (harness.activeEditorInput) { + return harness.activeEditorInput as IEditorService['activeEditor']; + } if (!harness.activeEditorResource) { return undefined; } const editor = { resource: harness.activeEditorResource }; return editor as IEditorService['activeEditor']; } + override async openEditor(...args: unknown[]): Promise { + const editor = args[0]; + if (editor instanceof EditorInput && !harness.activeGroupEditors.includes(editor)) { + harness.activeGroupEditors.push(store.add(editor)); + } + return undefined; + } + override async closeEditors(editors: readonly { editor: EditorInput }[]): Promise { + for (const { editor } of editors) { + const index = harness.activeGroupEditors.indexOf(editor); + if (index !== -1) { + harness.closeSuppressionFlags.push(harness.editorPartAutoVisibilitySuppressionDepth > 0); + harness.activeGroupEditors.splice(index, 1); + harness.closedEditors.push(editor); + } + } + } }); instaService.stub(IEditorGroupsService, new class extends mock() { + override get mainPart() { + const groups = this.groups; + return new class extends mock() { + override get groups() { return groups; } + override get activeGroup() { return testActiveGroup; } + }; + } override get groups() { return [{ isEmpty: !harness.editorGroupsHaveContent }] as unknown as IEditorGroupsService['groups']; } override saveWorkingSet(name: string): IEditorWorkingSet { return { id: name, name }; } - override async applyWorkingSet() { return true; } + override async applyWorkingSet(workingSet: IEditorWorkingSet | 'empty') { + harness.applyWorkingSetCalls.push(workingSet); + harness.onApplyWorkingSet?.(); + return true; + } override deleteWorkingSet() { } }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 253f00a2dbd..8e4deea4970 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -26,6 +26,7 @@ Agent host providers implement `IAgentHostSessionsProvider` (defined in sessions Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contribution.ts`: - **Gated on `chat.agentHost.enabled`** (`AgentHostEnabledSettingId`). If the setting is off the contribution returns early and registers nothing. +- The enablement bit is read once through the sessions-layer `AgentHostEnablementService`; the contribution does not subscribe to config changes. - Creates `LocalAgentHostSessionsProvider` via `IInstantiationService` and registers it through `ISessionsProvidersService.registerProvider`. - Registers a per-session-type **working-directory resolver** (`IAgentHostSessionWorkingDirectoryResolver`) for each `agent-host-${sessionType.id}` scheme, refreshed on `onDidChangeSessionTypes`. - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: @@ -46,13 +47,15 @@ The Electron-only `electron-browser/agentHost.contribution.ts` adds desktop-only | `label` | `"Local Agent Host"` | | `icon` | `Codicon.vm` | | `supportsLocalWorkspaces` | `true` | -| `supportsQuickChats` | reflects agent-host enablement — `true` while `chat.agentHost.enabled` is on, else `false` (changes signalled via `onDidChangeCapabilities`) | +| `supportsQuickChats` | snapshots agent-host enablement at construction — `true` when `chat.agentHost.enabled` was on then, else `false` | | `browseActions` | `[]` (local folders are browsed through the shared workspace picker) | | `order` | `-1` when `chat.agentHost.defaultSessionsProvider` is enabled (sorts before all other providers), else `1` | | `sessionTypes` | Dynamically populated from the local agent host's `rootState.agents`; the type label is the agent's unadorned `displayName` (e.g. `"Copilot"`), the type **id** is the agent provider name (e.g. `copilotcli`) so the same agent shares one session type across local and remote hosts | When the default-provider setting flips, the provider re-fires `onDidChangeSessionTypes` so the management service re-collects and re-sorts session types with the new `order`. +These session-type icons are specific to the Agents window provider. In the editor window, `agentSessions.ts` maps local Agent Host Copilot to the Local harness's `Codicon.vm` picker icon, while `agentSessionsViewer.ts` uses the same session-list status dot as the Local harness. + ## IDs and URI Schemes A single agent host session uses several distinct identifiers: @@ -76,6 +79,19 @@ A single agent host session uses several distinct identifiers: - **`NewSession`** is a disposable draft (pre-creation) session. Several can be in flight simultaneously; the management layer tears down superseded drafts via `deleteNewSession`. A draft eagerly creates its backend session once authentication settles, then **graduates** into a committed `AgentHostSessionAdapter` on first send. - The base provider is abstract; concrete providers supply: `connection`, `authenticationPending`, `resourceSchemeForProvider`, `_formatSessionTypeLabel`, `_adapterOptions` (workspace builder), `resolveWorkspace`, and optionally `_diffUriMapper`. +`notify/sessionAdded` is an authoritative upsert rather than create-only. An active provisional session can already have entered `_sessionCache` through `listSessions()` with its original checkout; when materialization publishes the final project and worktree working directory, the provider updates that adapter in place and reports it as changed. + +### Startup session caching (persistence) + +To avoid an empty list on window startup — before the agent host has started, authentication has settled, and the first `listSessions()` round-trip returns — the base provider persists a lightweight snapshot of each session summary to `IStorageService` and re-hydrates it on the next launch. This machinery lives in `BaseAgentHostSessionsProvider` and is **shared by both the local and remote providers**: + +- A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list. +- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. +- Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. +- `_shouldTrackSessionCacheChanges()` is a hook (default `true`) the remote provider overrides to suspend dirty-tracking while its sessions are unpublished (offline), so the on-disk snapshot survives an unreachable host. + +The **only** per-provider difference is the storage key: local uses the fixed `localAgentHost.cachedSessions` (single machine-wide host); remote uses `remoteAgentHost.cachedSessions.${authority}` (one key per connection). + ## How Chat Content Loads & Sends (no `IChatSessionItemController`) A common point of confusion is whether the Agents window needs to register an @@ -120,6 +136,8 @@ sidebar list. 2. Constructs a `NewSession` draft, stores it in `_newSessions`, and fires `onDidChangeSessionConfig`. New-session model/mode selection is seeded by the existing model/agent pickers and sent on the first message. 3. If a connection exists and authentication is **not** pending, eagerly starts the backend session and resolves its dynamic config in parallel. While auth is pending the draft waits; `_resumeNewSessionAfterAuthenticationSettles` (driven by the `authenticationPending` observable going false) starts the backend for all pending drafts. +The eager session-state subscription does not compute Git metadata while the host session lifecycle is `Creating`: its initial working directory is the selected checkout, not the final isolated worktree. Materialization publishes the resolved working directory through `notify/sessionAdded` and starts the first Git-state refresh against that path; the later `session/metaChanged` / `notify/sessionSummaryChanged` updates rebuild the adapter workspace with the resolved branch. + `createQuickChat(sessionTypeId)` is the **workspace-less** counterpart of `createNewSession` (declared via `supportsQuickChats`). It reuses the same `ISessionType` as a normal session — a quick chat is "identical minus exclusions", not a separate stack — but skips `resolveWorkspace` and builds the `NewSession` draft with `workspace === undefined` and `quickChat === true`. Both paths funnel through the shared `_createDraftSession` helper, so tracking, eager backend creation, and config resolution are otherwise identical. The draft's `session.workspace` resolves to `undefined`, and its eager `connection.createSession` call simply **omits `workingDirectory`** — there is no explicit quick-chat input flag on the wire. The agent host **infers workspace-less at create from the absent `workingDirectory`**, tags the session (`_meta.workspaceless` + persisted `copilot.workspaceless` metadata) and runs it in a stable per-session scratch cwd, with a **repo-less system prompt** (`COPILOT_AGENT_HOST_QUICK_CHAT_INSTRUCTIONS` appended) that tells the agent its cwd is a throwaway scratch directory, to stay read-only on real repos, and to delegate code changes to a dedicated session. The workspace-trust gate in `_startNewSessionBackend` is naturally skipped because a workspace-less draft has no folder to trust. Forks are **excluded** from this inference: `isWorkspaceless = !sessionConfig.fork && !sessionConfig.workingDirectory`, so a fork without an explicit `workingDirectory` inherits the source session's context rather than being tagged workspace-less. **Restore (persistence).** Quick chats survive reloads via the normal catalog round-trip: `listSessions()` re-advertises them with the `_meta.workspaceless` tag (carried on the session summary) — but also with the throwaway scratch cwd the host assigned. `AgentHostSessionAdapter` resolves its **session-kind once, at construction**, from `readSessionWorkspaceless(metadata._meta)` (`QuickChatSessionKind` vs `WorkspaceSessionKind`); `_computeWorkspace()` delegates to that kind, so a quick chat returns `undefined` regardless of the scratch working directory, and `ISession.isQuickChat` is a `constObservable` of the kind. Because the kind is fixed at construction and **cannot be flipped by a later `update`/`setMeta`**, the `_meta.workspaceless` tag MUST be present in the metadata passed to **every** adapter-construction path — `_refreshSessions()`/`listSessions` **and** the live `_handleSessionAdded(summary)` notification (which carries `summary._meta`). Dropping `_meta` on either path locks the committed session into `WorkspaceSessionKind` and leaks its scratch dir as a workspace (e.g. the archive-on-delete fallback then pre-fills a new session with that folder). On the host side, `CopilotAgent.listSessions()` re-emits `_meta.workspaceless` from the persisted `copilot.workspaceless` metadata (mirroring `getSessionMetadata`) so restored sessions carry the tag even after the state manager's live summary is gone. `restoreVisibleSessions` itself is workspace-agnostic — it resolves persisted slots by `sessionResource`, so a quick chat re-hydrates like any other session once the provider re-lists it. @@ -194,6 +212,7 @@ Two synthetic filesystem providers expose JSONC settings editors: | Resource scheme | `agent-host-${sessionType.id}` | `remote-${authority}-${agent.provider}` | | Browse actions | none | host-filesystem "Folders" picker | | Diff URIs | `toAgentHostUri(uri, 'local')` | host-scoped mapper | +| Startup session cache | Shared base persistence; fixed key `localAgentHost.cachedSessions` | Shared base persistence; key `remoteAgentHost.cachedSessions.${authority}` + `unpublishCachedSessions()` offline gate | | Extra interface members | — | `connectionStatus`, `remoteAddress`, `connect`/`disconnect` | ## Tests diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts index 55e4792e368..ebba79f9be5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts @@ -10,6 +10,7 @@ import { ISessionFileDiff } from '../../../../../platform/agentHost/common/state import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; import { IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { readChangesetFileMeta } from '../../../../../platform/agentHost/common/meta/agentChangesetFileMeta.js'; /** * Maps the protocol-layer session status bitset to the UI-layer @@ -36,8 +37,8 @@ export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatu * @param mapUri Optional URI mapper applied after parsing. The remote agent * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ -export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { - const normalized = normalizeFileEdit(d); +export function diffToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { + const normalized = normalizeFileEdit(file.edit); if (!normalized) { return undefined; } @@ -55,12 +56,16 @@ export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): I // fetch the snapshot of the file *before* the session's edits. const originalUri = normalized.beforeContentUri ? map(normalized.beforeContentUri) : undefined; + // Extract reviewed status from meta + const meta = readChangesetFileMeta(file); + return { uri, modifiedUri, originalUri, - insertions: d.diff?.added ?? 0, - deletions: d.diff?.removed ?? 0, + insertions: file.edit?.diff?.added ?? 0, + deletions: file.edit?.diff?.removed ?? 0, + reviewed: meta?.reviewed } satisfies IChatSessionFileChange2; } @@ -69,7 +74,7 @@ export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): I * or `undefined` when the underlying diff has no usable URI. */ export function changesetFileToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { - return diffToChange(file.edit, mapUri); + return diffToChange(file, mapUri); } /** @@ -78,8 +83,8 @@ export function changesetFileToChange(file: ChangesetFile, mapUri?: (uri: URI) = * @param mapUri Optional URI mapper applied after parsing. The remote agent * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ -export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { - return diffs.map(d => diffToChange(d, mapUri)).filter(isDefined); +export function diffsToChanges(files: readonly ChangesetFile[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { + return files.map(d => diffToChange(d, mapUri)).filter(isDefined); } /** @@ -93,7 +98,7 @@ export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri * additional information the UI needs. */ export function changesetFilesToChanges(files: readonly ChangesetFile[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { - return diffsToChanges(files.map(f => f.edit), mapUri); + return diffsToChanges(files, mapUri); } /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index f64e40e6baf..2ff379deb2c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; -import { constObservable, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction, waitForState, autorun } from '../../../../../base/common/observable.js'; +import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction, waitForState, autorun } from '../../../../../base/common/observable.js'; import { isEqual, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -58,6 +58,58 @@ import { createSessionFilesObs } from './agentHostSessionFiles.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** Maximum number of cached session summaries persisted per provider. */ +const CACHED_SESSIONS_MAX_PER_HOST = 100; + +/** + * Serialized shape of an {@link IAgentSessionMetadata} suitable for + * persisting via {@link IStorageService}. URIs are stored as strings + * and diffs are intentionally omitted (they are re-populated when the + * connection refreshes sessions). + */ +interface ISerializedSessionMetadata { + readonly session: string; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; + readonly isRead?: boolean; + readonly isArchived?: boolean; + /** @deprecated Legacy name for `isArchived`. */ + readonly isDone?: boolean; + readonly project?: { readonly uri: string; readonly displayName: string }; +} + +function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { + return { + session: meta.session.toString(), + startTime: meta.startTime, + modifiedTime: meta.modifiedTime, + summary: meta.summary, + workingDirectory: meta.workingDirectory?.toString(), + isRead: meta.isRead, + isArchived: meta.isArchived, + project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + }; +} + +function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { + try { + return { + session: URI.parse(raw.session), + startTime: raw.startTime, + modifiedTime: raw.modifiedTime, + summary: raw.summary, + workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, + isRead: raw.isRead, + isArchived: raw.isArchived ?? raw.isDone, + project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + }; + } catch { + return undefined; + } +} + function isSafeSessionConfigKey(property: string): boolean { return !UNSAFE_SESSION_CONFIG_KEYS.has(property); } @@ -1708,6 +1760,30 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); + /** + * Storage key under which {@link _sessionCache} snapshots are persisted, or + * `undefined` while persistence is disabled. Set via + * {@link _enableSessionCachePersistence}, which subclasses call once their + * identity fields are ready. When `undefined`, the cache is in-memory only. + */ + private _sessionCacheStorageKey: string | undefined; + + /** + * Snapshot of the source metadata for each adapter in {@link _sessionCache}, + * keyed by raw session ID. Captured in {@link createAdapter}/{@link updateAdapter} + * and re-used by {@link _persistCache} to serialize sessions without having to + * reconstruct every `IAgentSessionMetadata` field from observables. + */ + private readonly _metaByRawId = new Map(); + + /** + * Set when {@link _sessionCache} has changed since the last persist. The + * actual write happens on the next `onWillSaveState` signal from + * {@link IStorageService} so that bursts of notifications do not repeatedly + * re-serialize the whole cache. + */ + private _cacheDirty = false; + /** * Active progress indicators keyed by `progressToken`. Today's only * producer is the agent host's lazy, first-use SDK download, which is @@ -1788,6 +1864,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ private readonly _sessionStateIdleTimers = this._register(new DisposableMap()); + /** + * Session ids whose views are currently visible in the Agents window. Their + * state subscription is pinned open (no idle release) so host-driven catalog + * changes the user did not initiate — most importantly spawned subagent chats + * ({@link ChatOriginKind.Tool}) — keep flowing into `cached.chats` while the + * session is on screen. Without this, the idle timer (only refreshed by + * client-initiated actions/queries) can release the state listener mid-view, + * so a subagent's `chatAdded` is dropped and its inline "Open Subagent" pill + * cannot resolve until the session is re-subscribed (e.g. switched away and + * back). Driven by {@link _syncVisibleSessionStatePins}. + */ + private readonly _pinnedSessionStates = new Set(); + protected _cacheInitialized = false; private static readonly SESSION_REFRESH_RETRY_MIN_MS = 1_000; @@ -1838,6 +1927,37 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } this._activeDownloads.clear(); })); + + // Keep the state subscription of every on-screen session pinned so + // host-spawned catalog changes (e.g. subagents) reach `cached.chats` + // live, instead of relying on the idle timer that only client actions + // refresh. + this._register(autorun(reader => this._syncVisibleSessionStatePins(reader))); + + // Session-cache persistence. These listeners are inert until a subclass + // opts in via `_enableSessionCachePersistence` (which sets the storage + // key). They are safe to register unconditionally because they only act + // at event time and read the key lazily. + this._register(this._onDidChangeSessions.event(e => { + if (!this._shouldTrackSessionCacheChanges()) { + return; + } + if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { + this._cacheDirty = true; + } + for (const removed of e.removed) { + const rawId = this._rawIdFromChatId(removed.sessionId); + if (rawId) { + this._metaByRawId.delete(rawId); + } + } + })); + this._register(this._storageService.onWillSaveState(() => { + if (this._sessionCacheStorageKey && this._cacheDirty) { + this._persistCache(); + this._cacheDirty = false; + } + })); } // -- Subclass hooks ------------------------------------------------------- @@ -1873,9 +1993,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; + this._metaByRawId.set(AgentSession.id(meta.session), meta); return this._instantiationService.createInstance(AgentHostSessionAdapter, meta, this.id, resourceScheme, provider, options); } + protected updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { + this._metaByRawId.set(AgentSession.id(meta.session), meta); + this._cacheDirty = true; + return adapter.update(meta); + } + /** * Computes the URI resource scheme used to route session URIs to this * provider's content provider for a given agent provider name. Local @@ -3220,6 +3347,45 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ private static readonly SESSION_STATE_SUBSCRIPTION_IDLE_MS = 30_000; + /** + * Pin the state subscription of every currently-visible session (so + * host-driven catalog changes flow into `cached.chats` while it is on + * screen) and resume the idle-release timer for sessions that have left the + * viewport. Driven reactively by {@link ISessionsService.visibleSessions}. + */ + private _syncVisibleSessionStatePins(reader: IReader): void { + const visible = this._sessionsService.visibleSessions.read(reader); + const nowVisible = new Set(); + for (const session of visible) { + if (!session) { + continue; + } + for (const cached of this._sessionCache.values()) { + if (isEqual(cached.resource, session.resource)) { + nowVisible.add(cached.sessionId); + break; + } + } + } + // Pin visible sessions: hold the subscription open, cancelling any pending + // idle release. All operations are idempotent, so re-running per tick also + // recovers a subscription that could not be created earlier (e.g. a remote + // provider that was momentarily disconnected). + for (const sessionId of nowVisible) { + this._pinnedSessionStates.add(sessionId); + this._ensureSessionStateSubscription(sessionId); + this._sessionStateIdleTimers.deleteAndDispose(sessionId); + } + // Unpin sessions that have left the viewport: resume the idle-release + // timer so the agent host can eventually evict their restored state. + for (const sessionId of [...this._pinnedSessionStates]) { + if (!nowVisible.has(sessionId)) { + this._pinnedSessionStates.delete(sessionId); + this._keepSessionStateAlive(sessionId); + } + } + } + /** * Bump the idle-release timer for `sessionId` and lazily create the * underlying subscription if needed. Called from query paths @@ -3232,6 +3398,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!this._sessionStateSubscriptions.has(sessionId)) { return; } + // A visible session's subscription is pinned open; never arm the idle + // release while it is on screen. + if (this._pinnedSessionStates.has(sessionId)) { + this._sessionStateIdleTimers.deleteAndDispose(sessionId); + return; + } this._sessionStateIdleTimers.set( sessionId, disposableTimeout( @@ -3516,6 +3688,87 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Session cache management -------------------------------------------- + /** + * Opt in to persisting {@link _sessionCache} snapshots under `storageKey`. + * Subclasses call this at the **end** of their constructor — once the + * identity fields that {@link createAdapter}/{@link resourceSchemeForProvider}/ + * {@link _adapterOptions} depend on are initialized — because the initial + * hydration builds adapters. This is why the base cannot auto-load in its + * own constructor. Persisted summaries are hydrated into {@link _sessionCache} + * immediately so {@link getSessions} returns them before the first + * `listSessions()` round-trip resolves. + */ + protected _enableSessionCachePersistence(storageKey: string): void { + this._sessionCacheStorageKey = storageKey; + this._loadCachedSessions(); + } + + /** + * Whether {@link _onDidChangeSessions} events should update the persistence + * bookkeeping ({@link _cacheDirty} + {@link _metaByRawId}). Default `true`; + * the remote provider overrides this to suspend tracking while its cached + * sessions are unpublished (offline), so the on-disk snapshot survives. + */ + protected _shouldTrackSessionCacheChanges(): boolean { + return true; + } + + /** Load persisted session summaries into {@link _sessionCache}. */ + private _loadCachedSessions(): void { + if (!this._sessionCacheStorageKey) { + return; + } + const parsed = this._storageService.getObject(this._sessionCacheStorageKey, StorageScope.APPLICATION); + if (!Array.isArray(parsed)) { + return; + } + for (const entry of parsed as readonly ISerializedSessionMetadata[]) { + const meta = deserializeMetadata(entry); + if (!meta) { + continue; + } + const rawId = AgentSession.id(meta.session); + if (this._sessionCache.has(rawId)) { + continue; + } + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + } + } + + /** + * Persist the current {@link _sessionCache} to storage, capping at + * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. + * Mutable fields are read from each adapter's observables and overlaid on + * top of the original metadata snapshot captured in {@link _metaByRawId}. + */ + private _persistCache(): void { + if (!this._sessionCacheStorageKey) { + return; + } + const entries: ISerializedSessionMetadata[] = []; + for (const [rawId, adapter] of this._sessionCache) { + const base = this._metaByRawId.get(rawId); + if (!base) { + continue; + } + entries.push(serializeMetadata({ + ...base, + summary: adapter.title.get() || base.summary, + modifiedTime: adapter.updatedAt.get().getTime(), + isRead: adapter.isRead.get(), + isArchived: adapter.isArchived.get(), + })); + } + if (entries.length === 0) { + this._storageService.remove(this._sessionCacheStorageKey, StorageScope.APPLICATION); + return; + } + entries.sort((a, b) => b.modifiedTime - a.modifiedTime); + const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); + this._storageService.store(this._sessionCacheStorageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); + } + protected _ensureSessionCache(): void { if (this._cacheInitialized) { return; @@ -3561,7 +3814,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (announceExistingAsAdded) { added.push(existing); } - if (existing.update(meta)) { + if (this.updateAdapter(existing, meta)) { changed.push(existing); } } else { @@ -3712,10 +3965,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionAdded(summary: SessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); - if (this._sessionCache.has(rawId)) { - return; - } - const workingDir = typeof summary.workingDirectory === 'string' ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory)) : undefined; @@ -3740,6 +3989,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // and cannot be flipped by a later `update`/`setMeta`. ...(summary._meta !== undefined ? { _meta: summary._meta } : {}), }; + + const existing = this._sessionCache.get(rawId); + if (existing) { + if (this.updateAdapter(existing, meta)) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [existing] }); + } + return; + } + const cached = this.createAdapter(meta); this._sessionCache.set(rawId, cached); this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] }); @@ -3938,8 +4196,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionMetaChanged(session: string, meta: Record | undefined): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); - if (cached) { - cached.setMeta(meta); + if (cached?.setMeta(meta)) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index e17f645e888..864d3c4566a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -5,7 +5,6 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { AgentHostContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; @@ -14,6 +13,7 @@ import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/ import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../services/agentHost/common/agentHostEnablementService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../../nls.js'; @@ -47,14 +47,14 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib static readonly ID = 'sessions.contrib.localAgentHostContribution'; constructor( - @IConfigurationService configurationService: IConfigurationService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IAgentHostSessionWorkingDirectoryResolver workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, ) { super(); - if (!configurationService.getValue(AgentHostEnabledSettingId)) { + if (!this._agentHostEnablementService.enabled) { return; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 0de2c12b603..2f89ea4a922 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; @@ -12,7 +11,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { AgentHostEnabledSettingId, IAgentConnection, IAgentHostService, claudePreferAgentHostSettingId, shouldSurfaceLocalAgentHostProvider, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentConnection, IAgentHostService, claudePreferAgentHostSettingId, shouldSurfaceLocalAgentHostProvider, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -29,6 +28,7 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID, LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../services/agentHost/common/agentHostEnablementService.js'; import { AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js'; @@ -38,6 +38,13 @@ import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.j const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; +/** + * Storage key for the local agent host's cached session summaries. There is a + * single machine-wide local agent host, so a fixed key (no per-authority + * suffix) is used; the base provider persists under `StorageScope.APPLICATION`. + */ +const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY = 'localAgentHost.cachedSessions'; + /** * Local-window sessions provider backed by the in-process * {@link IAgentHostService}. A thin subclass of @@ -54,12 +61,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; - private readonly _onDidChangeCapabilities = this._register(new Emitter()); - readonly onDidChangeCapabilities: Event = this._onDidChangeCapabilities.event; - /** Quick chats are only offered while the agent host is enabled. */ get supportsQuickChats(): boolean { - return this._configurationService.getValue(AgentHostEnabledSettingId); + return this._agentHostEnablementService.enabled; } /** `true` when running in the dedicated Agents window vs. a regular editor window. */ @@ -82,6 +86,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -107,6 +112,12 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this.browseActions = []; + // Hydrate previously-persisted session summaries so the sidebar shows + // local sessions immediately at startup, before the agent host has + // started and the first `listSessions()` round-trip (gated on + // authentication settling below) reconciles them. + this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY); + this._attachConnectionListeners(this._agentHostService, this._store); const rootStateValue = this._agentHostService.rootState.value; @@ -145,9 +156,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // re-run the full sync to add/remove the Claude session type live. const preferAgentHostClaudeSettingId = claudePreferAgentHostSettingId(this._isSessionsWindow); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AgentHostEnabledSettingId)) { - this._onDidChangeCapabilities.fire(); - } if (e.affectsConfiguration(LocalAgentHostDefaultProviderSettingId)) { this._onDidChangeSessionTypes.fire(); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css index 1ed8fdaa958..bd2e94abcf1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css +++ b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css @@ -39,10 +39,13 @@ } /* Progress: the leading conversation icon is swapped for a spinner while the - subagent is still running. The running state is reflected by the - `chat-thinking-active` class the chat widget toggles on the enclosing - `.chat-subagent-part`. Selectors are scoped under the pill widget so they win - over the actionbar's `.action-item .codicon` sizing/visibility rules. */ + subagent is still running. Preferred signal is the pill's own + `chat-subagent-running` class, toggled from the resolved subagent chat's own + `SessionStatus.InProgress` status; the parent `.chat-subagent-part`'s + `chat-thinking-active` is kept as a fallback but stops early (the spawning tool + call completes as soon as the subagent is dispatched). Selectors are scoped + under the pill widget so they win over the actionbar's `.action-item .codicon` + sizing/visibility rules. */ .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon { display: inline-flex; align-items: center; @@ -63,11 +66,13 @@ display: none; } -.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon { +.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget.chat-subagent-running .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon { display: inline-flex; } -.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-open-icon { +.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-open-icon, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget.chat-subagent-running .chat-subagent-pill-icon .chat-subagent-pill-open-icon { display: none; } @@ -93,3 +98,7 @@ .chat-subagent-open-chat-toolbar .chat-subagent-pill-prefix::after { content: ':'; } + +.interactive-session .chat-used-context-label .monaco-button:hover .chat-subagent-open-chat-toolbar .chat-subagent-pill-prefix { + color: var(--vscode-foreground); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts index 8cb9072535e..14003e82d51 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts @@ -22,7 +22,7 @@ import { parseChatUri, parseSubagentSessionUri } from '../../../../../platform/a import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; -import { IChat } from '../../../../services/sessions/common/session.js'; +import { IChat, SessionStatus } from '../../../../services/sessions/common/session.js'; // "Open Subagent" affordance for agent host worker (subagent) chats. // @@ -244,19 +244,32 @@ class OpenSubagentChatActionViewItem extends BaseActionViewItem { : localize('chat.subagent.prefix', "Subagent"); } + /** + * Tracks the resolved subagent chat's title and running state. The pill's + * spinner reflects the subagent chat's **own** {@link SessionStatus.InProgress} + * status rather than the parent `.chat-subagent-part`'s `chat-thinking-active` + * class: the spawning tool call completes as soon as the subagent is + * dispatched, so that class stops early while the worker keeps running. + */ private _updateTitleTracker(): void { const resource = contextChatResource(this._context); if (!resource) { this._titleTracker.clear(); this._setResolvedTitle(undefined); + this._setRunning(false); return; } this._titleTracker.value = autorun(reader => { - const title = findSubagentChat(this.sessionsService, resource, reader)?.chat.title.read(reader); - this._setResolvedTitle(title || undefined); + const chat = findSubagentChat(this.sessionsService, resource, reader)?.chat; + this._setResolvedTitle(chat?.title.read(reader) || undefined); + this._setRunning(chat?.status.read(reader) === SessionStatus.InProgress); }); } + private _setRunning(running: boolean): void { + this.element?.classList.toggle('chat-subagent-running', running); + } + private _setResolvedTitle(title: string | undefined): void { if (title !== this._resolvedTitle) { this._resolvedTitle = title; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index cd5acb7fe60..6fdf31a2ae7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -48,6 +48,7 @@ import { GitHubPullRequestModel } from '../../../../github/browser/models/github import { IPullRequestIconCache, PullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../../github/common/types.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; +import { IAgentHostEnablementService } from '../../../../../services/agentHost/common/agentHostEnablementService.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -316,11 +317,13 @@ function createPolicyRestrictedConfigurationService(): TestConfigurationService function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; gitHubService?: IGitHubService }): LocalAgentHostSessionsProvider { +], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; gitHubService?: IGitHubService }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); - instantiationService.stub(IConfigurationService, options?.configurationService ?? new TestConfigurationService()); + const configurationService = options?.configurationService ?? new TestConfigurationService(); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: configurationService.getValue(AgentHostEnabledSettingId) ?? false }); instantiationService.stub(IWorkspaceTrustManagementService, new class extends mock() { override isWorkspaceTrusted(): boolean { return options?.workspaceTrusted ?? true; } override async getUriTrustInfo(uri: URI) { return { uri, trusted: options?.workspaceTrusted ?? true }; } @@ -354,8 +357,10 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen }()); instantiationService.stub(IPullRequestIconCache, instantiationService.createInstance(PullRequestIconCache)); const activeSessionObs = options?.activeSession ?? constObservable(undefined); + const visibleSessionsObs = options?.visibleSessions ?? constObservable([]); instantiationService.stub(ISessionsService, new class extends mock() { override readonly activeSession: IObservable = activeSessionObs; + override readonly visibleSessions: IObservable = visibleSessionsObs; }()); instantiationService.stub(IAgentHostActiveClientService, new class extends mock() { override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); @@ -393,7 +398,7 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary; workspaceless?: boolean }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -404,8 +409,8 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: provider, title: opts?.title ?? `Session ${rawId}`, status: ProtocolSessionStatus.Idle, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), + createdAt: opts?.createdAt ?? new Date().toISOString(), + modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, workingDirectory: opts?.workingDirectory, changes: opts?.changes, @@ -414,6 +419,18 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: }); } +function fireSessionMetaChanged(agentHost: MockAgentHostService, rawId: string, meta: Record | undefined, provider = 'copilotcli'): void { + agentHost.fireAction({ + channel: AgentSession.uri(provider, rawId).toString(), + action: { + type: ActionType.SessionMetaChanged, + _meta: meta, + }, + serverSeq: 1, + origin: undefined, + }); +} + function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -433,6 +450,25 @@ function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: strin }); } +/** + * Seed `storageService` with persisted session summaries by running a throwaway + * provider over a fresh agent host that lists `sessions`, then flushing so the + * base provider's `onWillSaveState` writes the cache to storage. Used to + * simulate what a previous window left behind for the next launch to hydrate. + */ +async function persistCachedSessions(disposables: DisposableStore, storageService: IStorageService, sessions: IAgentSessionMetadata[]): Promise { + const host = new MockAgentHostService(); + disposables.add(toDisposable(() => host.dispose())); + for (const session of sessions) { + host.addSession(session); + } + createProvider(disposables, host, undefined, { storageService }); + // Let the eager refresh pick up the sessions (marking the cache dirty) then + // flush so the cache is persisted. + await timeout(0); + await storageService.flush(); +} + suite('LocalAgentHostSessionsProvider', () => { const disposables = new DisposableStore(); let agentHost: MockAgentHostService; @@ -752,13 +788,14 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(changes[0].removed.length, 1); }); - test('duplicate session added notification is ignored', () => { + test('identical session added notification is ignored', () => { const provider = createProvider(disposables, agentHost); const changes: ISessionChangeEvent[] = []; disposables.add(provider.onDidChangeSessions(e => changes.push(e))); - fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); - fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); assert.strictEqual(changes.length, 1); }); @@ -775,6 +812,93 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Session listing via refresh ------- + test('session added authoritatively updates a listed session in place', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const originalProject = URI.parse('file:///Users/me/project'); + const originalWorkingDirectory = URI.parse('file:///Users/me/project'); + agentHost.addSession(createSession('worktree-upsert', { + summary: 'Worktree Session', + project: { uri: originalProject, displayName: 'project' }, + workingDirectory: originalWorkingDirectory, + modifiedTime: 1000, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]!; + const originalWorkspace = session.workspace.get()!; + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + const worktreeProject = 'file:///Users/me/project.worktrees/session'; + const worktreeWorkingDirectory = 'file:///Users/me/project.worktrees/session/src'; + fireSessionAdded(agentHost, 'worktree-upsert', { + title: 'Worktree Session', + project: { uri: worktreeProject, displayName: 'project-worktree' }, + workingDirectory: worktreeWorkingDirectory, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + }); + fireSessionSummaryChanged(agentHost, 'worktree-upsert', { + _meta: { git: { branchName: 'agents/worktree-session', baseBranchName: 'main' } }, + }); + + const current = provider.getSessions()[0]!; + const currentWorkspace = current.workspace.get()!; + assert.deepStrictEqual({ + sameAdapter: current === session, + originalWorkingDirectory: originalWorkspace.folders[0].workingDirectory.toString(), + workingDirectory: currentWorkspace.folders[0].workingDirectory.toString(), + branchName: currentWorkspace.folders[0].gitRepository?.branchName, + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + sameAdapter: true, + originalWorkingDirectory: originalWorkingDirectory.toString(), + workingDirectory: worktreeWorkingDirectory, + branchName: 'agents/worktree-session', + changedEvents: [[true], [true]], + }); + })); + + test('session metadata changes notify when observable git fields change', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('git-meta', { + summary: 'Git Session', + project: { uri: URI.parse('file:///Users/me/project'), displayName: 'project' }, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]!; + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + const meta = { + git: { + branchName: 'feature/worktree', + baseBranchName: 'main', + hasGitHubRemote: true, + upstreamBranchName: 'origin/feature/worktree', + incomingChanges: 2, + outgoingChanges: 3, + uncommittedChanges: 4, + }, + }; + + fireSessionMetaChanged(agentHost, 'git-meta', meta); + fireSessionMetaChanged(agentHost, 'git-meta', meta); + + const gitRepository = session.workspace.get()!.folders[0].gitRepository!; + assert.deepStrictEqual({ + branchName: gitRepository.branchName, + uncommittedChanges: gitRepository.uncommittedChanges, + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + branchName: 'feature/worktree', + uncommittedChanges: 4, + changedEvents: [[true]], + }); + })); + test('getSessions populates from listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('list-1', { summary: 'First' })); agentHost.addSession(createSession('list-2', { summary: 'Second' })); @@ -931,6 +1055,69 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + // ---- Startup session cache (persistence) ------- + + test('hydrates persisted sessions on startup before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('cached-1', { summary: 'Cached One' })]); + + // Fresh launch: authentication is still pending so the eager refresh is + // deferred, yet the persisted session must surface immediately. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + assert.deepStrictEqual({ + listSessionsCalls: nextHost.listSessionsCallCount, + cachedTitles: provider.getSessions().map(s => s.title.get()), + }, { + listSessionsCalls: 0, + cachedTitles: ['Cached One'], + }); + })); + + test('reconciles hydrated sessions against the authoritative list, pruning stale entries', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('stale-1', { summary: 'Stale' })]); + + // Fresh launch with an authoritative (empty) list: the hydrated session + // shows immediately, then is pruned once the first refresh succeeds. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + const beforeRefresh = provider.getSessions().map(s => s.title.get()); + await timeout(0); + const afterRefresh = provider.getSessions().map(s => s.title.get()); + + assert.deepStrictEqual({ beforeRefresh, afterRefresh }, { beforeRefresh: ['Stale'], afterRefresh: [] }); + })); + + test('hydrated sessions survive a failed initial listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('resilient-1', { summary: 'Resilient' })]); + + // Fresh launch where the first listSessions() throws (e.g. + // AHP_AUTH_REQUIRED before the token is effective). Without caching the + // list would be empty until the retry heals; the persisted session must + // stay visible throughout. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.failListSessionsCount = 1; + nextHost.addSession(createSession('resilient-1', { summary: 'Resilient' })); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + await timeout(0); + const afterFailedList = provider.getSessions().map(s => s.title.get()); + + // The backoff retry (min 1s) heals; the session remains listed. + await timeout(1_100); + const afterRetry = provider.getSessions().map(s => s.title.get()); + + assert.deepStrictEqual({ afterFailedList, afterRetry }, { afterFailedList: ['Resilient'], afterRetry: ['Resilient'] }); + })); + test('uses project metadata as workspace group source', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const projectUri = URI.file('/home/user/vscode'); const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature'); @@ -1462,19 +1649,16 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Quick chats (workspace-less sessions) ------- - test('declares quick chat support only while the agent host is enabled', () => { + test('declares quick chat support from the initial agent host setting', () => { const configService = new TestConfigurationService(); configService.setUserConfiguration(AgentHostEnabledSettingId, true); const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); assert.strictEqual(provider.supportsQuickChats, true); - // Toggle the agent host off: the capability flips and fires its change event. - let fired = 0; - disposables.add(provider.onDidChangeCapabilities(() => { fired++; })); configService.setUserConfiguration(AgentHostEnabledSettingId, false); fireConfigChange(configService, AgentHostEnabledSettingId); - assert.deepStrictEqual({ supportsQuickChats: provider.supportsQuickChats, fired }, { supportsQuickChats: false, fired: 1 }); + assert.strictEqual(provider.supportsQuickChats, true); }); test('does not declare quick chat support when the agent host is disabled', () => { @@ -3470,6 +3654,58 @@ suite('LocalAgentHostSessionsProvider', () => { const updated = provider.getSessionConfig(session!.sessionId); assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' }); })); + + test('keeps a visible session subscribed so host-spawned subagent chats keep reaching the catalog', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Regression for the "Open Subagent" pill: a passively-watched session + // must stay subscribed so a host-spawned subagent's `chatAdded` keeps + // reaching the catalog past the idle-release window. + agentHost.addSession(createSession('subagent-live', { summary: 'Lead' })); + const visibleSessions = observableValue('visible', []); + const provider = createProvider(disposables, agentHost, undefined, { visibleSessions }); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]; + + // The session's view is on screen: its state subscription must be pinned. + visibleSessions.set([new class extends mock() { + override readonly resource = session.resource; + }()], undefined); + + const sessionUri = AgentSession.uri('copilotcli', 'subagent-live').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentOne = buildSubagentChatUri(sessionUri, 'tc-1'); + const subagentTwo = buildSubagentChatUri(sessionUri, 'tc-2'); + const toolChat = (resource: string, toolCallId: string, title: string): ChatSummary => ({ + resource, title, status: ProtocolSessionStatus.InProgress, modifiedAt: new Date(0).toISOString(), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId }, + }); + const stateWith = (chats: ChatSummary[]): SessionState => ({ + provider: 'copilotcli', title: 'Lead', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, activeClients: [], defaultChat, chats, + }); + const defaultSummary: ChatSummary = { resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }; + + agentHost.setSessionState('subagent-live', 'copilotcli', stateWith([defaultSummary, toolChat(subagentOne, 'tc-1', 'Add name to README')])); + assert.ok(session.chats.get().some(c => c.resource.fragment === 'subagent/tc-1'), 'first subagent should reach the catalog while visible'); + + // Advance well past the idle-release window; a passively-watched session + // used to drop its state listener here. + await timeout(120_000); + + // A second subagent spawns later in the same run; it must still reach the + // catalog because the visible session stayed subscribed. + agentHost.setSessionState('subagent-live', 'copilotcli', stateWith([ + defaultSummary, + toolChat(subagentOne, 'tc-1', 'Add name to README'), + toolChat(subagentTwo, 'tc-2', 'Add description to package.json'), + ])); + + assert.deepStrictEqual( + session.chats.get().map(c => c.resource.fragment).filter(f => f.startsWith('subagent/')).sort(), + ['subagent/tc-1', 'subagent/tc-2'], + 'both subagents should reach the catalog after the idle window while the session stays visible', + ); + })); }); suite.skip('LocalAgentHostSessionsProvider - active-session branch changeset subscription', () => { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index 188e207bdb8..bf78aeaba3e 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -89,6 +89,10 @@ For multi-chat sessions (`capabilities.supportsMultipleChats === true`), `create The provider never opens the chat widget itself; widget opening is owned by the management service. +## Delete Flow + +For Copilot CLI sessions, `_deleteAgentSessions()` delegates to the extension-host command `agents.github.copilot.cli.deleteSessions` and passes both the session resource and current session label. The extension may later hit the Git extension's force-delete confirmation when removing a dirty worktree, so the label must be preserved in that command payload to identify which session owns the worktree. + ## New-Session Picker Contribution Model **File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts` diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts index 91d3d4dad8f..96ca41e214e 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts @@ -35,7 +35,7 @@ class GitRepositoryChangesetResolver implements IChangesetResolver { async resolve(firstCheckpointRef: string, lastCheckpointRef: string | undefined): Promise { const repositoryUri = this._repositoryUriObs.get(); if (!repositoryUri) { - return undefined; + return []; } const repository = await this._gitService.openRepository(repositoryUri); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 452ee358420..db0aad21dcc 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -43,7 +43,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; -import { AgentHostEnabledSettingId, ClaudePreferAgentHostAgentsSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { ClaudePreferAgentHostAgentsSettingId } from '../../../../../platform/agentHost/common/agentService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; @@ -52,6 +52,7 @@ import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IAgentHostEnablementService } from '../../../../services/agentHost/common/agentHostEnablementService.js'; /** Claude Code session type — local agent powered by Claude. */ export const ClaudeCodeSessionType: ISessionType = { @@ -1440,11 +1441,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _multiChatEnabled: boolean; - /** Whether the agent host is enabled via `chat.agentHost.enabled`. */ - private _isAgentHostEnabled(): boolean { - return this.configurationService.getValue(AgentHostEnabledSettingId) ?? false; - } - /** * Claude is offered by this (Copilot Chat sessions) provider only when the * underlying `claudeAgent.enabled` setting is on AND the user has not opted @@ -1460,9 +1456,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (!claudeEnabled) { return false; } - const isAgentHostEnabled = this._isAgentHostEnabled(); const preferAgentHost = this.configurationService.getValue(ClaudePreferAgentHostAgentsSettingId) ?? false; - if (isAgentHostEnabled && preferAgentHost) { + if (this.agentHostEnablementService.enabled && preferAgentHost) { return false; } return true; @@ -1477,9 +1472,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * `chat.agentHost.enabled` is also on. */ private _isCopilotCliAvailable(): boolean { - const isAgentHostEnabled = this._isAgentHostEnabled(); const hideExtensionHost = this.configurationService.getValue(ChatConfiguration.CopilotCliHideExtensionHostAgents) ?? false; - if (isAgentHostEnabled && hideExtensionHost) { + if (this.agentHostEnablementService.enabled && hideExtensionHost) { return false; } return true; @@ -1498,6 +1492,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, @ILabelService private readonly labelService: ILabelService, @@ -1510,8 +1505,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._register(this.configurationService.onDidChangeConfiguration(e => { const affectsSessionTypes = e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING) || e.affectsConfiguration(ClaudePreferAgentHostAgentsSettingId) - || e.affectsConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents) - || e.affectsConfiguration(AgentHostEnabledSettingId); + || e.affectsConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents); if (!affectsSessionTypes) { return; } @@ -1934,10 +1928,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private async _deleteAgentSessions(agentSessions: IAgentSession[]): Promise { - const cliSessionItems: { resource: URI }[] = []; + const cliSessionItems: { resource: URI; label: string }[] = []; for (const agentSession of agentSessions) { if (agentSession.providerType === CopilotCLISessionType.id) { - cliSessionItems.push({ resource: agentSession.resource }); + cliSessionItems.push({ resource: agentSession.resource, label: agentSession.label }); } else { await this.chatService.removeHistoryEntry(agentSession.resource); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index aa184e506fb..98e49789d79 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -39,6 +39,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { extUri } from '../../../../../../base/common/resources.js'; import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../../services/agentHost/common/agentHostEnablementService.js'; // ---- Helpers ---------------------------------------------------------------- @@ -120,12 +121,30 @@ class MockAgentSessionsModel { } } +interface IExecutedCommand { + readonly id: string; + readonly args: readonly unknown[]; +} + +interface ICreateProviderOptions { + readonly multiChatEnabled?: boolean; + readonly claudeEnabled?: boolean; + readonly preferAgentHost?: boolean; + readonly hideCopilotCli?: boolean; + readonly agentHostEnabled?: boolean; + readonly commandExecutions?: IExecutedCommand[]; +} + +function isCommandSessionItem(item: unknown): item is { readonly resource: URI; readonly label?: string } { + return typeof item === 'object' && item !== null && 'resource' in item && URI.isUri(item.resource); +} + // ---- Provider factory ------------------------------------------------------- function createProvider( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean; preferAgentHost?: boolean; hideCopilotCli?: boolean; agentHostEnabled?: boolean }, + opts?: ICreateProviderOptions, ): CopilotChatSessionsProvider { return createProviderWithConfig(disposables, model, opts).provider; } @@ -133,7 +152,7 @@ function createProvider( function createProviderWithConfig( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean; preferAgentHost?: boolean; hideCopilotCli?: boolean; agentHostEnabled?: boolean }, + opts?: ICreateProviderOptions, ): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } { const instantiationService = disposables.add(new TestInstantiationService()); @@ -145,22 +164,24 @@ function createProviderWithConfig( configService.setUserConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents, opts?.hideCopilotCli ?? false); instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: configService.getValue(AgentHostEnabledSettingId) ?? false }); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }), }); instantiationService.stub(ICommandService, { - executeCommand: async (_id: string, ...args: any[]) => { + executeCommand: async (id: string, ...args: unknown[]) => { + opts?.commandExecutions?.push({ id, args }); // Simulate 'agents.github.copilot.cli.deleteSessions' removing sessions const items = args[0]; if (Array.isArray(items)) { for (const item of items) { - if (item?.resource) { + if (isCommandSessionItem(item)) { model.removeSession(item.resource); } } - } else if (items?.resource) { + } else if (isCommandSessionItem(items)) { model.removeSession(items.resource); } return undefined; @@ -203,6 +224,7 @@ function createProviderWithConfig( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: opts?.agentHostEnabled ?? true }); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); return { provider, configService }; @@ -222,7 +244,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem']; configurationService?: TestConfigurationService }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem']; configurationService?: TestConfigurationService; agentHostEnabled?: boolean }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -276,6 +298,7 @@ function createProviderForSendTests( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: opts?.agentHostEnabled ?? true }); return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); } @@ -417,16 +440,13 @@ suite('CopilotChatSessionsProvider', () => { assert.ok(provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); }); - test('onDidChangeSessionTypes fires when chat.agentHost.enabled toggles the hide gate', () => { + test('chat.agentHost.enabled is read once when the provider is created', () => { // With the hide setting on but the agent host initially disabled, the - // Copilot CLI entry is visible. Enabling the agent host must live-apply - // the hide setting and drop the entry without a window reload. + // Copilot CLI entry is visible. Flipping the setting later does not + // re-evaluate the provider. const { provider, configService } = createProviderWithConfig(disposables, model, { hideCopilotCli: true, agentHostEnabled: false }); assert.ok(provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); - let fired = false; - disposables.add(provider.onDidChangeSessionTypes(() => { fired = true; })); - configService.setUserConfiguration(AgentHostEnabledSettingId, true); configService.onDidChangeConfigurationEmitter.fire({ source: ConfigurationTarget.USER, @@ -435,8 +455,7 @@ suite('CopilotChatSessionsProvider', () => { affectsConfiguration: (key: string) => key === AgentHostEnabledSettingId, }); - assert.ok(fired, 'onDidChangeSessionTypes should have fired'); - assert.ok(!provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); + assert.ok(provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); }); test('toggling claude setting refreshes sessions list', () => { @@ -899,6 +918,29 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(remainingSessions[0].title.get(), 'Session 2'); }); + test('deleteSession passes Copilot CLI session label to delete command', async () => { + const resource = URI.from({ scheme: CopilotCLISessionType.id, path: '/session-1' }); + const commandExecutions: IExecutedCommand[] = []; + model.addSession(createMockAgentSession(resource, { providerType: CopilotCLISessionType.id, title: 'Fix Build' })); + + const provider = createProvider(disposables, model, { commandExecutions }); + const sessions = provider.getSessions(); + + await provider.deleteSession(sessions[0].sessionId); + + assert.deepStrictEqual(commandExecutions.map(command => ({ + id: command.id, + items: Array.isArray(command.args[0]) + ? command.args[0].map(item => isCommandSessionItem(item) ? { resource: item.resource.toString(), label: item.label } : undefined) + : undefined, + options: command.args[1], + })), [{ + id: 'agents.github.copilot.cli.deleteSessions', + items: [{ resource: resource.toString(), label: 'Fix Build' }], + options: { skipConfirmation: true }, + }]); + }); + test('deleteChat with single chat delegates to deleteSession', async () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); model.addSession(createMockAgentSession(resource)); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 5c4c08d8609..661e19af02a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -18,8 +18,9 @@ import { ActionType } from '../../../../../platform/agentHost/common/state/sessi import { ROOT_STATE_URI, customizationId, type Customization } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationSyncProvider, type IHarnessDescriptor, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; import { CustomizationType } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -140,6 +141,9 @@ export function createRemoteAgentHarnessDescriptor( itemProvider: AgentCustomizationItemProvider, syncProvider: ICustomizationSyncProvider, ): IHarnessDescriptor { + const allSources = [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin, AICustomizationSources.extension, AICustomizationSources.builtin]; + const filter: IStorageSourceFilter = { sources: allSources }; + return { id: harnessId, label: displayName, @@ -149,6 +153,9 @@ export function createRemoteAgentHarnessDescriptor( AICustomizationManagementSection.McpServers, ], hideGenerateButton: true, + getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter { + return filter; + }, itemProvider, syncProvider, pluginActions: controller.pluginActions, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 9cf5a7d7acf..1e42397af5a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { agentHostUri } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import { type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IProgressService } from '../../../../../platform/progress/common/progress.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -38,64 +38,12 @@ import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '.. import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; /** Storage key prefix for cached session summaries, per remote address. */ const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.'; -/** Maximum number of cached session summaries persisted per host. */ -const CACHED_SESSIONS_MAX_PER_HOST = 100; - -/** - * Serialized shape of an {@link IAgentSessionMetadata} suitable for - * persisting via {@link IStorageService}. URIs are stored as strings - * and diffs are intentionally omitted (they are re-populated when the - * connection refreshes sessions). - */ -interface ISerializedSessionMetadata { - readonly session: string; - readonly startTime: number; - readonly modifiedTime: number; - readonly summary?: string; - readonly workingDirectory?: string; - readonly isRead?: boolean; - readonly isArchived?: boolean; - /** @deprecated Legacy name for `isArchived`. */ - readonly isDone?: boolean; - readonly project?: { readonly uri: string; readonly displayName: string }; -} - -function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { - return { - session: meta.session.toString(), - startTime: meta.startTime, - modifiedTime: meta.modifiedTime, - summary: meta.summary, - workingDirectory: meta.workingDirectory?.toString(), - isRead: meta.isRead, - isArchived: meta.isArchived, - project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, - }; -} - -function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { - try { - return { - session: URI.parse(raw.session), - startTime: raw.startTime, - modifiedTime: raw.modifiedTime, - summary: raw.summary, - workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, - isRead: raw.isRead, - isArchived: raw.isArchived ?? raw.isDone, - project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, - }; - } catch { - return undefined; - } -} - function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } @@ -128,7 +76,7 @@ export interface IRemoteAgentHostSessionsProviderConfig { * - **sessionId** - `{providerId}:{resource}` - the provider-scoped ID used by * {@link ISessionsProvider} methods. * - Protocol operations (e.g. `disposeSession`) use the canonical agent - * session URI (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}. + * session URI (`copilot:///abc123`), reconstructed via `AgentSession.uri`. */ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { @@ -174,20 +122,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _disconnectOnDemand: (() => Promise) | undefined; /** Storage key used for persisting {@link _sessionCache} snapshots. */ private readonly _storageKey: string; - /** - * Set when {@link _sessionCache} has changed since the last persist. - * The actual write happens on the next `onWillSaveState` signal from - * {@link IStorageService} so that bursts of notifications do not - * repeatedly re-serialize the whole cache. - */ - private _cacheDirty = false; - /** - * Snapshot of the source metadata for each adapter in {@link _sessionCache}, - * keyed by raw session ID. Captured in {@link createAdapter} and re-used by - * {@link _persistCache} to serialize sessions without having to reconstruct - * every `IAgentSessionMetadata` field from observables. - */ - private readonly _metaByRawId = new Map(); /** * When `true`, the provider has been marked unreachable and sessions are * hidden from {@link getSessions}, even though {@link _sessionCache} and @@ -243,29 +177,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid listFolders: (query, token) => this._listRemoteFolders(query, token), }]; - this._loadCachedSessions(); - - this._register(this._onDidChangeSessions.event(e => { - if (this._unpublished) { - return; - } - if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { - this._cacheDirty = true; - } - for (const removed of e.removed) { - const rawId = this._rawIdFromChatId(removed.sessionId); - if (rawId) { - this._metaByRawId.delete(rawId); - } - } - })); - - this._register(this._storageService.onWillSaveState(() => { - if (this._cacheDirty) { - this._persistCache(); - this._cacheDirty = false; - } - })); + this._enableSessionCachePersistence(this._storageKey); } // -- BaseAgentHostSessionsProvider hooks --------------------------------- @@ -274,9 +186,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected get authenticationPending(): IObservable { return this._authenticationPending; } - protected override createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { - this._metaByRawId.set(AgentSession.id(meta.session), meta); - return super.createAdapter(meta); + /** + * Suspend cache-change tracking while sessions are unpublished (offline) so + * the on-disk snapshot survives an unreachable host. See + * {@link unpublishCachedSessions}. + */ + protected override _shouldTrackSessionCacheChanges(): boolean { + return !this._unpublished; } protected _adapterOptions() { @@ -464,56 +380,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } } - /** Load persisted session summaries into {@link _sessionCache}. */ - private _loadCachedSessions(): void { - const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION); - if (!Array.isArray(parsed)) { - return; - } - for (const entry of parsed as readonly ISerializedSessionMetadata[]) { - const meta = deserializeMetadata(entry); - if (!meta) { - continue; - } - const rawId = AgentSession.id(meta.session); - if (this._sessionCache.has(rawId)) { - continue; - } - const cached = this.createAdapter(meta); - this._sessionCache.set(rawId, cached); - } - } - - /** - * Persist the current {@link _sessionCache} to storage, capping at - * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. - * Mutable fields are read from each adapter's observables and overlaid on - * top of the original metadata snapshot captured in {@link _metaByRawId}. - */ - private _persistCache(): void { - const entries: ISerializedSessionMetadata[] = []; - for (const [rawId, adapter] of this._sessionCache) { - const base = this._metaByRawId.get(rawId); - if (!base) { - continue; - } - entries.push(serializeMetadata({ - ...base, - summary: adapter.title.get() || base.summary, - modifiedTime: adapter.updatedAt.get().getTime(), - isRead: adapter.isRead.get(), - isArchived: adapter.isArchived.get(), - })); - } - if (entries.length === 0) { - this._storageService.remove(this._storageKey, StorageScope.APPLICATION); - return; - } - entries.sort((a, b) => b.modifiedTime - a.modifiedTime); - const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); - this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); - } - // -- Session-type sync --------------------------------------------------- protected _formatSessionTypeLabel(agentLabel: string): string { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index 2ac5fb94b0a..1c332360633 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -21,7 +21,7 @@ import { PromptsType } from '../../../../../../workbench/contrib/chat/common/pro import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -725,6 +725,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { id: harnessId, label: 'Remote Agent Host (test)', icon: ThemeIcon.fromId(Codicon.remote.id), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), itemProvider: provider, }; const harnessService = disposables.add(new CustomizationHarnessServiceBase([descriptor], harnessId, new MockPromptsService())); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 2f11502bfc5..37ae211eed5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -228,6 +228,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(IPullRequestIconCache, instantiationService.createInstance(PullRequestIconCache)); instantiationService.stub(ISessionsService, new class extends mock() { override readonly activeSession: IObservable = constObservable(undefined); + override readonly visibleSessions: IObservable = constObservable([]); }()); instantiationService.stub(IAgentHostActiveClientService, new class extends mock() { override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); @@ -265,7 +266,7 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s }); } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -276,8 +277,8 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: provider, title: opts?.title ?? `Session ${rawId}`, status: ProtocolSessionStatus.Idle, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), + createdAt: opts?.createdAt ?? new Date().toISOString(), + modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, workingDirectory: opts?.workingDirectory, }, @@ -483,8 +484,9 @@ suite('RemoteAgentHostSessionsProvider', () => { const changes: ISessionChangeEvent[] = []; disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); - fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); - fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(connection, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); + fireSessionAdded(connection, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); assert.strictEqual(changes.length, 1); }); @@ -880,6 +882,51 @@ suite('RemoteAgentHostSessionsProvider', () => { ); })); + test('authoritative session update persists materialized workspace metadata', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const provider = createProvider(disposables, connection, { storageService }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(connection, 'persist-upsert', { + title: 'Worktree Session', + project: { uri: 'file:///Users/me/project', displayName: 'project' }, + workingDirectory: 'file:///Users/me/project', + createdAt: timestamp, + modifiedAt: timestamp, + }); + fireSessionAdded(connection, 'persist-upsert', { + title: 'Worktree Session', + project: { uri: 'file:///Users/me/project', displayName: 'project' }, + workingDirectory: 'file:///Users/me/project.worktrees/session', + createdAt: timestamp, + modifiedAt: new Date(1000).toISOString(), + }); + const currentWorkspace = provider.getSessions()[0].workspace.get()!; + + await storageService.flush(); + + const restoredProvider = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true }); + const restoredWorkspace = restoredProvider.getSessions()[0].workspace.get()!; + assert.deepStrictEqual({ + current: { + root: currentWorkspace.folders[0].root.path, + workingDirectory: currentWorkspace.folders[0].workingDirectory.path, + }, + restored: { + root: restoredWorkspace.folders[0].root.path, + workingDirectory: restoredWorkspace.folders[0].workingDirectory.path, + }, + }, { + current: { + root: '/Users/me/project', + workingDirectory: '/Users/me/project.worktrees/session', + }, + restored: { + root: '/Users/me/project', + workingDirectory: '/Users/me/project.worktrees/session', + }, + }); + })); + test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('restore-me', { summary: 'Restore Me' })); const provider = createProvider(disposables, connection); diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts new file mode 100644 index 00000000000..d43d2856e51 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/blockedSessionsList.css'; +import { $, append } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { URI } from '../../../../base/common/uri.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { IApprovedSession, SessionsFlatList } from './views/sessionsList.js'; +import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; + +/** Fixed width of the blocked-sessions list, in pixels. */ +const BLOCKED_LIST_WIDTH = 360; +/** Maximum number of rows shown before the list scrolls. */ +const BLOCKED_LIST_MAX_VISIBLE_ROWS = 8; +/** Maximum number of terminal-command lines shown in a session's approval prompt. */ +const BLOCKED_LIST_APPROVAL_ROW_MAX_LINES = 5; + +export interface IBlockedSessionsListOptions { + /** Invoked when a session row is activated (clicked or opened via keyboard). */ + readonly onSessionOpen: (resource: URI, preserveFocus: boolean, sideBySide: boolean) => void; + /** Width of the list, in pixels. Defaults to a fixed width when omitted. */ + readonly width?: number; + /** Approval model forwarded to the underlying list (see {@link ISessionsFlatListOptions.approvalModel}). */ + readonly approvalModel?: AgentSessionApprovalModel; +} + +/** + * A self-sizing, flat list of blocked sessions. + * + * Wraps {@link SessionsFlatList} with the fixed width and bounded height used by + * the blocked-sessions dropdown in the sessions titlebar. Hosts append it to a + * container, push sessions via {@link setSessions}, and listen to + * {@link onDidChangeContentHeight} to reposition the surrounding surface (e.g. a + * context view) as rows resolve their heights. + */ +export class BlockedSessionsList extends Disposable { + + private readonly _onDidChangeContentHeight = this._register(new Emitter()); + /** Fires when the list resizes and the host should re-layout its container. */ + readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; + + private readonly _onDidApproveSession = this._register(new Emitter()); + /** Fires when a session's pending action is approved from its "Allow" button. */ + readonly onDidApproveSession: Event = this._onDidApproveSession.event; + + private readonly _rowsContainer: HTMLElement; + private readonly _list: SessionsFlatList; + private _width: number; + + constructor( + container: HTMLElement, + options: IBlockedSessionsListOptions, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._width = options.width ?? BLOCKED_LIST_WIDTH; + + const element = append(container, $('.agent-sessions-blocked-list')); + + // Header row: a title on the left and a toolbar of contributed actions on the + // right (e.g. the action that opens the full sessions picker). + const header = append(element, $('.agent-sessions-blocked-list-header')); + const title = append(header, $('.agent-sessions-blocked-list-title')); + title.textContent = localize('sessionsRequiringInput', "Sessions requiring input"); + const headerActions = append(header, $('.agent-sessions-blocked-list-header-actions')); + this._register(instantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.BlockedSessionsHeader, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'blockedSessionsList.header', + })); + + this._rowsContainer = append(element, $('.agent-sessions-blocked-list-rows')); + + this._list = this._register(instantiationService.createInstance(SessionsFlatList, this._rowsContainer, { + showSessionHover: true, + onSessionOpen: options.onSessionOpen, + approvalModel: options.approvalModel, + approvalRowMaxLines: BLOCKED_LIST_APPROVAL_ROW_MAX_LINES, + })); + + this._register(this._list.onDidChangeContentHeight(() => { + this._layout(); + this._onDidChangeContentHeight.fire(); + })); + + this._register(this._list.onDidApproveSession(approved => this._onDidApproveSession.fire(approved))); + } + + /** Replace the sessions shown in the list and resize to fit their content. */ + setSessions(sessions: readonly ISession[]): void { + this._list.setSessions(sessions); + this._layout(); + } + + /** Move keyboard focus into the list. */ + focus(): void { + this._list.focus(); + } + + /** + * Update the list width (e.g. when the anchoring widget reflows as the window + * resizes) and re-layout to the new width. + */ + setWidth(width: number): void { + if (this._width === width) { + return; + } + this._width = width; + this._layout(); + } + + private _layout(): void { + const maxHeight = BLOCKED_LIST_MAX_VISIBLE_ROWS * this._list.getRowHeight(); + const height = Math.min(this._list.getContentHeight(), maxHeight); + this._rowsContainer.style.width = `${this._width}px`; + this._rowsContainer.style.height = `${height}px`; + this._list.layout(height, this._width); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css new file mode 100644 index 00000000000..4df537c4d95 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Blocked-sessions list, shown below the command center box via the context view. */ +.agent-sessions-blocked-list { + display: flex; + flex-direction: column; + /* A small gap so the dropdown reads as separate from the command center box. */ + margin-top: var(--vscode-spacing-size60); + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +/* Header row: title on the left, a toolbar of contributed actions on the right. */ +.agent-sessions-blocked-list .agent-sessions-blocked-list-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size40) var(--vscode-spacing-size40) var(--vscode-spacing-size100); + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-header-actions { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-rows { + overflow: hidden; +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-rows .sessions-list-control .monaco-list-row:has(.session-item) { + border-radius: 0; + margin: 0; + width: 100%; +} + +/* Requires-input state of the sessions titlebar widget: shown when the primary + side bar is hidden and at least one session is blocked. The command center box + (owned by SessionsTitleBarWidget) adopts an orange accent. */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input { + color: var(--vscode-charts-orange); + border-color: var(--vscode-charts-orange); + background-color: color-mix(in srgb, var(--vscode-charts-orange) 14%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input:hover { + color: var(--vscode-charts-orange); + border-color: var(--vscode-charts-orange); + background-color: color-mix(in srgb, var(--vscode-charts-orange) 22%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input:focus { + outline-color: var(--vscode-charts-orange); +} + +/* The requires-input pill fills the box and centers its label (no dimming). */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input .agent-sessions-titlebar-pill { + opacity: 1; + justify-content: center; +} + +.command-center .agent-sessions-titlebar-requires-input-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); +} + +/* Attention blink: pulses the orange fill twice when a new session becomes blocked. */ +@keyframes agent-sessions-titlebar-attention-blink { + 0%, + 100% { + background-color: color-mix(in srgb, var(--vscode-charts-orange) 14%, transparent); + } + + 50% { + background-color: color-mix(in srgb, var(--vscode-charts-orange) 45%, transparent); + } +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input.agent-sessions-titlebar-blink { + animation: agent-sessions-titlebar-attention-blink 450ms ease-in-out 2; +} + +@media (prefers-reduced-motion: reduce) { + .command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input.agent-sessions-titlebar-blink { + animation: none; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 9c5d90629b6..34763936453 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -140,3 +140,32 @@ transition: none; } } + +/* Approved state: a transient green confirmation shown after the user approves + one or more sessions' pending actions from the sessions list. It stays + clickable (activating whatever the underlying state would do). */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved { + color: var(--vscode-charts-green); + border-color: var(--vscode-charts-green); + background-color: color-mix(in srgb, var(--vscode-charts-green) 14%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved:hover { + color: var(--vscode-charts-green); + border-color: var(--vscode-charts-green); + background-color: color-mix(in srgb, var(--vscode-charts-green) 22%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved .agent-sessions-titlebar-pill { + opacity: 1; + justify-content: center; +} + +.command-center .agent-sessions-titlebar-approved-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts new file mode 100644 index 00000000000..ef00388d01e --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; + +/** + * Tracks short-lived, user-facing feedback for actions taken on individual + * session items in the sessions list (e.g. approving a tool invocation). + * + * When a session's pending action is approved, {@link approvedCount} briefly + * reflects how many sessions were approved within a rolling window; each new + * approval increments the count and restarts the window. The sessions titlebar + * widget owns an instance and surfaces this as a transient "Approved N sessions" + * message. + */ +export class SessionActionFeedback extends Disposable { + + /** How long the "Approved N sessions" message stays visible, in milliseconds. */ + private static readonly WINDOW_MS = 3000; + + private readonly _approvedCount = observableValue('approvedCount', 0); + /** Number of sessions approved within the current window; `0` when idle. */ + readonly approvedCount: IObservable = this._approvedCount; + + private readonly _resetScheduler = this._register(new RunOnceScheduler( + () => this._approvedCount.set(0, undefined), + SessionActionFeedback.WINDOW_MS, + )); + + /** Report that a session's pending action was approved. Restarts the window. */ + notifyApproved(): void { + this._approvedCount.set(this._approvedCount.get() + 1, undefined); + this._resetScheduler.schedule(); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 8bec0b3898c..f43135bdf5f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -18,6 +18,8 @@ import { SessionConversationsMenuContribution, SessionNewChatActionViewItemContr import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './views/sessionsViewActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -54,6 +56,19 @@ const sessionsViewPaneDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING]: { + type: 'boolean', + tags: ['preview'], + description: localize('sessions.list.showEmptyDefaultGroups', "Controls whether the default groups (Pinned, Chats) are shown in the sessions list even when they are empty."), + default: true, + experiment: { mode: 'auto' } + }, + }, +}); + registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 4f303261488..3113dafe917 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -27,7 +27,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -923,14 +923,16 @@ export class SessionNewChatActionViewItemContribution extends Disposable impleme // shown: when the strip is hidden it lives in the session header toolbar; once the // session has more than one open chat (the tab strip is shown) it moves to the // chat tab bar action menu at the end of the strip instead (see -// Menus.SessionChatTabBar below). +// Menus.SessionChatTabBar below). It also surfaces when the active chat has +// subagents (a separate group at the bottom lists them), even if that is the only +// committed chat. MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { submenu: Menus.SessionConversations, title: localize2('chatCompositeBar.conversations', "Conversations"), icon: Codicon.commentDiscussion, group: 'navigation', order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext.negate()), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext.negate()), }); // Mirror of the header Conversations submenu, rendered at the end of the chat tab @@ -943,7 +945,7 @@ MenuRegistry.appendMenuItem(Menus.SessionChatTabBar, { icon: Codicon.commentDiscussion, group: 'navigation', order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext), }); /** @@ -985,22 +987,15 @@ export class SessionConversationsMenuContribution extends Disposable implements const allChats = session.chats.read(reader); const mainResource = session.mainChat.read(reader).resource; - const openChats = session.openChats.read(reader); + const visibleChatTabs = session.visibleChatTabs.read(reader); + const activeChatResource = session.activeChat.read(reader).resource; - allChats.forEach((chat, index) => { - // Skip untitled (in-composer) draft chats: they are transient "New - // Chat" drafts that can't be meaningfully closed/reopened, and listing - // them here (titled "New Chat") just duplicates the New Chat action. - if (chat.status.read(reader) === SessionStatus.Untitled) { - return; - } - // Subagent (tool-origin) chats are surfaced via the Subagents dropdown, - // not the Conversations submenu. - if (chat.origin?.kind === ChatOriginKind.Tool) { - return; - } + const registerToggle = (chat: IChat, group: string, order: number) => { const chatResource = chat.resource; - const isOpen = openChats.some(c => extUri.isEqual(c.resource, chatResource)); + // Whether the chat is currently shown as a tab. For regular chats this + // mirrors `openChats`; for subagents it reflects the shown-subagent set, + // which is what open/close toggles. + const isShown = visibleChatTabs.some(c => extUri.isEqual(c.resource, chatResource)); const isMain = extUri.isEqual(chatResource, mainResource); const title = chat.title.read(reader) || localize('untitledChat', "Untitled Chat"); // Action IDs are global, so scope them to the session and a hash of the @@ -1011,9 +1006,9 @@ export class SessionConversationsMenuContribution extends Disposable implements super({ id: `sessions.toggleChat.${session.sessionId}.${hash(chatResource.toString())}`, title, - toggled: isOpen ? ContextKeyExpr.true() : undefined, + toggled: isShown ? ContextKeyExpr.true() : undefined, precondition: isMain ? ContextKeyExpr.false() : undefined, - menu: { id: Menus.SessionConversations, group: '1_chats', order: index, when: scopedToSession }, + menu: { id: Menus.SessionConversations, group, order, when: scopedToSession }, }); } override async run(_accessor: ServicesAccessor, forwardedSession?: IActiveSession): Promise { @@ -1022,16 +1017,41 @@ export class SessionConversationsMenuContribution extends Disposable implements if (!targetChat) { return; } - if (target.openChats.get().some(c => extUri.isEqual(c.resource, chatResource))) { + if (target.visibleChatTabs.get().some(c => extUri.isEqual(c.resource, chatResource))) { await that._sessionsService.closeChat(target, targetChat); } else { - // Opening a closed chat also un-hides it in the tab strip. + // Opening a closed chat (or hidden subagent) un-hides it in the tab strip. await that._sessionsService.openChat(target, targetChat.resource); } } })); + }; + + allChats.forEach((chat, index) => { + // Skip untitled (in-composer) draft chats: they are transient "New + // Chat" drafts that can't be meaningfully closed/reopened, and listing + // them here (titled "New Chat") just duplicates the New Chat action. + if (chat.status.read(reader) === SessionStatus.Untitled) { + return; + } + // Subagent (tool-origin) chats are surfaced in their own group below, + // scoped to the currently-active chat. + if (chat.origin?.kind === ChatOriginKind.Tool) { + return; + } + registerToggle(chat, '1_chats', index); }); + // Subagents of the currently-active chat, shown as a separate group at the + // bottom (a separator divides them from the session's chats). This group + // changes as the active chat changes. + allChats + .filter(chat => + chat.origin?.kind === ChatOriginKind.Tool && + !!chat.origin.parentChat && + extUri.isEqual(chat.origin.parentChat, activeChatResource)) + .forEach((chat, index) => registerToggle(chat, '2_subagents', index)); + return store; } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 1f2fff0efc8..87a15cba363 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,26 +4,64 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, reset } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { localize } from '../../../../nls.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AnchorAlignment, AnchorPosition } from '../../../../base/common/layout.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IContextViewService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; +import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../blockedSessions/browser/blockedSessions.js'; +import { BlockedSessionsList } from './blockedSessionsList.js'; +import { SessionActionFeedback } from './sessionActionFeedback.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprovalId } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { getFirstApprovalAcrossChats, IApprovedSession } from './views/sessionsList.js'; +import { openSessionToTheSide } from './views/sessionsView.js'; + +/** + * Internal command behind the blocked-sessions dropdown header's "Show All + * Sessions" action: it dismisses the dropdown (a transient context view) before + * opening the full sessions picker so the popup doesn't linger behind it. + */ +const SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID = 'sessions.blockedSessions.showAllSessions'; + +/** + * The specific reason a homogeneous set of blocked sessions needs attention, + * used to render a more helpful requires-input message. `undefined` (a mix of + * reasons, or an indeterminate one) falls back to the generic message. + */ +const enum RequiresInputKind { + /** All sessions are waiting to run a terminal command. */ + TerminalApproval, + /** All sessions are asking the user a question. */ + Question, + /** All sessions have failing CI checks. */ + FailingCI, + /** All sessions have unresolved pull request comments. */ + UnresolvedComments, +} + /** * Sessions Title Bar Widget - renders the active chat session * in the command center of the agent sessions workbench. @@ -32,32 +70,182 @@ import { getUntitledSessionTitle } from '../../../services/sessions/common/sessi * - Kind icon at the beginning (provider type icon) * - Repository folder name and active branch/worktree name when available * + * When the primary side bar is hidden and at least one session is blocked + * (needs input, has failing CI checks, or has unresolved pull request comments), + * the widget instead adopts an orange "N sessions require input" state and, on + * click, reveals those sessions as a flat list in a dropdown anchored below the + * command center box. A short blink animation plays whenever a new session + * becomes blocked. In every other case it behaves as the active-session pill and + * opens the sessions picker on click. + * * Session actions (changes, terminal, etc.) are rendered via the * SessionTitleActions menu toolbar next to this widget. - * - * On click, opens the sessions picker. */ export class SessionsTitleBarWidget extends BaseActionViewItem { private _container: HTMLElement | undefined; private readonly _dynamicDisposables = this._register(new DisposableStore()); + /** Owns the blink animation's `animationend` listener, kept across re-renders. */ + private readonly _blinkListener = this._register(new MutableDisposable()); + /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; /** Guard to prevent re-entrant rendering */ private _isRendering = false; + /** + * Last observed blocked-session count, tracked on every render regardless of + * side-bar visibility. Because the count is captured even while the + * requires-input state is hidden (side bar visible), toggling the side bar to + * reveal the indicator leaves the count unchanged and never blinks. + */ + private _lastBlockedCount = 0; + + /** Reactive primary-side-bar visibility. */ + private readonly _sidebarVisible: IObservable; + + /** + * Blocked sessions that are NOT currently visible on screen. A session the + * user can already see doesn't need the titlebar indicator or a dropdown row, + * so it is excluded from both the "N sessions require input" count and the list. + */ + private readonly _blockedSessions: IObservable; + + /** + * The homogeneous reason the blocked sessions need attention (all terminal + * approvals, all failing CI, etc.), or `undefined` when they are a mix — which + * drives whether a specific or the generic requires-input message is shown. + */ + private readonly _requiresInputKind: IObservable; + + /** Tracks pending tool approvals per chat; distinguishes terminal vs question. */ + private readonly _approvalModel: AgentSessionApprovalModel; + + /** Computes the set of blocked sessions (needs input / failing CI / comments). */ + private readonly _blockedSessionsModel: BlockedSessions; + + /** + * Sessions whose current pending approval the user just allowed, keyed by + * `sessionId` → the approved approval's identity. Such a session is optimistically + * hidden from the blocked set until its approval resolves into a NEW distinct + * block (or it stops being blocked), so an approved row disappears immediately + * instead of lingering until the provider updates the session status. + */ + private readonly _dismissedApprovals = observableValue>('dismissedApprovals', new Map()); + + /** The currently open blocked-sessions dropdown, if any. */ + private _openContextView: IOpenContextView | undefined; + /** The blocked-sessions list rendered inside the open dropdown, if any. */ + private _blockedList: BlockedSessionsList | undefined; + + /** Drives the transient "Approved N sessions" confirmation. Owned by the widget. */ + private readonly _sessionActionFeedback: SessionActionFeedback; + constructor( action: SubmenuItemAction, options: IBaseActionViewItemOptions | undefined, + sessionActionFeedback: SessionActionFeedback | undefined, + approvalModel: AgentSessionApprovalModel | undefined, + blockedSessions: BlockedSessions | undefined, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly commandService: ICommandService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(undefined, action, options); + // The widget owns the approval-feedback state; the optional parameter is a + // test seam so fixtures can supply a preset instance. + this._sessionActionFeedback = sessionActionFeedback ?? this._register(new SessionActionFeedback()); + + // Likewise the widget owns an approval model (shared with the dropdown list so + // both agree on each session's pending action); the optional parameter is a + // test seam. + this._approvalModel = approvalModel ?? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)); + + // The widget owns the blocked-sessions model; the optional parameter is a + // test seam so fixtures can supply a preset instance. + this._blockedSessionsModel = blockedSessions ?? this._register(this.instantiationService.createInstance(BlockedSessions)); + + this._sidebarVisible = observableFromEvent( + this, + this.layoutService.onDidChangePartVisibility, + () => this.layoutService.isVisible(Parts.SIDEBAR_PART), + ); + + // A session that is currently visible on screen is not treated as blocked: + // exclude visible sessions from the requires-input indicator and the dropdown. + this._blockedSessions = derived(this, reader => { + const visibleSessionIds = new Set(); + for (const session of this.sessionsService.visibleSessions.read(reader)) { + if (session) { + visibleSessionIds.add(session.sessionId); + } + } + const dismissed = this._dismissedApprovals.read(reader); + return this._blockedSessionsModel.blockedSessionsWithReasons.read(reader) + .filter(blocked => !visibleSessionIds.has(blocked.session.sessionId) && !this._isApprovalDismissed(blocked, dismissed, reader)); + }); + + // The homogeneous reason across all blocked sessions (or `undefined` for a + // mix), refining `NeedsInput` into terminal-approval vs question via the + // approval model. Drives the specific requires-input message. + this._requiresInputKind = derived(this, reader => { + const blocked = this._blockedSessions.read(reader); + if (blocked.length === 0) { + return undefined; + } + let common: RequiresInputKind | undefined; + let hasCommon = false; + for (const entry of blocked) { + const kind = this._kindOf(entry, reader); + if (kind === undefined) { + return undefined; + } + if (!hasCommon) { + common = kind; + hasCommon = true; + } else if (common !== kind) { + return undefined; + } + } + return common; + }); + + // Drop optimistic dismissals once the session is no longer blocked or its + // pending approval has been superseded by a new, distinct one — so a stale + // dismissal can't keep hiding a genuinely new block. + this._register(autorun(reader => { + const dismissed = this._dismissedApprovals.read(reader); + if (dismissed.size === 0) { + return; + } + const blockedById = new Map(this._blockedSessionsModel.blockedSessionsWithReasons.read(reader).map(blocked => [blocked.session.sessionId, blocked] as const)); + let next: Map | undefined; + for (const [sessionId, approvalId] of dismissed) { + const blocked = blockedById.get(sessionId); + let stale: boolean; + if (!blocked || blocked.reason !== BlockedSessionReason.NeedsInput) { + stale = true; + } else { + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + stale = approval !== undefined && agentSessionApprovalId(approval) !== approvalId; + } + if (stale) { + next ??= new Map(dismissed); + next.delete(sessionId); + } + } + if (next) { + this._dismissedApprovals.set(next, undefined); + } + })); + // Re-render when the active session's title, workspace, or quick-chat kind changes this._register(autorun(reader => { const sessionData = this.sessionsService.activeSession.read(reader); @@ -70,6 +258,20 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._render(); })); + // Re-render when the set of blocked sessions or the side bar visibility changes; + // both feed the "N sessions require input" state. Keep an open dropdown in sync. + this._register(autorun(reader => { + const blocked = this._blockedSessions.read(reader); + this._sidebarVisible.read(reader); + this._sessionActionFeedback.approvedCount.read(reader); + this._requiresInputKind.read(reader); + if (this._openContextView && this._blockedList) { + this._blockedList.setSessions(blocked.map(entry => entry.session)); + this.contextViewService.layout(); + } + this._render(); + })); + // Re-render when sessions data changes (e.g., changes info updated) this._register(this.sessionsManagementService.onDidChangeSessions(() => { this._lastRenderState = undefined; @@ -81,6 +283,9 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._lastRenderState = undefined; this._render(); })); + + // Ensure any open dropdown is closed when the widget is disposed. + this._register(toDisposable(() => this._openContextView?.close())); } override render(container: HTMLElement): void { @@ -114,12 +319,37 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._isRendering = true; try { - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); + const approvedCount = this._sessionActionFeedback.approvedCount.get(); + const blockedCount = this._blockedSessions.get().length; + const requiresInput = blockedCount > 0 && !this._sidebarVisible.get(); - // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + // The transient "Approved N sessions" confirmation takes precedence over the + // requires-input state while it is showing. + const showApproved = approvedCount > 0; + const showRequiresInput = requiresInput && !showApproved; + + // The attention blink fires only when a *new* blocked session pushes the + // count up while the requires-input state is shown — including the very first + // one. The count is tracked even while the state is hidden (side bar visible), + // so merely revealing the indicator by hiding the side bar (count unchanged) + // never blinks. + const previousBlockedCount = this._lastBlockedCount; + this._lastBlockedCount = blockedCount; + const shouldBlink = showRequiresInput && blockedCount > previousBlockedCount; + + const requiresInputKind = this._requiresInputKind.get(); + + let renderState: string; + if (showApproved) { + renderState = `approved|${approvedCount}`; + } else if (showRequiresInput) { + renderState = `blocked|${blockedCount}|${requiresInputKind ?? 'mixed'}`; + } else { + const icon = this._getActiveSessionIcon(); + const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); + const workspaceLabel = this._getRepositoryLabel(); + renderState = `normal|${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + } // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -127,6 +357,16 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } this._lastRenderState = renderState; + // Close the open blocked-sessions dropdown only when there are no blocked + // sessions left to show (or the requires-input UI no longer applies, e.g. + // the side bar became visible). Note this keys off `requiresInput`, not + // `showRequiresInput`: approving a session shows the transient green state + // (suppressing `showRequiresInput`) but the dropdown must stay open while + // other sessions remain blocked — it just drops the approved row. + if (!requiresInput && this._openContextView) { + this._openContextView.close(); + } + // Clear existing content reset(this._container); this._dynamicDisposables.clear(); @@ -134,66 +374,383 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Set up container as the button directly this._container.removeAttribute('aria-hidden'); this._container.setAttribute('role', 'button'); - this._container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); this._container.tabIndex = 0; - - // Session pill: icon + title + workspace together - const sessionPill = $('div.agent-sessions-titlebar-pill'); - - // Center group: icon + title + workspace name - const centerGroup = $('div.agent-sessions-titlebar-center'); - - // Kind icon at the beginning - if (icon) { - const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); - centerGroup.appendChild(iconEl); + // Preserve an in-progress blink when re-rendering the SAME requires-input + // count. Other autoruns (e.g. onDidChangeSessions) invalidate the cached + // render state and force a redundant rebuild of the identical pill; without + // this guard that rebuild would strip the freshly-added blink class and cut + // the animation short — which is why the first "1 session requires input" + // never appeared to animate. + if (!(showRequiresInput && blockedCount === previousBlockedCount)) { + this._container.classList.remove('agent-sessions-titlebar-blink'); } + this._container.classList.toggle('agent-sessions-titlebar-requires-input', showRequiresInput); + this._container.classList.toggle('agent-sessions-titlebar-approved', showApproved); - // Session title shown next to the icon - if (sessionTitle) { - const titleEl = $('div.agent-sessions-titlebar-title'); - titleEl.textContent = sessionTitle; - centerGroup.appendChild(titleEl); + if (showApproved) { + this._renderApproved(approvedCount); + } else if (showRequiresInput) { + this._renderRequiresInput(blockedCount, requiresInputKind, shouldBlink); + } else { + this._renderActiveSession(); } - - // Workspace name shown after the session title - if (workspaceLabel) { - const separatorEl = $('div.agent-sessions-titlebar-separator'); - centerGroup.appendChild(separatorEl); - - const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = workspaceLabel; - centerGroup.appendChild(workspaceEl); - } - - sessionPill.appendChild(centerGroup); - - // Click handler on pill - this._dynamicDisposables.add(addDisposableGenericMouseDownListener(sessionPill, (e) => { - e.preventDefault(); - e.stopPropagation(); - })); - this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this._showSessionsPicker(); - })); - - this._container.appendChild(sessionPill); - - // Keyboard handler - this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this._showSessionsPicker(); - } - })); } finally { this._isRendering = false; } } + /** + * Render the active-session pill: icon + title + workspace. Clicking opens the + * sessions picker. + */ + private _renderActiveSession(): void { + const container = this._container!; + container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); + + const icon = this._getActiveSessionIcon(); + const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); + const workspaceLabel = this._getRepositoryLabel(); + + // Session pill: icon + title + workspace together + const sessionPill = $('div.agent-sessions-titlebar-pill'); + + // Center group: icon + title + workspace name + const centerGroup = $('div.agent-sessions-titlebar-center'); + + // Kind icon at the beginning + if (icon) { + const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); + centerGroup.appendChild(iconEl); + } + + // Session title shown next to the icon + if (sessionTitle) { + const titleEl = $('div.agent-sessions-titlebar-title'); + titleEl.textContent = sessionTitle; + centerGroup.appendChild(titleEl); + } + + // Workspace name shown after the session title + if (workspaceLabel) { + const separatorEl = $('div.agent-sessions-titlebar-separator'); + centerGroup.appendChild(separatorEl); + + const workspaceEl = $('div.agent-sessions-titlebar-workspace'); + workspaceEl.textContent = workspaceLabel; + centerGroup.appendChild(workspaceEl); + } + + sessionPill.appendChild(centerGroup); + + // Click handler on pill + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(sessionPill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + })); + + container.appendChild(sessionPill); + + // Keyboard handler + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + } + })); + } + + /** + * Whether a blocked session should stay hidden because the user just approved + * its pending action: hidden while that approval resolves (no current approval, + * status lagging) or is unchanged; a new, distinct approval re-surfaces it. + */ + private _isApprovalDismissed(blocked: IBlockedSession, dismissed: ReadonlyMap, reader: IReader): boolean { + const dismissedId = dismissed.get(blocked.session.sessionId); + if (dismissedId === undefined || blocked.reason !== BlockedSessionReason.NeedsInput) { + return false; + } + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + return approval === undefined || agentSessionApprovalId(approval) === dismissedId; + } + + /** + * Remember that the user allowed this exact approval so the session drops out of + * the blocked set immediately (see {@link _isApprovalDismissed}). + */ + private _dismissApproval(approved: IApprovedSession): void { + const next = new Map(this._dismissedApprovals.get()); + next.set(approved.session.sessionId, approved.approvalId); + this._dismissedApprovals.set(next, undefined); + } + + /** + * Classify a single blocked session into a specific requires-input kind, or + * `undefined` when it can't be classified (which forces the generic message). + */ + private _kindOf(blocked: IBlockedSession, reader: IReader): RequiresInputKind | undefined { + switch (blocked.reason) { + case BlockedSessionReason.FailingCI: + return RequiresInputKind.FailingCI; + case BlockedSessionReason.UnresolvedComments: + return RequiresInputKind.UnresolvedComments; + case BlockedSessionReason.NeedsInput: { + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + switch (approval?.kind) { + case AgentSessionApprovalKind.Terminal: + return RequiresInputKind.TerminalApproval; + case AgentSessionApprovalKind.Question: + return RequiresInputKind.Question; + default: + return undefined; + } + } + default: + return undefined; + } + } + + /** + * Build the requires-input pill label. A homogeneous set of blocked sessions + * gets a specific, more actionable message; a mix (or an unclassified session) + * falls back to the generic "N sessions require input". + */ + private _getRequiresInputLabel(count: number, kind: RequiresInputKind | undefined): string { + switch (kind) { + case RequiresInputKind.TerminalApproval: + return count === 1 + ? localize('oneSessionTerminalApproval', "1 session requires terminal approval") + : localize('nSessionsTerminalApproval', "{0} sessions require terminal approval", count); + case RequiresInputKind.Question: + return count === 1 + ? localize('oneSessionQuestion', "1 session has a question") + : localize('nSessionsQuestion', "{0} sessions have questions", count); + case RequiresInputKind.FailingCI: + return count === 1 + ? localize('oneSessionFailingCI', "1 session is failing CI") + : localize('nSessionsFailingCI', "{0} sessions are failing CI", count); + case RequiresInputKind.UnresolvedComments: + return count === 1 + ? localize('oneSessionUnresolvedComments', "1 session has unresolved comments") + : localize('nSessionsUnresolvedComments', "{0} sessions have unresolved comments", count); + default: + return count === 1 + ? localize('oneSessionRequiresInput', "1 session requires input") + : localize('nSessionsRequireInput', "{0} sessions require input", count); + } + } + + /** + * Render the requires-input pill. Clicking toggles a dropdown that lists the + * blocked sessions below the command center box. + */ + private _renderRequiresInput(count: number, kind: RequiresInputKind | undefined, shouldBlink: boolean): void { + const container = this._container!; + const label = this._getRequiresInputLabel(count, kind); + container.setAttribute('aria-label', label); + + const pill = $('div.agent-sessions-titlebar-pill'); + const labelEl = $('div.agent-sessions-titlebar-requires-input-label'); + labelEl.textContent = label; + pill.appendChild(labelEl); + + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._toggleBlockedSessions(); + })); + + container.appendChild(pill); + + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._toggleBlockedSessions(); + } + })); + + if (shouldBlink) { + this._triggerAttentionBlink(); + } + } + + /** + * Render the transient green "Approved N sessions" confirmation shown briefly + * after the user approves one or more sessions' pending actions from the list. + */ + private _renderApproved(count: number): void { + const container = this._container!; + const label = count === 1 + ? localize('oneSessionApproved', "Approved 1 session") + : localize('nSessionsApproved', "Approved {0} sessions", count); + container.setAttribute('aria-label', label); + + const pill = $('div.agent-sessions-titlebar-pill'); + const labelEl = $('div.agent-sessions-titlebar-approved-label'); + labelEl.textContent = label; + pill.appendChild(labelEl); + + // The confirmation is transient but stays clickable: clicking does whatever + // the widget's underlying (non-approved) state would do. + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._activateDefaultAction(); + })); + + container.appendChild(pill); + + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._activateDefaultAction(); + } + })); + } + + /** + * Activate the widget as its non-approved state would: reveal the blocked + * sessions when the requires-input state applies, otherwise the sessions picker. + */ + private _activateDefaultAction(): void { + const requiresInput = this._blockedSessions.get().length > 0 && !this._sidebarVisible.get(); + if (requiresInput) { + this._toggleBlockedSessions(); + } else { + this._showSessionsPicker(); + } + } + + /** + * Restart the attention blink animation on the command center box. Re-adding + * the class after a forced reflow guarantees the CSS animation replays even + * when the container element persists across renders. + */ + private _triggerAttentionBlink(): void { + const container = this._container; + if (!container) { + return; + } + container.classList.remove('agent-sessions-titlebar-blink'); + container.getBoundingClientRect(); // force reflow so the animation restarts + container.classList.add('agent-sessions-titlebar-blink'); + // Own the listener outside `_dynamicDisposables` (cleared on every render) so a + // redundant re-render can't drop it before the animation finishes. + this._blinkListener.value = addDisposableListener(container, 'animationend', () => { + container.classList.remove('agent-sessions-titlebar-blink'); + this._blinkListener.clear(); + }); + } + + /** + * Toggle the blocked-sessions dropdown open/closed. + */ + private _toggleBlockedSessions(): void { + if (this._openContextView) { + this._openContextView.close(); + return; + } + this._showBlockedSessions(); + } + + /** + * Show the blocked sessions as a flat list in a dropdown anchored below the + * command center box. + */ + private _showBlockedSessions(): void { + const container = this._container; + if (!container) { + return; + } + if (this._blockedSessions.get().length === 0) { + return; + } + + // Match the dropdown width to the command center box it hangs off. + const width = container.getBoundingClientRect().width; + + const store = new DisposableStore(); + this._openContextView = this.contextViewService.showContextView({ + getAnchor: () => container, + anchorAlignment: AnchorAlignment.LEFT, + anchorPosition: AnchorPosition.BELOW, + render: (viewContainer): IDisposable => { + const list = store.add(this.instantiationService.createInstance(BlockedSessionsList, viewContainer, { + width, + approvalModel: this._approvalModel, + onSessionOpen: (resource, preserveFocus, sideBySide) => { + this.contextViewService.hideContextView(); + this._openBlockedSession(resource, preserveFocus, sideBySide); + }, + })); + list.setSessions(this._blockedSessions.get().map(entry => entry.session)); + store.add(list.onDidChangeContentHeight(() => this.contextViewService.layout())); + store.add(list.onDidApproveSession(approved => { + this._dismissApproval(approved); + this._sessionActionFeedback.notifyApproved(); + })); + + // Keep the dropdown width matched to the command center box as the + // window resizes (the command center reflows to a new width). + store.add(this.layoutService.onDidLayoutActiveContainer(() => { + list.setWidth(container.getBoundingClientRect().width); + this.contextViewService.layout(); + })); + + this._blockedList = list; + return store; + }, + focus: () => this._blockedList?.focus(), + onDOMEvent: (e: Event) => { + // Dismiss on Escape, or on a click outside the dropdown. Clicks on the + // anchor are ignored here because the anchor toggles the dropdown itself. + if (e.type === EventType.KEY_DOWN) { + if (new StandardKeyboardEvent(e as KeyboardEvent).equals(KeyCode.Escape)) { + this.contextViewService.hideContextView(); + } + } else if (e.type === EventType.CLICK) { + const target = e.target as HTMLElement | null; + if (target + && !isAncestor(target, this.contextViewService.getContextViewElement()) + && !isAncestor(target, container)) { + this.contextViewService.hideContextView(); + } + } + }, + onHide: () => { + store.dispose(); + this._openContextView = undefined; + this._blockedList = undefined; + }, + }); + } + + private _openBlockedSession(resource: URI, preserveFocus: boolean, sideBySide: boolean): void { + if (sideBySide) { + const session = this.sessionsManagementService.getSession(resource); + if (session) { + openSessionToTheSide(this.sessionsService, session, { preserveFocus }).catch(onUnexpectedError); + return; + } + } + this.sessionsService.openSession(resource, { preserveFocus }).catch(onUnexpectedError); + } + /** * Get the icon for the active session's type. */ @@ -266,11 +823,31 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben when: IsAuxiliaryWindowContext.negate() })); + // The blocked-sessions dropdown header's "Show All Sessions" action dismisses + // the dropdown (a transient context view) before opening the full sessions + // picker, so the popup doesn't linger behind it. + this._register(CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, accessor => { + accessor.get(IContextViewService).hideContextView(); + return accessor.get(ICommandService).executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); + })); + + // Contribute the action to the blocked-sessions dropdown header toolbar. + this._register(MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { + command: { + id: SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, + title: localize('showAllSessions', "Show All Sessions"), + icon: Codicon.listSelection, + }, + group: 'navigation', + order: 1, + when: IsAuxiliaryWindowContext.negate() + })); + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } - return instantiationService.createInstance(SessionsTitleBarWidget, action, options); + return instantiationService.createInstance(SessionsTitleBarWidget, action, options, undefined, undefined, undefined); }, undefined)); } } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 9cd32b40c25..61fa8a3a602 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -5,6 +5,7 @@ import '../media/sessionsList.css'; import * as DOM from '../../../../../base/browser/dom.js'; +import { synchronizeCSSAnimations } from '../../../../../base/browser/animationSync.js'; import { Gesture } from '../../../../../base/browser/touch.js'; import { IListVirtualDelegate, ListDragOverEffectPosition, ListDragOverEffectType, NotSelectableGroupId } from '../../../../../base/browser/ui/list/list.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; @@ -35,8 +36,9 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { IStyleOverride, defaultButtonStyles, defaultFindWidgetStyles, defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { Action, ActionRunner, IAction, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; @@ -83,6 +85,10 @@ export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = MenuId.SessionItemContextMenu; export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); export const SessionGroupToolbarMenuId = new MenuId('SessionGroupToolbar'); + +/** Controls whether empty default groups (Pinned, Chats) are shown in the sessions list. */ +export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups'; + export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey('sessionItem.hasBranchName', false); /** Whether the focused session item currently belongs to a user group. */ @@ -166,6 +172,12 @@ function isSessionItem(item: SessionListItem): item is ISession { const SHOW_MORE_FOLDERS_LABEL = '__more_folders__'; const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000; +/** + * Default number of terminal-command lines shown in a session row's approval + * prompt. The blocked-sessions dropdown overrides this to show more lines. + */ +const DEFAULT_APPROVAL_ROW_MAX_LINES = 3; + //#endregion //#region Tree Delegate @@ -187,6 +199,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { constructor( private readonly _approvalModel: AgentSessionApprovalModel | undefined, private readonly _isPhone: () => boolean, + private readonly _approvalRowMaxLines: number = DEFAULT_APPROVAL_ROW_MAX_LINES, ) { } getHeight(element: SessionListItem): number { @@ -204,7 +217,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { if (this._approvalModel) { const approval = getFirstApprovalAcrossChats(this._approvalModel, element as ISession, undefined); if (approval) { - height += SessionItemRenderer.getApprovalRowHeight(approval.label); + height += SessionItemRenderer.getApprovalRowHeight(approval.label, this._approvalRowMaxLines); } } return height; @@ -256,10 +269,16 @@ class SessionItemActionRunner extends ActionRunner { } } +// Keyframes name of the in-progress title shimmer (see `session-title-shimmer` +// in sessionsList.css). Used to phase-align the shimmer across rows. +const SESSION_TITLE_SHIMMER_ANIMATION_NAME = 'session-title-shimmer'; +const SESSION_TITLE_SHIMMER_ANIMATION_NAMES = new Set([SESSION_TITLE_SHIMMER_ANIMATION_NAME]); + interface ISessionItemTemplate { readonly container: HTMLElement; readonly statusIcon: SessionStatusIcon; readonly title: HighlightedLabel; + readonly titleContainer: HTMLElement; readonly titleToolbar: MenuWorkbenchToolBar; readonly detailsRow: HTMLElement; readonly approvalRow: HTMLElement; @@ -270,24 +289,37 @@ interface ISessionItemTemplate { readonly elementDisposables: DisposableStore; } +/** Payload emitted when the user approves a session's pending action. */ +export interface IApprovedSession { + readonly session: ISession; + /** + * Identity of the approval that was allowed, so consumers can tell this exact + * approval apart from a later, distinct one on the same session. + */ + readonly approvalId: string; +} + class SessionItemRenderer implements ITreeRenderer { static readonly TEMPLATE_ID = 'session-item'; readonly templateId = SessionItemRenderer.TEMPLATE_ID; - private static readonly APPROVAL_ROW_MAX_LINES = 3; private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18; private static readonly _APPROVAL_ROW_OVERHEAD = 14; - static getApprovalRowHeight(label: string): number { - const lineCount = Math.min(label.split(/\r?\n/).length, SessionItemRenderer.APPROVAL_ROW_MAX_LINES); + static getApprovalRowHeight(label: string, maxLines: number = DEFAULT_APPROVAL_ROW_MAX_LINES): number { + const lineCount = Math.min(label.split(/\r?\n/).length, maxLines); return lineCount * SessionItemRenderer._APPROVAL_ROW_LINE_HEIGHT + SessionItemRenderer._APPROVAL_ROW_OVERHEAD; } private readonly _onDidChangeItemHeight = new Emitter(); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + private readonly _onDidApproveSession = new Emitter(); + /** Fires when the user approves a session's pending action via its "Allow" button. */ + readonly onDidApproveSession: Event = this._onDidApproveSession.event; + constructor( - private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; isInChatsSection: (session: ISession) => boolean }, + private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable; getMultiSelectedSessions: (session: ISession) => ISession[]; isInChatsSection: (session: ISession) => boolean; showHover: boolean; approvalRowMaxLines: number }, private readonly approvalModel: AgentSessionApprovalModel | undefined, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, @@ -296,8 +328,7 @@ class SessionItemRenderer implements ITreeRenderer { + if (e.target === titleContainer && e.animationName === SESSION_TITLE_SHIMMER_ANIMATION_NAME) { + synchronizeCSSAnimations(titleContainer, { animationNames: SESSION_TITLE_SHIMMER_ANIMATION_NAMES }); + } + })); const titleToolbarContainer = DOM.append(titleRow, $('.session-title-toolbar')); // The list opens a session on click and on Gesture `tap` (touch). // DOM event propagation stops only cover mouse/pointer events; the @@ -336,7 +379,7 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -357,14 +400,15 @@ class SessionItemRenderer implements ITreeRenderer ({ - content: buildSessionHoverContent(element, this.sessionsProvidersService), - appearance: { showPointer: true }, - position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, - persistence: { hideOnHover: false }, - }), { groupId: 'sessions-list' })); + if (this.options.showHover) { + // Rich hover on the row showing folder, branch, diff stats and provider. + template.elementDisposables.add(this.hoverService.setupDelayedHover(template.container, () => ({ + content: buildSessionHoverContent(element, this.sessionsProvidersService), + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, + persistence: { hideOnHover: false }, + }), { groupId: 'sessions-list' })); + } // Toolbar context template.titleToolbar.context = element; @@ -408,6 +452,9 @@ class SessionItemRenderer implements ITreeRenderer maxLines) { visibleLines[maxLines - 1] = `${visibleLines[maxLines - 1]} \u2026`; @@ -605,13 +652,14 @@ class SessionItemRenderer implements ITreeRenderer info.confirm())); + buttonStore.add(button.onDidClick(() => { + // Capture the approval's identity BEFORE confirming: `confirm()` may + // synchronously clear the pending approval, so we can't read it after. + const approvalId = agentSessionApprovalId(info); + info.confirm(); + this._onDidApproveSession.fire({ session: element, approvalId }); + })); } if (wasVisible !== visible) { @@ -1516,6 +1570,7 @@ export class SessionsList extends Disposable implements ISessionsList { @IKeybindingService private readonly keybindingService: IKeybindingService, @ICommandService private readonly commandService: ICommandService, @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -1561,10 +1616,15 @@ export class SessionsList extends Disposable implements ISessionsList { subscribeProviderCapabilities(); this.update(); })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING)) { + this.update(); + } + })); // TEMPORARY (#320480): see the note on the `IAgentSessionsService` import. const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const sessionRenderer = new SessionItemRenderer( - { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), isInChatsSection: s => this._chatsSectionSessionIds.has(s.resource.toString()) }, + { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), isInChatsSection: s => this._chatsSectionSessionIds.has(s.resource.toString()), showHover: true, approvalRowMaxLines: DEFAULT_APPROVAL_ROW_MAX_LINES }, approvalModel, instantiationService, contextKeyService, @@ -1974,16 +2034,20 @@ export class SessionsList extends Disposable implements ISessionsList { const hasRecentSessions = sections.some(s => s.id === 'recent' && s.sessions.length > 0); + // Keep the "Pinned" and "Chats" default sections visible even when empty so + // they stay discoverable, unless the user opts out via the setting. + const showEmptyDefaultGroups = this.configurationService.getValue(SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING); + // Keep the "Pinned" section always visible (even with no pinned sessions) // so it is discoverable, mirroring the always-visible "Chats" section. - if (!sections.some(s => s.id === 'pinned')) { + if (showEmptyDefaultGroups && !sections.some(s => s.id === 'pinned')) { sections.push({ id: 'pinned', label: localize('pinned', "Pinned"), sessions: [] }); } // Keep the "Chats" section always visible (even with no quick chats) so its // header — leading chat icon, label, and the "+" create action — is always // reachable. Only when a provider can actually serve quick chats. - if (this._someProviderSupportsQuickChats() && !sections.some(s => s.id === QUICK_CHATS_SECTION_ID)) { + if (showEmptyDefaultGroups && this._someProviderSupportsQuickChats() && !sections.some(s => s.id === QUICK_CHATS_SECTION_ID)) { sections.push({ id: QUICK_CHATS_SECTION_ID, label: localize('chatsSection', "Chats"), sessions: [] }); } @@ -2869,7 +2933,7 @@ export class SessionsList extends Disposable implements ISessionsList { //#region Approval Helpers -function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { +export function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { let oldest: IAgentSessionApprovalInfo | undefined; for (const chat of session.chats.read(reader)) { const approval = approvalModel.getApproval(chat.resource).read(reader); @@ -3153,3 +3217,156 @@ export function groupByDate(sessions: ISession[], sorting: SessionsSorting, getS } //#endregion + +//#region Flat List + +export interface ISessionsFlatListOptions { + readonly overrideStyles?: IStyleOverride; + readonly showSessionHover?: boolean; + /** Called when a session row is opened (clicked / activated). */ + onSessionOpen(resource: URI, preserveFocus: boolean, sideBySide: boolean): void; + /** + * Approval model tracking pending tool confirmations for the shown sessions. + * When omitted the list creates and owns its own; injectable so tests and + * fixtures can supply pending approvals without a live chat session. + */ + readonly approvalModel?: AgentSessionApprovalModel; + /** + * Maximum number of terminal-command lines shown in a session's approval + * prompt. Defaults to the same limit as the main sessions list; the + * blocked-sessions dropdown passes a larger value. + */ + readonly approvalRowMaxLines?: number; +} + +/** + * A lightweight, flat sessions list that renders session rows exactly like the + * main {@link SessionsList} but without any sections, groups or workspace + * headers. Only the sessions passed to {@link setSessions} are shown. Used by + * surfaces that need a focused, sectionless view of a specific set of sessions + * (e.g. the titlebar "N blocked" hover). + */ +export class SessionsFlatList extends Disposable { + + private static readonly ROW_HEIGHT = 54; + + private readonly _onDidChangeContentHeight = this._register(new Emitter()); + readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; + private readonly _onDidApproveSession = this._register(new Emitter()); + /** Fires when a session's pending action is approved from its "Allow" button. */ + readonly onDidApproveSession: Event = this._onDidApproveSession.event; + private readonly tree: WorkbenchObjectTree; + private readonly _delegate: SessionsTreeDelegate; + private _sessions: readonly ISession[] = []; + + constructor( + container: HTMLElement, + private readonly options: ISessionsFlatListOptions, + @ISessionsService private readonly _sessionsService: ISessionsService, + @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, + @IHoverService hoverService: IHoverService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + // Wrap in `.sessions-list-control` so the row styles scoped to that class + // (needs-input/pinned row highlights) apply exactly like the main list. + const listRoot = DOM.append(container, $('.sessions-list-control')); + const approvalModel = this.options.approvalModel ?? this._register(instantiationService.createInstance(AgentSessionApprovalModel)); + + // TEMPORARY (#320480): the row renderer reaches into a Copilot-provider + // internal to lazily resolve expensive session properties. Resolved via + // the instantiation service so this file's single suppressed import stays + // the only reference. See the note on the `IAgentSessionsService` import. + const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); + + const sessionRenderer = new SessionItemRenderer( + { + grouping: () => SessionsGrouping.Date, + isPinned: s => this._sessionsListModelService.isSessionPinned(s), + isRead: s => this._sessionsListModelService.isSessionRead(s), + visibleSessions: this._sessionsService.visibleSessions, + getMultiSelectedSessions: s => [s], + showHover: this.options.showSessionHover ?? true, + isInChatsSection: s => false, + approvalRowMaxLines: this.options.approvalRowMaxLines ?? DEFAULT_APPROVAL_ROW_MAX_LINES, + }, + approvalModel, + instantiationService, + contextKeyService, + markdownRendererService, + hoverService, + sessionsProvidersService, + agentSessionsService, + ); + + this._delegate = new SessionsTreeDelegate(approvalModel, () => false, this.options.approvalRowMaxLines ?? DEFAULT_APPROVAL_ROW_MAX_LINES); + + this.tree = this._register(instantiationService.createInstance( + WorkbenchObjectTree, + 'SessionsFlatList', + listRoot, + this._delegate, + [sessionRenderer], + { + accessibilityProvider: new SessionsAccessibilityProvider(), + identityProvider: { + getId: (element: SessionListItem) => (element as ISession).resource.toString(), + }, + horizontalScrolling: false, + multipleSelectionSupport: false, + indent: 0, + overrideStyles: this.options.overrideStyles, + renderIndentGuides: RenderIndentGuides.None, + twistieAdditionalCssClass: () => 'force-no-twistie', + } + )); + + this._register(this.tree.onDidOpen(e => { + const element = e.element; + if (!element || !isSessionItem(element)) { + return; + } + this._sessionsListModelService.markRead(element); + const isLeftClick = DOM.isMouseEvent(e.browserEvent) && e.browserEvent.button === 0; + const preserveFocus = isLeftClick ? false : (e.editorOptions.preserveFocus ?? false); + this.options.onSessionOpen(element.resource, preserveFocus, e.sideBySide); + })); + + this._register(sessionRenderer.onDidChangeItemHeight(session => { + if (this.tree.hasElement(session)) { + this.tree.updateElementHeight(session, this._delegate.getHeight(session)); + this._onDidChangeContentHeight.fire(); + } + })); + + this._register(sessionRenderer.onDidApproveSession(approved => this._onDidApproveSession.fire(approved))); + } + + setSessions(sessions: readonly ISession[]): void { + this._sessions = sessions; + this.tree.setChildren(null, sessions.map(session => ({ element: session }))); + } + + /** The total pixel height required to render all current rows without scrolling. */ + getContentHeight(): number { + return this._sessions.reduce((total, session) => total + this._delegate.getHeight(session), 0); + } + + getRowHeight(): number { + return SessionsFlatList.ROW_HEIGHT; + } + + layout(height: number, width: number): void { + this.tree.layout(height, width); + } + + focus(): void { + this.tree.domFocus(); + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 3e320b67ef4..743925521eb 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -19,7 +19,7 @@ import { IAgentHostToolSetEnablementService, IToolEnablementState } from '../../ import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { getChatSessionType } from '../../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem, ICustomizationItemConfig } from '../../browser/customizationsToolbar.contribution.js'; @@ -162,6 +162,7 @@ function createMockHarnessService(hiddenSections: readonly string[] = []): ICust label: 'Fixture', icon: ThemeIcon.fromId('vm'), hiddenSections, + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), }; return new class extends mock() { override readonly activeSessionResource = observableValue('mockActiveSessionResource', URI.parse(`${descriptor.id}:///session`)); diff --git a/src/vs/sessions/services/agentHost/browser/agentHostEnablementService.ts b/src/vs/sessions/services/agentHost/browser/agentHostEnablementService.ts new file mode 100644 index 00000000000..571457cbe05 --- /dev/null +++ b/src/vs/sessions/services/agentHost/browser/agentHostEnablementService.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; + +export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { + + declare readonly _serviceBrand: undefined; + + readonly enabled: boolean; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + this.enabled = configurationService.getValue(AgentHostEnabledSettingId) ?? false; + } +} + +registerSingleton(IAgentHostEnablementService, AgentHostEnablementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/agentHost/common/agentHostEnablementService.ts b/src/vs/sessions/services/agentHost/common/agentHostEnablementService.ts new file mode 100644 index 00000000000..3629a761573 --- /dev/null +++ b/src/vs/sessions/services/agentHost/common/agentHostEnablementService.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IAgentHostEnablementService = createDecorator('agentHostEnablementService'); + +export interface IAgentHostEnablementService { + readonly _serviceBrand: undefined; + readonly enabled: boolean; +} diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 7d89cf88160..fd05f1177f6 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -108,7 +108,7 @@ export class VisibleSession extends Disposable implements IActiveSession { }); // Tab strip contents: the open chats in the provider's order, with subagent // (tool-origin) chats hidden by default. A subagent surfaces as a tab only - // once explicitly opened (e.g. from the Subagents dropdown), tracked in + // once explicitly opened (e.g. from the Conversations menu), tracked in // `_shownSubagentUris`. Hidden and closed chats are excluded by `openChats`. this.visibleChatTabs = derived(this, reader => { const shownSubagents = this._shownSubagentUris.read(reader); @@ -119,9 +119,15 @@ export class VisibleSession extends Disposable implements IActiveSession { // Shown for more than one real (non-tool) chat — counting closed ones — // or a single chat whose title diverged from the session title. An opened // subagent tab also warrants showing the strip, so any time there is more - // than one visible tab the strip is shown. + // than one visible tab the strip is shown. The strip is also shown as soon + // as the session has any subagent (tool-origin) chat, so the Conversations + // menu (which lists subagents) surfaces in the tab bar. this.shouldShowChatTabs = derived(this, reader => { - const tabChats = this._session.chats.read(reader).filter(c => + const chats = this._session.chats.read(reader); + if (chats.some(c => c.origin?.kind === ChatOriginKind.Tool)) { + return true; + } + const tabChats = chats.filter(c => c.origin?.kind !== ChatOriginKind.Tool && c.interactivity.read(reader) !== ChatInteractivity.Hidden); if (tabChats.length > 1) { @@ -148,7 +154,7 @@ export class VisibleSession extends Disposable implements IActiveSession { return; } // Closing a subagent (tool-origin) tab just hides it again; it stays - // reachable from the Subagents dropdown and is not added to the + // reachable from the Conversations menu and is not added to the // reopenable closed set. if (chat.origin?.kind === ChatOriginKind.Tool) { const shown = this._shownSubagentUris.get(); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 366191d41c0..fb016e216d1 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -646,6 +646,10 @@ export function sessionFileChangesEqual(a: readonly ISessionFileChange[], b: rea if (!isEqual(x.originalUri, y.originalUri)) { return false; } + + if (x.reviewed !== y.reviewed) { + return false; + } } return true; diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index e157a9539fe..4738c7dbf12 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -28,6 +28,7 @@ import { SessionHasMultipleOpenChatsContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, + SessionActiveChatHasSubagentsContext, } from '../../../common/contextkeys.js'; import { ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; @@ -57,6 +58,7 @@ interface ISessionContextKeys { readonly hasMultipleOpenChats: IContextKey; readonly activeChatIsClosable: IContextKey; readonly activeChatIsDeletable: IContextKey; + readonly activeChatHasSubagents: IContextKey; } /** @@ -94,6 +96,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey hasMultipleOpenChats: SessionHasMultipleOpenChatsContext.bindTo(contextKeyService), activeChatIsClosable: SessionActiveChatIsClosableContext.bindTo(contextKeyService), activeChatIsDeletable: SessionActiveChatIsDeletableContext.bindTo(contextKeyService), + activeChatHasSubagents: SessionActiveChatHasSubagentsContext.bindTo(contextKeyService), }; boundKeysByService.set(contextKeyService, keys); } @@ -192,4 +195,13 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, // it: the main chat and worker (subagent) chats report `canDelete: false`, // so they are closeable but not deletable. keys.activeChatIsDeletable.set(!!activeChat && getChatCapabilities(activeChat, session, reader).canDelete); + + // The active chat has subagents when any tool-origin chat names it as its + // parent. These are listed as a separate group in the Conversations menu, so + // the menu must surface even when the active chat is the only committed chat. + const allChats = session?.chats.read(reader) ?? []; + keys.activeChatHasSubagents.set(!!activeChat && allChats.some(chat => + chat.origin?.kind === ChatOriginKind.Tool && + !!chat.origin.parentChat && + isEqual(chat.origin.parentChat, activeChat.resource))); } diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 151564ff992..a7b73a1a2df 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -143,9 +143,8 @@ export interface ISessionsProvider { * Whether this provider can create **quick chats**: workspace-less sessions * that are not scoped to any folder (`ISession.workspace` is `undefined`). * When `true`, the provider must implement {@link createQuickChat}. - * Defaults to falsy (quick chats not supported). May change at runtime (e.g. - * when agent-host enablement toggles); providers signal such changes via - * {@link onDidChangeCapabilities}. + * Defaults to falsy (quick chats not supported). Providers that do change + * this at runtime should signal it via {@link onDidChangeCapabilities}. */ readonly supportsQuickChats?: boolean; diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 52feac51f5b..7e055a99c0a 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -1187,14 +1187,14 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); - test('hidden for a single non-tool chat matching the session title with an unopened subagent', () => { + test('shown for a single non-tool chat matching the session title when it has a subagent', () => { const visible = createSession('Title', [ makeChat('main', 'Title'), makeChat('tool', 'tool', ChatOriginKind.Tool), ]); - // Subagents are hidden by default, so an unopened subagent does not force - // the strip to show when the single non-tool chat matches the session title. - assert.strictEqual(visible.shouldShowChatTabs.get(), false); + // The strip is shown as soon as the session has any subagent, so the + // Conversations menu (which lists subagents) surfaces in the tab bar. + assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); test('hidden when there are no tab chats', () => { diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 60ae30ad1d4..7a8decbf034 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -143,6 +143,7 @@ import '../workbench/services/chat/common/chatEntitlementService.js'; import '../workbench/services/log/common/defaultLogLevels.js'; import '../workbench/services/agentHost/common/agentHostResourceService.js'; import '../platform/agentHost/browser/agentHostConnectionsService.js'; +import './services/agentHost/browser/agentHostEnablementService.js'; import './services/agentHost/browser/agentHostCustomizationService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index 09cc84f1b10..5ef475e7ef2 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -10,7 +10,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { isIMenuItem, MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { ToggleAuxiliaryBarAction } from '../../../workbench/browser/parts/auxiliarybar/auxiliaryBarActions.js'; +import { MainEditorAreaVisibleContext } from '../../../workbench/common/contextkeys.js'; import { Menus } from '../../browser/menus.js'; +import { SinglePaneDetailChangesOrFilesActiveContext } from '../../common/contextkeys.js'; // Import layout actions to trigger menu registration import '../../browser/layoutActions.js'; @@ -29,24 +31,44 @@ suite('Sessions - Layout Actions', () => { assert.strictEqual(toggleAlwaysOnTop.group, 'navigation'); }); - test('auxiliary bar toggle reuses the core command with state-dependent icons on the editor title', () => { - // The editor-title menu items reference the core toggle command rather than registering - // their own; assert it is actually registered so the contribution cannot silently break. + test('original-layout auxiliary bar toggle reuses the core command with state-dependent icons on the editor title layout menu', () => { + // The original (non-single-pane) editor-title menu items reference the core toggle command + // rather than registering their own; assert it is actually registered so the contribution + // cannot silently break. (The single-pane "Toggle Details" item is a dedicated command + // registered by SinglePaneDesktopSessionLayoutController and is asserted in its own suite.) assert.ok(CommandsRegistry.getCommand(ToggleAuxiliaryBarAction.ID), 'core toggle auxiliary bar command should be registered'); - const auxiliaryBarToggles = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) + // Original layout: two mutually-exclusive right-panel icons on the layout group. + const layoutToggleIcons = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) .filter(isIMenuItem) .filter(item => item.command.id === ToggleAuxiliaryBarAction.ID) - .map(item => ({ - group: item.group, - order: item.order, - icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, - })) - .sort((a, b) => (a.icon ?? '').localeCompare(b.icon ?? '')); + .map(item => ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined) + .sort((a, b) => (a ?? '').localeCompare(b ?? '')); + assert.deepStrictEqual(layoutToggleIcons, [Codicon.rightPanelHide.id, Codicon.rightPanelShow.id]); + }); - assert.deepStrictEqual(auxiliaryBarToggles, [ - { group: 'navigation', order: 99.5, icon: Codicon.rightPanelHide.id }, - { group: 'navigation', order: 99.5, icon: Codicon.rightPanelShow.id }, - ]); + test('single-pane editor title layout actions are ordered at the end', async () => { + await import('../../contrib/editor/browser/editor.contribution.js'); + + const menuItems = MenuRegistry.getMenuItems(MenuId.EditorTitle).filter(isIMenuItem); + const orders = (id: string) => menuItems + .filter(item => item.command.id === id) + .map(item => item.order); + const groupOrders = (id: string) => menuItems + .filter(item => item.command.id === id) + .map(item => ({ group: item.group, order: item.order })); + const whens = (id: string) => menuItems + .filter(item => item.command.id === id) + .map(item => item.when?.serialize() ?? ''); + + assert.deepStrictEqual(orders('workbench.action.agentSessions.maximizeMainEditorPart'), [1000000]); + assert.deepStrictEqual(orders('workbench.action.agentSessions.restoreMainEditorPart'), [1000000]); + assert.deepStrictEqual(groupOrders('workbench.action.agentSessions.hideMainEditorPart'), [{ group: 'navigation', order: 999999 }]); + assert.ok(orders('workbench.action.agentSessions.hideMainEditorPart').every(order => typeof order === 'number' && order < 1000000)); + assert.ok(whens('workbench.action.agentSessions.maximizeMainEditorPart').every(when => when.includes(MainEditorAreaVisibleContext.key))); + assert.ok(whens('workbench.action.agentSessions.restoreMainEditorPart').every(when => when.includes(MainEditorAreaVisibleContext.key))); + assert.ok(whens('workbench.action.agentSessions.hideMainEditorPart').every(when => when.includes(MainEditorAreaVisibleContext.key))); + assert.ok(whens('workbench.action.agentSessions.hideMainEditorPart').every(when => when.includes(SinglePaneDetailChangesOrFilesActiveContext.key))); + assert.ok(orders('workbench.action.agentSessions.addFileAsContext').every(order => typeof order === 'number' && order < 1000000)); }); }); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index c0ca04011b5..6168b4f7293 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -4,7 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SashState } from '../../../base/browser/ui/sash/sash.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { IPartVisibilityChangeEvent, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { DockedAuxiliaryBarController, IDockedAuxiliaryBarHost } from '../../browser/dockedAuxiliaryBarController.js'; import { Workbench } from '../../browser/workbench.js'; interface IWorkbenchTestHarness { @@ -23,10 +27,16 @@ interface IWorkbenchTestHarness { storageService: { store(...args: unknown[]): void; }; + readonly _dockDetailPanel?: boolean; + _editorPartAutoVisibilitySuppressionCount: number; _editorMaximized: boolean; _restoreAttachedEditorMaximizedOnShow: boolean; setEditorMaximized(maximized: boolean): void; setAuxiliaryBarHidden(hidden: boolean): void; + setEditorHidden(hidden: boolean): void; + suppressEditorPartAutoVisibility(): { dispose(): void }; + areAllGroupsInMainPartEmpty(): boolean; + rememberAttachedEditorMaximizedState(): void; _savePartVisibility(): void; } @@ -36,15 +46,51 @@ suite('Sessions - Workbench', () => { const rememberAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'rememberAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const setAuxiliaryBarHidden = Reflect.get(Workbench.prototype, 'setAuxiliaryBarHidden') as (this: IWorkbenchTestHarness, hidden: boolean) => void; + const isAuxViewContainerActive = Reflect.get(Workbench.prototype, '_isAuxViewContainerActive') as (this: { viewDescriptorService: unknown }, containerId: string) => boolean; + const handleDidCloseEditor = Reflect.get(Workbench.prototype, 'handleDidCloseEditor') as (this: IWorkbenchTestHarness) => void; + const areAllGroupsInMainPartEmpty = Reflect.get(Workbench.prototype, 'areAllGroupsInMainPartEmpty') as (this: IWorkbenchTestHarness) => boolean; const loadPartVisibility = Reflect.get(Workbench.prototype, '_loadPartVisibility') as (this: IWorkbenchTestHarness, storageService: { get(): string | undefined; remove(): void }) => { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean }; const savePartVisibility = Reflect.get(Workbench.prototype, '_savePartVisibility') as (this: IWorkbenchTestHarness) => void; + const setSideBarHidden = Reflect.get(Workbench.prototype, 'setSideBarHidden') as (this: ISideBarResizeTestHarness, hidden: boolean) => void; + const resizeDockedEditorAfterSidebarChange = Reflect.get(Workbench.prototype, '_resizeDockedEditorAfterSidebarChange') as (this: ISideBarResizeTestHarness, size: { width: number; height: number }) => void; + const growDockedDetailAfterSidebarChange = Reflect.get(Workbench.prototype, '_growDockedDetailAfterSidebarChange') as (this: ISideBarResizeTestHarness, width: number) => void; const setEditorHidden = Reflect.get(Workbench.prototype, 'setEditorHidden') as (this: IEditorSplitTestHarness, hidden: boolean) => void; + const suppressEditorPartAutoVisibility = Workbench.prototype.suppressEditorPartAutoVisibility as (this: IWorkbenchTestHarness) => { dispose(): void }; const applyEditorSplitSize = Reflect.get(Workbench.prototype, '_applyEditorSplitSize') as (this: IEditorSplitTestHarness, mainAreaWidth: number) => void; + const syncDockedEditorVisibility = Reflect.get(Workbench.prototype, '_syncDockedEditorVisibility') as (this: IEditorSplitTestHarness, nodeWidth: number) => void; + const syncDockedEditorVisibilityFromGrid = Reflect.get(Workbench.prototype, '_syncDockedEditorVisibilityFromGrid') as (this: IEditorSplitTestHarness) => void; + const handleDockedEditorPartLayout = Workbench.prototype.handleDockedEditorPartLayout as (this: IEditorSplitTestHarness, nodeWidth: number) => void; + const handleWillOpenEditor = Reflect.get(Workbench.prototype, '_handleWillOpenEditor') as (this: IWillOpenTestHarness, e: { groupId: number; editor: { typeId: string } }) => void; + + interface IWillOpenTestHarness { + _editorPartAutoVisibilitySuppressionCount: number; + partVisibility: { editor: boolean }; + editorGroupService: { mainPart: { groups: { id: number }[] } }; + setEditorHidden(hidden: boolean, explicit?: boolean): void; + restoreAttachedEditorMaximizedState(): void; + } + + function createWillOpenHarness(overrides?: Partial): { harness: IWillOpenTestHarness; setEditorHiddenCalls: { hidden: boolean; explicit?: boolean }[] } { + const setEditorHiddenCalls: { hidden: boolean; explicit?: boolean }[] = []; + const harness: IWillOpenTestHarness = { + _editorPartAutoVisibilitySuppressionCount: 0, + partVisibility: { editor: false }, + editorGroupService: { mainPart: { groups: [{ id: 1 }] } }, + setEditorHidden: (hidden, explicit) => setEditorHiddenCalls.push({ hidden, explicit }), + restoreAttachedEditorMaximizedState: () => { }, + ...overrides, + }; + return { harness, setEditorHiddenCalls }; + } + interface IEditorSplitTestHarness { readonly editorPartView: object; readonly sessionsPartView: object; readonly mainContainer: { classList: { toggle(name: string, force: boolean): void } }; + readonly _dockDetailPanel: boolean; + readonly _dockedAuxBar?: { layout(): void }; + readonly _onDidChangePartVisibility: { fire(e: IPartVisibilityChangeEvent): void }; readonly workbenchGrid: { getViewSize(view: object): { width: number; height: number }; setViewVisible(view: object, visible: boolean): void; @@ -52,26 +98,113 @@ suite('Sessions - Workbench', () => { }; readonly resizes: { width: number; height: number }[]; readonly visibilityChanges: boolean[]; - partVisibility: { editor: boolean }; + partVisibility: { editor: boolean; auxiliaryBar: boolean }; _editorMaximized: boolean; _hasAppliedInitialEditorSplit: boolean; + _dockedAuxiliaryBarWidth: number; + _dockedEditorSizeBeforeHide?: { width: number; height: number }; + _editorSizeGrownForSidebarHide?: { width: number; height: number }; + _detailWidthGrownForSidebarHide?: number; + _editorRevealedExplicitly?: boolean; + _syncingDockedEditorVisibility: boolean; + _syncDockedEditorVisibility(nodeWidth: number): void; setEditorMaximized(maximized: boolean): void; _applyEditorSplitSize(mainAreaWidth: number): void; + handleContainerDidLayout(): void; _savePartVisibility(): void; } - function createEditorSplitHarness(sessionsWidth: number, overrides?: Partial>): IEditorSplitTestHarness { + interface ISideBarResizeTestHarness { + readonly sideBarPartView: object; + readonly editorPartView: object; + readonly mainContainer: { classList: { toggle(name: string, force: boolean): void } }; + readonly _dockDetailPanel: boolean; + readonly _dockedAuxBar?: { layout(): void }; + readonly workbenchGrid: { + getViewSize(view: object): { width: number; height: number }; + setViewVisible(view: object, visible: boolean): void; + resizeView(view: object, size: { width: number; height: number }): void; + }; + readonly paneCompositeService: { + getActivePaneComposite(...args: unknown[]): undefined; + hideActivePaneComposite(...args: unknown[]): void; + getLastActivePaneCompositeId(...args: unknown[]): string | undefined; + openPaneComposite(...args: unknown[]): void; + }; + readonly viewDescriptorService: { + getDefaultViewContainer(...args: unknown[]): { id: string } | undefined; + }; + readonly resizes: { width: number; height: number }[]; + readonly visibilityChanges: boolean[]; + partVisibility: { sidebar: boolean; editor: boolean; auxiliaryBar: boolean }; + _editorSizeGrownForSidebarHide?: { width: number; height: number }; + _detailWidthGrownForSidebarHide?: number; + _dockedAuxiliaryBarWidth: number; + _syncingDockedEditorVisibility: boolean; + _resizeDockedEditorAfterSidebarChange(size: { width: number; height: number }): void; + _growDockedDetailAfterSidebarChange(width: number): void; + layoutMobileSidebar(): void; + _savePartVisibility(): void; + } + + function createSideBarResizeHarness(dockDetailPanel: boolean): ISideBarResizeTestHarness { + const sideBarPartView = {}; + const editorPartView = {}; + const resizes: { width: number; height: number }[] = []; + const visibilityChanges: boolean[] = []; + const viewSizes = new Map([ + [sideBarPartView, { width: 280, height: 800 }], + [editorPartView, { width: 620, height: 800 }], + ]); + return { + sideBarPartView, + editorPartView, + mainContainer: { classList: { toggle: () => { } } }, + _dockDetailPanel: dockDetailPanel, + _dockedAuxBar: { layout: () => { } }, + workbenchGrid: { + getViewSize: view => viewSizes.get(view) ?? { width: 0, height: 0 }, + setViewVisible: (_view, visible) => visibilityChanges.push(visible), + resizeView: (view, size) => { + resizes.push(size); + viewSizes.set(view, size); + }, + }, + paneCompositeService: { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + getLastActivePaneCompositeId: () => undefined, + openPaneComposite: () => { }, + }, + viewDescriptorService: { + getDefaultViewContainer: () => undefined, + }, + resizes, + visibilityChanges, + partVisibility: { sidebar: true, editor: true, auxiliaryBar: true }, + _dockedAuxiliaryBarWidth: 300, + _syncingDockedEditorVisibility: false, + _resizeDockedEditorAfterSidebarChange: resizeDockedEditorAfterSidebarChange, + _growDockedDetailAfterSidebarChange: growDockedDetailAfterSidebarChange, + layoutMobileSidebar: () => { }, + _savePartVisibility: () => { }, + }; + } + + function createEditorSplitHarness(sessionsWidth: number, overrides?: Partial, editorWidth = 0): IEditorSplitTestHarness { const editorPartView = {}; const sessionsPartView = {}; const resizes: { width: number; height: number }[] = []; const visibilityChanges: boolean[] = []; - const editorSize = { width: 0, height: 800 }; + const editorSize = { width: editorWidth, height: 800 }; return { editorPartView, sessionsPartView, mainContainer: { classList: { toggle: () => { } } }, + _dockDetailPanel: false, + _onDidChangePartVisibility: { fire: () => { } }, workbenchGrid: { - getViewSize: view => view === sessionsPartView ? { width: sessionsWidth, height: 800 } : editorSize, + getViewSize: view => view === sessionsPartView ? { width: sessionsWidth, height: 800 } : { ...editorSize }, setViewVisible: (_view, visible) => visibilityChanges.push(visible), resizeView: (_view, size) => { resizes.push(size); @@ -80,11 +213,15 @@ suite('Sessions - Workbench', () => { }, resizes, visibilityChanges, - partVisibility: { editor: false }, + partVisibility: { editor: false, auxiliaryBar: true }, _editorMaximized: false, _hasAppliedInitialEditorSplit: false, + _dockedAuxiliaryBarWidth: 300, + _syncingDockedEditorVisibility: false, + _syncDockedEditorVisibility: syncDockedEditorVisibility, setEditorMaximized: () => { }, _applyEditorSplitSize: applyEditorSplitSize, + handleContainerDidLayout: () => { }, _savePartVisibility: () => { }, ...overrides, }; @@ -108,6 +245,92 @@ suite('Sessions - Workbench', () => { }); }); + test('docked sidebar hide grows the editor by the freed sidebar width and show restores it', () => { + let layoutCount = 0; + const workbench = createSideBarResizeHarness(true); + (workbench as ISideBarResizeTestHarness & { _dockedAuxBar: { layout(): void } })._dockedAuxBar = { layout: () => layoutCount++ }; + + setSideBarHidden.call(workbench, true); + setSideBarHidden.call(workbench, false); + + assert.deepStrictEqual({ + sidebarVisible: workbench.partVisibility.sidebar, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + layoutCount, + snapshot: workbench._editorSizeGrownForSidebarHide, + }, { + sidebarVisible: true, + visibilityChanges: [false, true], + resizes: [ + { width: 900, height: 800 }, + { width: 620, height: 800 }, + ], + layoutCount: 2, + snapshot: undefined, + }); + }); + + test('standard layout sidebar hide does not grow the editor', () => { + const workbench = createSideBarResizeHarness(false); + + setSideBarHidden.call(workbench, true); + + assert.deepStrictEqual({ + sidebarVisible: workbench.partVisibility.sidebar, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + }, { + sidebarVisible: false, + visibilityChanges: [false], + resizes: [], + }); + }); + + test('docked sidebar hide grows the detail panel (not the editor node) when the editor is hidden and show restores it', () => { + let layoutCount = 0; + const workbench = createSideBarResizeHarness(true); + workbench.partVisibility.editor = false; + workbench._dockedAuxiliaryBarWidth = 300; + (workbench as ISideBarResizeTestHarness & { _dockedAuxBar: { layout(): void } })._dockedAuxBar = { layout: () => layoutCount++ }; + + setSideBarHidden.call(workbench, true); + const afterHide = { + editorVisible: workbench.partVisibility.editor, + detailWidth: workbench._dockedAuxiliaryBarWidth, + resizes: [...workbench.resizes], + detailSnapshot: workbench._detailWidthGrownForSidebarHide, + editorSnapshot: workbench._editorSizeGrownForSidebarHide, + }; + + setSideBarHidden.call(workbench, false); + + assert.deepStrictEqual({ + afterHide, + editorVisible: workbench.partVisibility.editor, + detailWidth: workbench._dockedAuxiliaryBarWidth, + resizes: workbench.resizes, + detailSnapshot: workbench._detailWidthGrownForSidebarHide, + layoutCount, + }, { + afterHide: { + editorVisible: false, + detailWidth: 580, + resizes: [{ width: 580, height: 800 }], + detailSnapshot: 300, + editorSnapshot: undefined, + }, + editorVisible: false, + detailWidth: 300, + resizes: [ + { width: 580, height: 800 }, + { width: 300, height: 800 }, + ], + detailSnapshot: undefined, + layoutCount: 2, + }); + }); + test('does not re-apply the even split on later editor reveals', () => { const workbench = createEditorSplitHarness(1000, { _hasAppliedInitialEditorSplit: true }); @@ -132,6 +355,607 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual(workbench.resizes, [{ width: 300, height: 800 }]); }); + test('relayouts the docked detail panel when the editor visibility changes', () => { + let layoutCount = 0; + const workbench = createEditorSplitHarness(1000, { _hasAppliedInitialEditorSplit: true }); + (workbench as IEditorSplitTestHarness & { _dockDetailPanel: boolean })._dockDetailPanel = true; + (workbench as IEditorSplitTestHarness & { _dockedAuxBar: { layout(): void } })._dockedAuxBar = { layout: () => layoutCount++ }; + + setEditorHidden.call(workbench, false); + setEditorHidden.call(workbench, true); + + assert.deepStrictEqual({ + layoutCount, + visibilityChanges: workbench.visibilityChanges, + }, { + layoutCount: 2, + visibilityChanges: [true, true], + }); + }); + + test('fires editor visibility changes when docked editor content is hidden or shown', () => { + const events: IPartVisibilityChangeEvent[] = []; + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + }); + + setEditorHidden.call(workbench, true); + setEditorHidden.call(workbench, false); + + assert.deepStrictEqual(events, [ + { partId: Parts.EDITOR_PART, visible: false }, + { partId: Parts.EDITOR_PART, visible: true }, + ]); + }); + + test('shrinks the docked editor node to the detail width when hiding the editor', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 320, + }, 900); + + setEditorHidden.call(workbench, true); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + }, { + editorVisible: false, + visibilityChanges: [true], + resizes: [{ width: 320, height: 800 }], + }); + }); + + test('clears stale sidebar-grow snapshots when hiding the editor with the detail visible', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 320, + // Captured while the editor was visible and the sessions list was hidden. + _editorSizeGrownForSidebarHide: { width: 900, height: 800 }, + _detailWidthGrownForSidebarHide: 500, + }, 900); + + setEditorHidden.call(workbench, true); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + resizes: workbench.resizes, + editorSizeGrownForSidebarHide: workbench._editorSizeGrownForSidebarHide, + detailWidthGrownForSidebarHide: workbench._detailWidthGrownForSidebarHide, + }, { + editorVisible: false, + resizes: [{ width: 320, height: 800 }], + editorSizeGrownForSidebarHide: undefined, + detailWidthGrownForSidebarHide: undefined, + }); + }); + + test('[Scenario 5] does not reveal a hidden editor when the managed empty Files tab is activated', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + // Closing the Changes tab activates the managed empty Files placeholder. + handleWillOpenEditor.call(harness, { groupId: 1, editor: { typeId: 'workbench.editors.agentSessions.emptyFile' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, []); + }); + + test('[Scenario 5] reveals a hidden editor when a real editor is opened', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + handleWillOpenEditor.call(harness, { groupId: 1, editor: { typeId: 'workbench.editors.files.fileEditorInput' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, [{ hidden: false, explicit: true }]); + }); + + test('[Scenario 5] does not reveal when the open targets a non-main-part group', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + handleWillOpenEditor.call(harness, { groupId: 99, editor: { typeId: 'workbench.editors.files.fileEditorInput' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, []); + }); + + test('suppresses docked editor reveal sync while hiding the editor', () => { + const events: IPartVisibilityChangeEvent[] = []; + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 320, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + }, 900); + const setViewVisible = workbench.workbenchGrid.setViewVisible; + workbench.workbenchGrid.setViewVisible = (view, visible) => { + setViewVisible(view, visible); + handleDockedEditorPartLayout.call(workbench, 900); + }; + + setEditorHidden.call(workbench, true); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + syncing: workbench._syncingDockedEditorVisibility, + events, + resizes: workbench.resizes, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + editorVisible: false, + syncing: false, + events: [{ partId: Parts.EDITOR_PART, visible: false }], + resizes: [{ width: 320, height: 800 }], + snapshot: { width: 900, height: 800 }, + }); + }); + + test('restores the docked editor node size when showing after hide', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 320, + }, 900); + + setEditorHidden.call(workbench, true); + setEditorHidden.call(workbench, false); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + visibilityChanges: [true, true], + resizes: [ + { width: 320, height: 800 }, + { width: 900, height: 800 }, + ], + snapshot: undefined, + }); + }); + + test('applies an even split when revealing the docked editor with no captured width even after the initial split', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: false, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedAuxBar: { layout: () => { } }, + }); + + setEditorHidden.call(workbench, false); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + }, { + editorVisible: true, + visibilityChanges: [true], + resizes: [{ width: 500, height: 800 }], + }); + }); + + test('restores a captured docked editor width instead of applying an even split', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: false, auxiliaryBar: true }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedEditorSizeBeforeHide: { width: 720, height: 800 }, + _dockedAuxBar: { layout: () => { } }, + }); + + setEditorHidden.call(workbench, false); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + visibilityChanges: workbench.visibilityChanges, + resizes: workbench.resizes, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + visibilityChanges: [true], + resizes: [{ width: 720, height: 800 }], + snapshot: undefined, + }); + }); + + test('reopening the whole side pane while the sidebar is collapsed even-splits instead of restoring a cramped width', () => { + // Simulates toggle-close order (auxiliary bar already hidden, editor about + // to hide) while the sidebar is collapsed: the editor grid node collapses to + // a tiny width and a stale sidebar-grow snapshot is present. Closing must not + // capture the collapsed width, and must clear the stale snapshots so the + // reopen applies a comfortable even split of the wide main area. + const workbench = createEditorSplitHarness(1360, { + partVisibility: { editor: true, auxiliaryBar: false }, + _hasAppliedInitialEditorSplit: true, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _editorSizeGrownForSidebarHide: { width: 620, height: 800 }, + _detailWidthGrownForSidebarHide: 300, + _dockedAuxBar: { layout: () => { } }, + }, 40); + + setEditorHidden.call(workbench, true); + const afterClose = { + snapshot: workbench._dockedEditorSizeBeforeHide, + grownEditor: workbench._editorSizeGrownForSidebarHide, + grownDetail: workbench._detailWidthGrownForSidebarHide, + resizes: [...workbench.resizes], + }; + + setEditorHidden.call(workbench, false); + + assert.deepStrictEqual({ + afterClose, + editorVisible: workbench.partVisibility.editor, + resizes: workbench.resizes, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + afterClose: { + snapshot: undefined, + grownEditor: undefined, + grownDetail: undefined, + resizes: [], + }, + editorVisible: true, + resizes: [{ width: 680, height: 800 }], + snapshot: undefined, + }); + }); + + test('marks docked editor visible when grid sash reveals editor content', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const classToggles: { name: string; force: boolean }[] = []; + const workbench = createEditorSplitHarness(1000, { + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedEditorSizeBeforeHide: { width: 900, height: 800 }, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + mainContainer: { classList: { toggle: (name, force) => classToggles.push({ name, force }) } }, + _savePartVisibility: () => { saveCount++; }, + }, 305); + + syncDockedEditorVisibilityFromGrid.call(workbench); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + classToggles, + resizes: workbench.resizes, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + events: [{ partId: Parts.EDITOR_PART, visible: true }], + layoutCount: 1, + saveCount: 1, + classToggles: [{ name: 'nomaineditorarea', force: false }], + resizes: [], + snapshot: undefined, + }); + }); + + test('marks docked editor visible from editor part layout width', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const workbench = createEditorSplitHarness(1000, { + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedEditorSizeBeforeHide: { width: 900, height: 800 }, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + _savePartVisibility: () => { saveCount++; }, + }, 300); + + handleDockedEditorPartLayout.call(workbench, 305); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + snapshot: workbench._dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + events: [{ partId: Parts.EDITOR_PART, visible: true }], + layoutCount: 1, + saveCount: 1, + snapshot: undefined, + }); + }); + + test('keeps docked editor hidden when editor part layout width leaves only detail width', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const workbench = createEditorSplitHarness(1000, { + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + _savePartVisibility: () => { saveCount++; }, + }, 300); + + handleDockedEditorPartLayout.call(workbench, 304); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('keeps docked editor hidden when grid sash leaves only detail width', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const workbench = createEditorSplitHarness(1000, { + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + _savePartVisibility: () => { saveCount++; }, + }, 300); + + syncDockedEditorVisibilityFromGrid.call(workbench); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('hides docked editor when sash squeezes node down to detail width', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const classToggles: { name: string; force: boolean }[] = []; + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + mainContainer: { classList: { toggle: (name, force) => classToggles.push({ name, force }) } }, + _savePartVisibility: () => { saveCount++; }, + }, 600); + + handleDockedEditorPartLayout.call(workbench, 304); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + classToggles, + }, { + editorVisible: false, + events: [{ partId: Parts.EDITOR_PART, visible: false }], + layoutCount: 1, + saveCount: 1, + classToggles: [{ name: 'nomaineditorarea', force: true }], + }); + }); + + test('does not hide docked editor when node is squeezed but detail is also hidden', () => { + const events: IPartVisibilityChangeEvent[] = []; + let layoutCount = 0; + let saveCount = 0; + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: false }, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _dockedAuxBar: { layout: () => layoutCount++ }, + _onDidChangePartVisibility: { fire: e => events.push(e) }, + _savePartVisibility: () => { saveCount++; }, + }, 600); + + handleDockedEditorPartLayout.call(workbench, 304); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + events, + layoutCount, + saveCount, + }, { + editorVisible: true, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('clears stale snapshots and explicit-reveal flag when sash-collapse hides the editor', () => { + const workbench = createEditorSplitHarness(1000, { + partVisibility: { editor: true, auxiliaryBar: true }, + _dockDetailPanel: true, + _dockedAuxiliaryBarWidth: 300, + _editorSizeGrownForSidebarHide: { width: 800, height: 600 }, + _detailWidthGrownForSidebarHide: 400, + _editorRevealedExplicitly: true, + _dockedAuxBar: { layout: () => { } }, + _savePartVisibility: () => { }, + }, 600); + + handleDockedEditorPartLayout.call(workbench, 300); + + assert.deepStrictEqual({ + editorVisible: workbench.partVisibility.editor, + editorSizeGrownForSidebarHide: workbench._editorSizeGrownForSidebarHide, + detailWidthGrownForSidebarHide: workbench._detailWidthGrownForSidebarHide, + editorRevealedExplicitly: workbench._editorRevealedExplicitly, + }, { + editorVisible: false, + editorSizeGrownForSidebarHide: undefined, + detailWidthGrownForSidebarHide: undefined, + editorRevealedExplicitly: false, + }); + }); + + test('fills the narrowed docked detail node when editor content is hidden', () => { + + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const layouts: { width: number; height: number; top: number; left: number }[] = []; + const insets: number[] = []; + const persistedWidths: number[] = []; + let editorVisible = true; + let editorWidth = 800; + + Object.defineProperty(editorContainer, 'clientWidth', { get: () => editorWidth }); + Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + editorContainer.getBoundingClientRect = () => ({ + width: editorWidth, + height: 600, + top: 0, + right: editorWidth, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: (width: number, height: number, top: number, left: number) => { + layouts.push({ width, height, top, left }); + }, + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: width => persistedWidths.push(width), + isEditorAreaVisible: () => true, + isEditorVisible: () => editorVisible, + isAuxiliaryBarVisible: () => true, + setEditorContentRightInset: px => insets.push(px), + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + editorWidth = 260; + editorVisible = false; + controller.layout(); + + const sash = Reflect.get(controller, '_sash') as { state: SashState } | undefined; + assert.deepStrictEqual({ + insets, + persistedWidths, + layouts, + style: { + top: auxiliaryBarContainer.style.top, + right: auxiliaryBarContainer.style.right, + width: auxiliaryBarContainer.style.width, + height: auxiliaryBarContainer.style.height, + }, + sashState: sash?.state, + }, { + insets: [260, 260], + persistedWidths: [], + layouts: [ + { width: 260, height: 566, top: 34, left: 540 }, + { width: 260, height: 566, top: 34, left: 0 }, + ], + style: { + top: '34px', + right: '0px', + width: '260px', + height: '566px', + }, + sashState: SashState.Disabled, + }); + + controller.dispose(); + }); + + test('uses persisted docked detail width when editor content is visible', () => { + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const layouts: { width: number; height: number; top: number; left: number }[] = []; + const insets: number[] = []; + + Object.defineProperty(editorContainer, 'clientWidth', { value: 800 }); + Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + editorContainer.getBoundingClientRect = () => ({ + width: 800, + height: 600, + top: 0, + right: 800, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: (width: number, height: number, top: number, left: number) => { + layouts.push({ width, height, top, left }); + }, + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: () => { }, + isEditorAreaVisible: () => true, + isEditorVisible: () => true, + isAuxiliaryBarVisible: () => true, + setEditorContentRightInset: px => insets.push(px), + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + + const sash = Reflect.get(controller, '_sash') as { state: SashState } | undefined; + assert.deepStrictEqual({ + insets, + layouts, + style: { + width: auxiliaryBarContainer.style.width, + height: auxiliaryBarContainer.style.height, + }, + sashState: sash?.state, + }, { + insets: [260], + layouts: [{ width: 260, height: 566, top: 34, left: 540 }], + style: { + width: '260px', + height: '566px', + }, + sashState: SashState.Enabled, + }); + + controller.dispose(); + }); + function createWorkbenchHarness(): IWorkbenchTestHarness { return { partVisibility: { @@ -149,14 +973,96 @@ suite('Sessions - Workbench', () => { storageService: { store: () => { }, }, + _editorPartAutoVisibilitySuppressionCount: 0, _editorMaximized: false, _restoreAttachedEditorMaximizedOnShow: false, setEditorMaximized: () => { }, setAuxiliaryBarHidden: () => { }, + setEditorHidden: () => { }, + suppressEditorPartAutoVisibility, + areAllGroupsInMainPartEmpty, + rememberAttachedEditorMaximizedState, _savePartVisibility: () => { }, }; } + test('docked last editor close hides the whole side pane under suppression', () => { + const editorHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const auxHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + editorGroupService: { mainPart: { groups: readonly { isEmpty: boolean }[] } }; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.editorGroupService = { mainPart: { groups: [{ isEmpty: true }] } }; + workbench.setEditorHidden = hidden => { + editorHiddenCalls.push({ hidden, suppression: workbench._editorPartAutoVisibilitySuppressionCount }); + workbench.partVisibility.editor = !hidden; + }; + workbench.setAuxiliaryBarHidden = hidden => { + auxHiddenCalls.push({ hidden, suppression: workbench._editorPartAutoVisibilitySuppressionCount }); + if (hidden && !workbench.partVisibility.editor && workbench._editorPartAutoVisibilitySuppressionCount === 0) { + workbench.setEditorHidden(false); + } + workbench.partVisibility.auxiliaryBar = !hidden; + }; + + handleDidCloseEditor.call(workbench); + + assert.deepStrictEqual({ + editorHiddenCalls, + auxHiddenCalls, + visibility: workbench.partVisibility, + suppression: workbench._editorPartAutoVisibilitySuppressionCount, + }, { + editorHiddenCalls: [{ hidden: true, suppression: 1 }], + auxHiddenCalls: [{ hidden: true, suppression: 1 }], + visibility: { + sidebar: true, + auxiliaryBar: false, + editor: false, + panel: false, + sessions: true, + }, + suppression: 0, + }); + }); + + test('docked last editor close hides lingering detail when editor is already hidden', () => { + const editorHiddenCalls: boolean[] = []; + const auxHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + editorGroupService: { mainPart: { groups: readonly { isEmpty: boolean }[] } }; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.partVisibility.editor = false; + workbench.editorGroupService = { mainPart: { groups: [{ isEmpty: true }] } }; + workbench.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + workbench.partVisibility.editor = !hidden; + }; + workbench.setAuxiliaryBarHidden = hidden => { + auxHiddenCalls.push({ hidden, suppression: workbench._editorPartAutoVisibilitySuppressionCount }); + if (hidden && !workbench.partVisibility.editor && workbench._editorPartAutoVisibilitySuppressionCount === 0) { + workbench.setEditorHidden(false); + } + workbench.partVisibility.auxiliaryBar = !hidden; + }; + + handleDidCloseEditor.call(workbench); + + assert.deepStrictEqual({ + editorHiddenCalls, + auxHiddenCalls, + editorVisible: workbench.partVisibility.editor, + auxiliaryBarVisible: workbench.partVisibility.auxiliaryBar, + }, { + editorHiddenCalls: [], + auxHiddenCalls: [{ hidden: true, suppression: 1 }], + editorVisible: false, + auxiliaryBarVisible: false, + }); + }); + test('restores attached editor maximized state when the auxiliary bar stays visible', () => { const maximizedStates: boolean[] = []; const workbench = createWorkbenchHarness(); @@ -233,6 +1139,203 @@ suite('Sessions - Workbench', () => { assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); }); + test('docked auxiliary bar hide reveals hidden editor content', () => { + const editorHiddenCalls: boolean[] = []; + const gridVisible: boolean[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + mainContainer: { classList: { toggle(): void } }; + workbenchGrid: { setViewVisible(_view: object, visible: boolean): void }; + editorPartView: {}; + paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; + viewDescriptorService: { getDefaultViewContainer(): undefined }; + _onDidChangePartVisibility: { fire(_event: IPartVisibilityChangeEvent): void }; + handleContainerDidLayout(): void; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.partVisibility.editor = false; + workbench.partVisibility.auxiliaryBar = true; + workbench.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + workbench.partVisibility.editor = !hidden; + }; + workbench.mainContainer = { classList: { toggle: () => { } } }; + workbench.workbenchGrid = { setViewVisible: (_view, visible) => { gridVisible.push(visible); } }; + workbench.editorPartView = {}; + workbench.paneCompositeService = { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + openPaneComposite: () => { }, + getLastActivePaneCompositeId: () => undefined, + }; + workbench.viewDescriptorService = { + getDefaultViewContainer: () => undefined, + }; + workbench._onDidChangePartVisibility = { fire: () => { } }; + workbench.handleContainerDidLayout = () => { }; + + setAuxiliaryBarHidden.call(workbench, true); + + assert.deepStrictEqual({ + editorHiddenCalls, + editorVisible: workbench.partVisibility.editor, + auxiliaryBarVisible: workbench.partVisibility.auxiliaryBar, + gridVisible, + }, { + editorHiddenCalls: [false], + editorVisible: true, + auxiliaryBarVisible: false, + gridVisible: [true], + }); + }); + + test('docked auxiliary bar hide does not reveal editor while side pane toggle is suppressed', () => { + const editorHiddenCalls: boolean[] = []; + const gridVisible: boolean[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + mainContainer: { classList: { toggle(): void } }; + workbenchGrid: { setViewVisible(_view: object, visible: boolean): void }; + editorPartView: {}; + paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; + viewDescriptorService: { getDefaultViewContainer(): undefined }; + _onDidChangePartVisibility: { fire(_event: IPartVisibilityChangeEvent): void }; + handleContainerDidLayout(): void; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.partVisibility.editor = false; + workbench.partVisibility.auxiliaryBar = true; + workbench._editorPartAutoVisibilitySuppressionCount = 1; + workbench.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + workbench.partVisibility.editor = !hidden; + }; + workbench.mainContainer = { classList: { toggle: () => { } } }; + workbench.workbenchGrid = { setViewVisible: (_view, visible) => { gridVisible.push(visible); } }; + workbench.editorPartView = {}; + workbench.paneCompositeService = { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + openPaneComposite: () => { }, + getLastActivePaneCompositeId: () => undefined, + }; + workbench.viewDescriptorService = { + getDefaultViewContainer: () => undefined, + }; + workbench._onDidChangePartVisibility = { fire: () => { } }; + workbench.handleContainerDidLayout = () => { }; + + setAuxiliaryBarHidden.call(workbench, true); + + assert.deepStrictEqual({ + editorHiddenCalls, + editorVisible: workbench.partVisibility.editor, + auxiliaryBarVisible: workbench.partVisibility.auxiliaryBar, + gridVisible, + }, { + editorHiddenCalls: [], + editorVisible: false, + auxiliaryBarVisible: false, + gridVisible: [false], + }); + }); + + test('docked auxiliary bar show does not force-open an empty (gated-off) container', () => { + const openedContainers: string[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + mainContainer: { classList: { toggle(): void } }; + workbenchGrid: { setViewVisible(_view: object, visible: boolean): void }; + editorPartView: {}; + paneCompositeService: { + getActivePaneComposite(): undefined; + hideActivePaneComposite(): void; + openPaneComposite(id: string): void; + getLastActivePaneCompositeId(): string | undefined; + }; + viewDescriptorService: { + getDefaultViewContainer(): { id: string }; + getViewContainerById(id: string): { hideIfEmpty: boolean } | null; + getViewContainerModel(container: object): { activeViewDescriptors: readonly object[] }; + }; + _onDidChangePartVisibility: { fire(_event: IPartVisibilityChangeEvent): void }; + handleContainerDidLayout(): void; + _isAuxViewContainerActive(containerId: string): boolean; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.partVisibility.editor = true; + workbench.partVisibility.auxiliaryBar = false; + workbench.mainContainer = { classList: { toggle: () => { } } }; + workbench.workbenchGrid = { setViewVisible: () => { } }; + workbench.editorPartView = {}; + workbench.paneCompositeService = { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + openPaneComposite: (id: string) => { openedContainers.push(id); }, + getLastActivePaneCompositeId: () => undefined, + }; + // The resolved default container is `hideIfEmpty` with no active views + // (e.g. Changes/Files gated off for a workspace-less quick chat). + workbench.viewDescriptorService = { + getDefaultViewContainer: () => ({ id: 'empty.container' }), + getViewContainerById: () => ({ hideIfEmpty: true }), + getViewContainerModel: () => ({ activeViewDescriptors: [] }), + }; + workbench._onDidChangePartVisibility = { fire: () => { } }; + workbench.handleContainerDidLayout = () => { }; + workbench._isAuxViewContainerActive = isAuxViewContainerActive; + + setAuxiliaryBarHidden.call(workbench, false); + + assert.deepStrictEqual(openedContainers, [], 'must not force-open an empty container in docked mode'); + }); + + test('docked auxiliary bar show opens a container that has active views', () => { + const openedContainers: string[] = []; + const workbench = createWorkbenchHarness() as IWorkbenchTestHarness & { + mainContainer: { classList: { toggle(): void } }; + workbenchGrid: { setViewVisible(_view: object, visible: boolean): void }; + editorPartView: {}; + paneCompositeService: { + getActivePaneComposite(): undefined; + hideActivePaneComposite(): void; + openPaneComposite(id: string): void; + getLastActivePaneCompositeId(): string | undefined; + }; + viewDescriptorService: { + getDefaultViewContainer(): { id: string }; + getViewContainerById(id: string): { hideIfEmpty: boolean } | null; + getViewContainerModel(container: object): { activeViewDescriptors: readonly object[] }; + }; + _onDidChangePartVisibility: { fire(_event: IPartVisibilityChangeEvent): void }; + handleContainerDidLayout(): void; + _isAuxViewContainerActive(containerId: string): boolean; + }; + Object.defineProperty(workbench, '_dockDetailPanel', { get: () => true }); + workbench.partVisibility.editor = true; + workbench.partVisibility.auxiliaryBar = false; + workbench.mainContainer = { classList: { toggle: () => { } } }; + workbench.workbenchGrid = { setViewVisible: () => { } }; + workbench.editorPartView = {}; + workbench.paneCompositeService = { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + openPaneComposite: (id: string) => { openedContainers.push(id); }, + getLastActivePaneCompositeId: () => undefined, + }; + // The resolved default container has an active view descriptor, so it has + // content to render and must be opened normally. + workbench.viewDescriptorService = { + getDefaultViewContainer: () => ({ id: 'active.container' }), + getViewContainerById: () => ({ hideIfEmpty: true }), + getViewContainerModel: () => ({ activeViewDescriptors: [{}] }), + }; + workbench._onDidChangePartVisibility = { fire: () => { } }; + workbench.handleContainerDidLayout = () => { }; + workbench._isAuxViewContainerActive = isAuxViewContainerActive; + + setAuxiliaryBarHidden.call(workbench, false); + + assert.deepStrictEqual(openedContainers, ['active.container'], 'must open a container that has active views'); + }); + interface IMaximizeTestHarness { partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; readonly editorPartView: object; diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 667ead9edf5..72e7f300878 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -16,6 +16,7 @@ import { StatusBarItemsExtensionPoint } from './statusBarExtensionPoint.js'; import { CSSExtensionPoint } from '../../services/themes/browser/cssExtensionPoint.js'; // --- mainThread participants +import './mainThreadAgentEditorComments.js'; import './mainThreadLocalization.js'; import './mainThreadBulkEdits.js'; import './mainThreadLanguageModels.js'; diff --git a/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts b/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts new file mode 100644 index 00000000000..f6c358c5a95 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { IRange } from '../../../editor/common/core/range.js'; +import { IAgentEditorCommentsBridge } from '../../services/agentEditorComments/common/agentEditorComments.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostAgentEditorCommentsShape, ExtHostContext, IAgentEditorCommentDto, MainContext, MainThreadAgentEditorCommentsShape } from '../common/extHost.protocol.js'; + +/** + * Bridges the {@link IAgentEditorCommentsBridge} (backed, in the Agents window, + * by the same store the code editor renders its session comments from) to the + * extension host, so custom editors (e.g. the Markdown editor) can render and + * contribute the same comments. Registered in every extension host; when no + * provider is installed (e.g. the regular workbench window) the bridge is a + * no-op and this customer simply reports no comments. + */ +@extHostNamedCustomer(MainContext.MainThreadAgentEditorComments) +export class MainThreadAgentEditorComments implements MainThreadAgentEditorCommentsShape { + + private readonly _proxy: ExtHostAgentEditorCommentsShape; + private readonly _resources = new Map(); + private readonly _disposables = new DisposableMap(); + + constructor( + extHostContext: IExtHostContext, + @IAgentEditorCommentsBridge private readonly _bridge: IAgentEditorCommentsBridge, + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAgentEditorComments); + } + + async $createAgentEditorComments(handle: number, uri: UriComponents): Promise { + const resource = URI.revive(uri); + this._resources.set(handle, resource); + + const store = new DisposableStore(); + store.add(this._bridge.onDidChangeComments(() => this._sendComments(handle))); + this._disposables.set(handle, store); + + this._sendComments(handle); + } + + async $addComment(handle: number, range: IRange, body: string): Promise { + const resource = this._resources.get(handle); + if (!resource) { + return; + } + this._bridge.addComment(resource, range, body); + } + + async $disposeAgentEditorComments(handle: number): Promise { + this._resources.delete(handle); + this._disposables.deleteAndDispose(handle); + } + + private _sendComments(handle: number): void { + const resource = this._resources.get(handle); + if (!resource) { + return; + } + const comments: IAgentEditorCommentDto[] = this._bridge.getComments(resource).map(comment => ({ id: comment.id, range: comment.range, body: comment.body })); + this._proxy.$acceptAgentEditorComments(handle, comments); + } + + dispose(): void { + this._disposables.dispose(); + this._resources.clear(); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 1faa3281d25..a42e2afdce8 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -47,7 +47,7 @@ import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatS import { NotebookDto } from './mainThreadNotebookDto.js'; import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { AICustomizationManagementSection } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -809,7 +809,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return folders.map(folder => ({ uri: URI.revive(folder.uri), label: folder.label, - source: folder.source, })); }, }; @@ -842,6 +841,11 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA label: metadata.label, icon: metadata.iconId ? ThemeIcon.fromId(metadata.iconId) : ThemeIcon.fromId(Codicon.extensions.id), hiddenSections, + getStorageSourceFilter: () => ({ + // Extension-provided harnesses manage their own items via the provider, + // so we show all sources for storage-filter-based flows. + sources: AICustomizationSources.all + }), itemProvider, }; diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 34d2fd7a4ca..684f1e4d069 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -89,6 +89,7 @@ import { IExtHostOutputService } from './extHostOutput.js'; import { ExtHostProfileContentHandlers } from './extHostProfileContentHandler.js'; import { IExtHostProgress } from './extHostProgress.js'; import { ExtHostQuickDiff } from './extHostQuickDiff.js'; +import { ExtHostAgentEditorComments } from './extHostAgentEditorComments.js'; import { createExtHostQuickOpen } from './extHostQuickOpen.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { ExtHostSCM } from './extHostSCM.js'; @@ -229,6 +230,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostQuickOpen = rpcProtocol.set(ExtHostContext.ExtHostQuickOpen, createExtHostQuickOpen(rpcProtocol, extHostWorkspace, extHostCommands)); const extHostSCM = rpcProtocol.set(ExtHostContext.ExtHostSCM, new ExtHostSCM(rpcProtocol, extHostCommands, extHostDocuments, extHostLogService)); const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, extHostDocuments, uriTransformer)); + const extHostAgentEditorComments = rpcProtocol.set(ExtHostContext.ExtHostAgentEditorComments, new ExtHostAgentEditorComments(rpcProtocol)); const extHostShare = rpcProtocol.set(ExtHostContext.ExtHostShare, new ExtHostShare(rpcProtocol, uriTransformer)); const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, createExtHostComments(rpcProtocol, extHostCommands, extHostDocuments)); const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); @@ -1057,6 +1059,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'textEditorDiffInformation'); return extHostQuickDiff.createSourceControlDiffInformation(uri); }, + createAgentEditorComments(uri: vscode.Uri): vscode.AgentEditorCommentsProvider { + checkProposedApiEnabled(extension, 'agentEditorComments'); + return extHostAgentEditorComments.createAgentEditorComments(uri); + }, get tabGroups(): vscode.TabGroups { return extHostEditorTabs.tabGroups; }, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b2dbb67ab64..ca682429a40 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2199,6 +2199,23 @@ export interface MainThreadQuickDiffShape extends IDisposable { $disposeSourceControlDiffInformation(handle: number): Promise; } +export interface IAgentEditorCommentDto { + id: string; + range: IRange; + body: string; + author?: string; +} + +export interface MainThreadAgentEditorCommentsShape extends IDisposable { + $createAgentEditorComments(handle: number, uri: UriComponents): Promise; + $addComment(handle: number, range: IRange, body: string): Promise; + $disposeAgentEditorComments(handle: number): Promise; +} + +export interface ExtHostAgentEditorCommentsShape { + $acceptAgentEditorComments(handle: number, comments: IAgentEditorCommentDto[]): void; +} + export interface IDocumentDiffLineChangeDto { originalRange: IRange; modifiedRange: IRange; @@ -4042,6 +4059,7 @@ export const MainContext = { MainThreadOutputService: createProxyIdentifier('MainThreadOutputService'), MainThreadProgress: createProxyIdentifier('MainThreadProgress'), MainThreadQuickDiff: createProxyIdentifier('MainThreadQuickDiff'), + MainThreadAgentEditorComments: createProxyIdentifier('MainThreadAgentEditorComments'), MainThreadDocumentDiff: createProxyIdentifier('MainThreadDocumentDiff'), MainThreadQuickOpen: createProxyIdentifier('MainThreadQuickOpen'), MainThreadStatusBar: createProxyIdentifier('MainThreadStatusBar'), @@ -4118,6 +4136,7 @@ export const ExtHostContext = { ExtHostLanguageFeatures: createProxyIdentifier('ExtHostLanguageFeatures'), ExtHostQuickOpen: createProxyIdentifier('ExtHostQuickOpen'), ExtHostQuickDiff: createProxyIdentifier('ExtHostQuickDiff'), + ExtHostAgentEditorComments: createProxyIdentifier('ExtHostAgentEditorComments'), ExtHostStatusBar: createProxyIdentifier('ExtHostStatusBar'), ExtHostShare: createProxyIdentifier('ExtHostShare'), ExtHostExtensionService: createProxyIdentifier('ExtHostExtensionService'), diff --git a/src/vs/workbench/api/common/extHostAgentEditorComments.ts b/src/vs/workbench/api/common/extHostAgentEditorComments.ts new file mode 100644 index 00000000000..2bd556cf9b0 --- /dev/null +++ b/src/vs/workbench/api/common/extHostAgentEditorComments.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { Emitter } from '../../../base/common/event.js'; +import { ExtHostAgentEditorCommentsShape, IAgentEditorCommentDto, IMainContext, MainContext, MainThreadAgentEditorCommentsShape } from './extHost.protocol.js'; +import * as typeConvert from './extHostTypeConverters.js'; + +class ExtHostAgentEditorCommentsProvider implements vscode.AgentEditorCommentsProvider { + + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + private _comments: readonly vscode.AgentEditorComment[] = []; + get comments(): readonly vscode.AgentEditorComment[] { return this._comments; } + + constructor( + private readonly handle: number, + private readonly proxy: MainThreadAgentEditorCommentsShape, + private readonly onDispose: (handle: number) => void + ) { } + + $acceptComments(comments: IAgentEditorCommentDto[]): void { + this._comments = comments.map(comment => Object.freeze({ + id: comment.id, + range: typeConvert.Range.to(comment.range), + body: comment.body, + author: comment.author, + } satisfies vscode.AgentEditorComment)); + this._onDidChange.fire(); + } + + addComment(range: vscode.Range, body: string): void { + this.proxy.$addComment(this.handle, typeConvert.Range.from(range), body); + } + + dispose(): void { + this.proxy.$disposeAgentEditorComments(this.handle); + this._onDidChange.dispose(); + this.onDispose(this.handle); + } +} + +export class ExtHostAgentEditorComments implements ExtHostAgentEditorCommentsShape { + private static handlePool = 0; + + private readonly proxy: MainThreadAgentEditorCommentsShape; + private readonly providers = new Map(); + + constructor(mainContext: IMainContext) { + this.proxy = mainContext.getProxy(MainContext.MainThreadAgentEditorComments); + } + + createAgentEditorComments(uri: vscode.Uri): vscode.AgentEditorCommentsProvider { + const handle = ExtHostAgentEditorComments.handlePool++; + const provider = new ExtHostAgentEditorCommentsProvider(handle, this.proxy, h => this.providers.delete(h)); + this.providers.set(handle, provider); + this.proxy.$createAgentEditorComments(handle, uri); + return provider; + } + + $acceptAgentEditorComments(handle: number, comments: IAgentEditorCommentDto[]): void { + this.providers.get(handle)?.$acceptComments(comments); + } +} diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 9b40580185c..6b72e8ec75f 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -112,10 +112,11 @@ export function connectProxyResolver( }, env: process.env, }; - const { resolveProxyWithRequest, resolveProxyURL } = createProxyResolver(params); + const { resolveProxyWithRequest, resolveProxyURL, resolveProxyByURL } = createProxyResolver(params); // eslint-disable-next-line local/code-no-any-casts const target = (proxyAgent as any).default || proxyAgent; target.resolveProxyURL = resolveProxyURL; + target.resolveProxyByURL = resolveProxyByURL; patchGlobalFetch(params, configProvider, mainThreadTelemetry, initData, resolveProxyURL, disposables); patchGlobalWebSocket(params, resolveProxyURL); diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index d92b36a5869..e78443892f0 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -156,10 +156,6 @@ padding-bottom: var(--vscode-spacing-size40); } -.monaco-workbench.floating-panels .activitybar > .content > .composite-bar { - margin-top: var(--vscode-spacing-size20); -} - /* * When the status bar is hidden every floating card sits directly against the window * bottom edge. Double the bottom margin (4px → 8px) to match the doubled outer gutter diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index e7b9278318f..0d2e7c4fcb9 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -217,6 +217,8 @@ class PartLayout { private static readonly HEADER_HEIGHT = 35; private static readonly TITLE_HEIGHT = 35; + // KEEP IN SYNC WITH: styleOverrides/browser/media/padding.css `.style-override .part > .title { height: 32px }` + private static readonly TITLE_HEIGHT_STYLE_OVERRIDE = 32; private static readonly Footer_HEIGHT = 35; private headerVisible: boolean = false; @@ -226,10 +228,16 @@ class PartLayout { layout(width: number, height: number): ILayoutContentResult { - // Title Size: Width (Fill), Height (Variable) + // Title Size: Width (Fill), Height (Variable). + // When the Modern UI style-override is active the title bar is 32 px + // (set in padding.css). Mirror that value here so the content area + // calculation stays in sync. Uses the same `.closest('.style-override')` + // check as EditorTabsControl.tabHeight. let titleSize: Dimension; if (this.options.hasTitle) { - titleSize = new Dimension(width, Math.min(height, PartLayout.TITLE_HEIGHT)); + const isStyleOverride = !!this.contentArea?.closest('.style-override'); + const titleHeight = isStyleOverride ? PartLayout.TITLE_HEIGHT_STYLE_OVERRIDE : PartLayout.TITLE_HEIGHT; + titleSize = new Dimension(width, Math.min(height, titleHeight)); } else { titleSize = Dimension.None; } diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 7b3e372a077..3f89436bc07 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -130,6 +130,14 @@ export class EditorGroupView extends Themable implements IEditorGroupView { private readonly editorContainer: HTMLElement; private readonly editorPane: EditorPanes; + /** + * Optional inset (in px) reserved on the right of the editor pane while the + * title control keeps the full group width. Used by the Agents window to dock + * the detail panel beside the editor content under one full-width tab bar. + * `0` (default) is a no-op for all other layouts. + */ + private _contentRightInset = 0; + private readonly disposedEditorsWorker = this._register(new RunOnceWorker(editors => this.handleDisposedEditors(editors), 0)); private readonly mapEditorToPendingConfirmation = new Map>(); @@ -2171,7 +2179,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.lastLayout = { width, height, top, left }; this.element.classList.toggle('max-height-478px', height <= 478); - // Layout the title control first to receive the size it occupies + // Layout the title control first to receive the size it occupies. The + // title always spans the full group width (so the tab strip and its + // toolbar can extend across any docked right inset). const titleControlSize = this.titleControl.layout({ container: new Dimension(width, height), available: new Dimension(width, height - this.editorPane.minimumHeight) @@ -2180,10 +2190,27 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Update progress bar location this.progressBar.getContainer().style.top = `${Math.max(this.titleHeight.offset - 2, 0)}px`; - // Pass the container width and remaining height to the editor layout + // The editor pane is inset on the right by `_contentRightInset` so a docked + // panel can sit beside it under the full-width title (0 = fill the group). + const contentWidth = Math.max(0, width - this._contentRightInset); const editorHeight = Math.max(0, height - titleControlSize.height); + this.editorContainer.style.width = `${contentWidth}px`; this.editorContainer.style.height = `${editorHeight}px`; - this.editorPane.layout({ width, height: editorHeight, top: top + titleControlSize.height, left }); + this.editorPane.layout({ width: contentWidth, height: editorHeight, top: top + titleControlSize.height, left }); + } + + /** + * Sets the right inset (px) reserved beside the editor pane while the title + * keeps the full group width, then relayouts. `0` restores the default + * full-width content. + */ + setContentRightInset(inset: number): void { + const next = Math.max(0, Math.round(inset)); + if (next === this._contentRightInset) { + return; + } + this._contentRightInset = next; + this.relayout(); } relayout(): void { diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 4cb9e60f6d7..ba39d236b99 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -240,6 +240,38 @@ export class EditorPart extends Part implements IEditorPart, private _contentDimension!: Dimension; get contentDimension(): Dimension { return this._contentDimension; } + private _contentRightInset = 0; + + /** + * Reserves an inset (px) on the right of the editor content of the group(s) at the + * right edge of the editor part, while the title stays full width, so a docked panel + * can sit beside the editor content under one full-width tab bar. Only the right-edge + * groups (no neighbor to the right) are inset; interior groups in a split layout keep + * full-width content. Recomputed when the group topology changes. `0` (default) + * restores full-width content for all groups. + */ + setContentRightInset(inset: number): void { + this._contentRightInset = Math.max(0, Math.round(inset)); + this.applyContentRightInset(); + } + + private applyContentRightInset(): void { + if (!this.gridWidget) { + return; + } + + for (const group of this.groupViews.values()) { + if (!(group instanceof EditorGroupView)) { + continue; + } + + // Only groups at the right edge of the editor part (no neighbor to the right) + // sit under the docked panel overlay; interior groups keep full-width content. + const atRightEdge = this._contentRightInset > 0 && this.gridWidget.getNeighborViews(group, Direction.Right).length === 0; + group.setContentRightInset(atRightEdge ? this._contentRightInset : 0); + } + } + private _activeGroup!: IEditorGroupView; get activeGroup(): IEditorGroupView { return this._activeGroup; @@ -1109,14 +1141,22 @@ export class EditorPart extends Part implements IEditorPart, this._register(this.onDidAddGroup(() => { updateContextKeys(); updateTopRightGroupContextKey(); + this.applyContentRightInset(); })); this._register(this.onDidRemoveGroup(() => { updateContextKeys(); updateTopRightGroupContextKey(); + this.applyContentRightInset(); + })); + this._register(this.onDidChangeGroupMaximized(() => { + updateContextKeys(); + this.applyContentRightInset(); })); - this._register(this.onDidChangeGroupMaximized(() => updateContextKeys())); this._register(this.onDidChangeEditorPartOptions(() => updateEditorTabsVisibleContext())); - this._register(this.onDidMoveGroup(() => updateTopRightGroupContextKey())); + this._register(this.onDidMoveGroup(() => { + updateTopRightGroupContextKey(); + this.applyContentRightInset(); + })); this._register(this.onDidLayout(() => updateTopRightGroupContextKey())); } diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 15eacbe4777..32da6539bb4 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -13,7 +13,7 @@ import { IAction, ActionRunner } from '../../../../base/common/actions.js'; import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -151,6 +151,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC @IThemeService themeService: IThemeService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, @IHostService private readonly hostService: IHostService, + @IMenuService protected readonly menuService: IMenuService, ) { super(themeService); diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index d57abaaf412..3756da20715 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -83,6 +83,22 @@ overflow: scroll !important; } +/* Add tab control */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab { + display: flex; + align-items: center; + flex: 0 0 auto; + height: var(--editor-group-tab-height); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab.hidden { + display: none; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab .action-label { + margin: 0 4px; +} + .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container { /* Enable wrapping via flex layout and dynamic height */ diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index f92b135359e..0fe89b57236 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -12,13 +12,16 @@ import { computeEditorAriaLabel } from '../../editor.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { EventType as TouchEventType, GestureEvent, Gesture } from '../../../../base/browser/touch.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { toAction } from '../../../../base/common/actions.js'; import { ResourceLabels, IResourceLabel, DEFAULT_LABELS_CONTAINER } from '../../labels.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { EditorCommandsContextActionRunner, EditorTabsControl } from './editorTabsControl.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IDisposable, dispose, DisposableStore, combinedDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -58,6 +61,8 @@ import { IReadonlyEditorGroupModel } from '../../../common/editor/editorGroupMod import { IHostService } from '../../../services/host/browser/host.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; interface IEditorInputLabel { readonly editor: EditorInput; @@ -109,6 +114,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { private tabsAndActionsContainer: HTMLElement | undefined; private tabsContainer: HTMLElement | undefined; private tabsScrollbar: ScrollableElement | undefined; + private addTabContainer: HTMLElement | undefined; private tabSizingFixedDisposables: DisposableStore | undefined; private readonly closeEditorAction = this._register(this.instantiationService.createInstance(CloseEditorTabAction, CloseEditorTabAction.ID, CloseEditorTabAction.LABEL)); @@ -152,8 +158,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { @ITreeViewsDnDService private readonly treeViewsDragAndDropService: ITreeViewsDnDService, @IEditorResolverService editorResolverService: IEditorResolverService, @IHostService hostService: IHostService, + @IMenuService menuService: IMenuService, ) { - super(parent, editorPartsView, groupsView, groupView, tabsModel, contextMenuService, instantiationService, contextKeyService, keybindingService, notificationService, quickInputService, themeService, editorResolverService, hostService); + super(parent, editorPartsView, groupsView, groupView, tabsModel, contextMenuService, instantiationService, contextKeyService, keybindingService, notificationService, quickInputService, themeService, editorResolverService, hostService, menuService); // Resolve the correct path library for the OS we are on // If we are connected to remote, this accounts for the @@ -190,6 +197,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Tabs Container listeners this.registerTabsContainerListeners(this.tabsContainer, this.tabsScrollbar); + // Create add tab control + this.createAddTabControl(); + // Create Editor Toolbar this.createEditorActionsToolBar(this.tabsAndActionsContainer, ['editor-actions']); @@ -199,6 +209,53 @@ export class MultiEditorTabsControl extends EditorTabsControl { return this.tabsAndActionsContainer; } + private createAddTabControl(): void { + const tabsContainer = assertReturnsDefined(this.tabsContainer); + const container = $('.tabs-bar-add-tab'); + tabsContainer.appendChild(container); + this.addTabContainer = container; + + const menu = this._register(this.menuService.createMenu(MenuId.EditorTabsBarAddTab, this.contextKeyService)); + const getActions = () => getFlatActionBarActions(menu.getActions({ shouldForwardArgs: true })); + + const addTabAction = toAction({ + id: 'editor.tabs.addTab', + label: localize('addTab', "Add Tab"), + class: ThemeIcon.asClassName(Codicon.add), + run: () => { } + }); + + const dropdown = this._register(new DropdownMenuActionViewItem(addTabAction, { getActions }, this.contextMenuService, { + classNames: ThemeIcon.asClassNameArray(Codicon.add) + })); + dropdown.render(container); + + const updateVisibility = () => this.addTabContainer?.classList.toggle('hidden', getActions().length === 0); + updateVisibility(); + this._register(menu.onDidChange(() => updateVisibility())); + } + + private get tabCount(): number { + const tabsContainer = assertReturnsDefined(this.tabsContainer); + return this.addTabContainer ? tabsContainer.children.length - 1 : tabsContainer.children.length; + } + + private appendTab(tab: HTMLElement, tabsContainer: HTMLElement): void { + if (this.addTabContainer) { + tabsContainer.insertBefore(tab, this.addTabContainer); + } else { + tabsContainer.appendChild(tab); + } + } + + private removeLastTab(tabsContainer: HTMLElement): void { + if (this.addTabContainer) { + this.addTabContainer.previousElementSibling?.remove(); + } else { + tabsContainer.lastChild?.remove(); + } + } + private createTabsScrollbar(scrollable: HTMLElement): ScrollableElement { const tabsScrollbar = this._register(new ScrollableElement(scrollable, { horizontal: this.getTabsScrollbarVisibility(), @@ -520,8 +577,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Create tabs as needed const [tabsContainer, tabsScrollbar] = assertReturnsAllDefined(this.tabsContainer, this.tabsScrollbar); - for (let i = tabsContainer.children.length; i < this.tabsModel.count; i++) { - tabsContainer.appendChild(this.createTab(i, tabsContainer, tabsScrollbar)); + for (let i = this.tabCount; i < this.tabsModel.count; i++) { + this.appendTab(this.createTab(i, tabsContainer, tabsScrollbar), tabsContainer); } // Make sure to recompute tab labels and detect @@ -607,10 +664,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Remove tabs that got closed const tabsContainer = assertReturnsDefined(this.tabsContainer); - while (tabsContainer.children.length > this.tabsModel.count) { + while (this.tabCount > this.tabsModel.count) { // Remove one tab from container (must be the last to keep indexes in order!) - tabsContainer.lastChild?.remove(); + this.removeLastTab(tabsContainer); // Remove associated tab label and widget dispose(this.tabDisposables.pop()); @@ -627,6 +684,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { else { if (this.tabsContainer) { clearNode(this.tabsContainer); + if (this.addTabContainer) { + this.tabsContainer.appendChild(this.addTabContainer); + } } this.tabDisposables = dispose(this.tabDisposables); @@ -1988,6 +2048,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { let currentTabsPosY: number | undefined = undefined; let lastTab: HTMLElement | undefined = undefined; for (const child of tabsContainer.children) { + if (child === this.addTabContainer) { + continue; + } const tab = child as HTMLElement; const tabPosY = tab.offsetTop; diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 7186f51ac36..aa900f30f6c 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -71,7 +71,8 @@ export const enum AccessibilityVerbositySettingId { SessionsChat = 'accessibility.verbosity.sessionsChat', SessionsChanges = 'accessibility.verbosity.sessionsChanges', ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', - Survey = 'accessibility.verbosity.survey' + Survey = 'accessibility.verbosity.survey', + Automations = 'accessibility.verbosity.automations' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -225,6 +226,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.survey', 'Provide information about how to navigate and interact with the survey editor pane.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.Automations]: { + description: localize('verbosity.automations', 'Provide information about how to use the Automations section of the Agent Customizations editor, including keyboard navigation and how to inspect scheduled runs.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 1bdde2a0696..7e5b2724f74 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -15,7 +15,7 @@ import { Disposable, IDisposable, toDisposable } from '../../../../base/common/l import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService, PreferredGroup, SIDE_GROUP, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from '../../../services/editor/common/editorService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; import { IEditorGroup, IEditorGroupsService, preferredSideBySideGroupDirection } from '../../../services/editor/common/editorGroupsService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -114,6 +114,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustEnablementService private readonly workspaceTrustEnablementService: IWorkspaceTrustEnablementService, @ILogService private readonly logService: ILogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @@ -494,6 +495,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV maxHistoryEntries: this.configurationService.getValue(BrowserMaxHistoryEntriesSettingId), proxyInfo: this._remoteProxyInfo, trustedFileRoots: this._getTrustedFileRoots(), + trustAllFiles: !this.workspaceTrustEnablementService.isWorkspaceTrustEnabled(), }); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts index 4236f0d1d91..fc39687ad06 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts @@ -15,6 +15,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { BrowserRemoteProxyEnabledSettingId } from '../browserViewWorkbenchService.js'; +import product from '../../../../../platform/product/common/product.js'; class BrowserRemoteIndicatorContribution extends BrowserEditorContribution { private readonly _container: HTMLElement; @@ -91,7 +92,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { [BrowserRemoteProxyEnabledSettingId]: { type: 'boolean', - default: false, + default: product.quality !== 'stable', tags: ['experimental'], scope: ConfigurationScope.WINDOW, experiment: { mode: 'startup' }, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index edde9a5f9bb..11bb749c50c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -498,7 +498,7 @@ right: 0; bottom: 0; background-image: none; - background-size: cover; + background-size: contain; background-repeat: no-repeat; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 6ff0c71b882..7a4f4381060 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -665,6 +665,41 @@ export class OpenWorkspacePickerAction extends Action2 { } } +/** + * Workspace picker chip for the automations dialog. Sits between the mode + * picker (order 1) and the model picker (order 3) in the primary chat input + * toolbar. Visible only when the hosting `ChatInputPart` was constructed with + * a `workspacePickerInput` and the dialog has set + * {@link ChatContextKeys.inAutomationsDialog} on its scoped context-key + * service. + */ +export class OpenAutomationsWorkspacePickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openAutomationsWorkspacePicker'; + + constructor() { + super({ + id: OpenAutomationsWorkspacePickerAction.ID, + title: localize2('interactive.openAutomationsWorkspacePicker.label', "Open Automations Workspace Picker"), + tooltip: localize('selectAutomationsWorkspace', "Select Workspace Folder"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + menu: [ + { + id: MenuId.ChatInput, + order: 2, + group: 'navigation', + when: ChatContextKeys.inAutomationsDialog, + }, + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + // The picker is opened via the action view item's trigger. + } +} + export class ChatSessionPrimaryPickerAction extends Action2 { static readonly ID = 'workbench.action.chat.chatSessionPrimaryPicker'; constructor() { @@ -1230,6 +1265,7 @@ export function registerChatExecuteActions(): DisposableStore { store.add(registerAction2(OpenSessionTargetPickerAction)); store.add(registerAction2(OpenDelegationPickerAction)); store.add(registerAction2(OpenWorkspacePickerAction)); + store.add(registerAction2(OpenAutomationsWorkspacePickerAction)); store.add(registerAction2(ChatSessionPrimaryPickerAction)); store.add(registerAction2(ChangeChatModelAction)); store.add(registerAction2(CancelEdit)); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index 8487c949628..4766e72e8b5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -81,8 +81,8 @@ class InstallFromSourceAction extends Action2 { const store = new DisposableStore(); const inputBox = store.add(quickInputService.createInputBox()); - inputBox.placeholder = localize('pluginSourcePlaceholder', "owner/repo or git clone URL"); - inputBox.prompt = localize('pluginSourcePrompt', "Enter a GitHub repository or git URL to install a plugin from"); + inputBox.placeholder = localize('pluginSourcePlaceholder', "owner/repo, git URL, or local folder path"); + inputBox.prompt = localize('pluginSourcePrompt', "Enter a GitHub repository, git URL, or local folder path to install a plugin from"); inputBox.ignoreFocusOut = true; inputBox.show(); @@ -122,7 +122,7 @@ class InstallFromSourceAction extends Action2 { // Hide the input box so it doesn't conflict with trust/progress dialogs. inputBox.hide(); - const result = await pluginInstallService.installPluginFromValidatedSource(source); + const result = await pluginInstallService.installPluginFromSource(source); if (!result.success) { if (result.message) { // Re-open with the error so the user can correct their input. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 6f21d2b131f..295a4fff915 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -159,8 +159,6 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } async provideSourceFolders(sessionResource: URI, type: PromptsType, _token: CancellationToken): Promise { - const workingDirectory = this._customAgentsService.getWorkingDirectory(sessionResource); - const folders: ICustomizationSourceFolder[] = []; for (const customization of this._customAgentsService.getCustomizations(sessionResource)) { if (!isDirectoryCustomization(customization) || !customization.writable) { @@ -169,11 +167,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto if (toPromptsType(customization.contents) !== type) { continue; } - const source = workingDirectory && customization.uri.startsWith(workingDirectory + '/') ? AICustomizationSources.local : AICustomizationSources.user; folders.push({ uri: this.toRemoteUri(customization.uri), label: customization.name, - source, }); } return folders; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 6f3743356da..83f0005b2fe 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -19,6 +19,7 @@ import { IAgentPluginService } from '../../../common/plugins/agentPluginService. import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../common/tools/languageModelToolsService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; @@ -82,6 +83,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo @IInstantiationService private readonly _instantiationService: IInstantiationService, @IFileService private readonly _fileService: IFileService, @IMcpService private readonly _mcpService: IMcpService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @IAgentHostToolSetEnablementService private readonly _toolSetEnablementService: IAgentHostToolSetEnablementService, ) { super(); @@ -101,7 +103,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const updateCustomizations = async () => { const seq = ++updateSeq; try { - const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, bundler, sessionType); + const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, this._configurationResolverService, bundler, sessionType); if (seq !== updateSeq) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 0ec64a45aa5..c3fb424b42c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -29,7 +29,7 @@ import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthent import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { AICustomizationManagementSection } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; @@ -251,6 +251,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr // The Tools section is surfaced for the Copilot CLI agent host only. hiddenSections: agent.provider === 'copilotcli' ? [AICustomizationManagementSection.Prompts] : [AICustomizationManagementSection.Tools, AICustomizationManagementSection.Prompts], hideGenerateButton: true, + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), syncProvider, itemProvider, })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 817f98ee448..aebc13acc41 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -16,7 +17,10 @@ import { type ICustomizationSyncProvider } from '../../../common/customizationHa import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../../common/enablement.js'; import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../mcp/common/discovery/pluginMcpDiscovery.js'; -import { IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; import type { ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { isDefined } from '../../../../../../base/common/types.js'; @@ -155,6 +159,53 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf } } +/** + * Attempts to resolve every configuration variable (`${workspaceFolder}`, + * `${env:…}`, …) in an MCP server config without any user interaction, using + * {@link IConfigurationResolverService.resolveAsync}. Returns the resolved + * config, or `undefined` when it cannot be fully resolved without prompting the + * user. + * + * The synced `.mcp.json` is launched by the agent host verbatim, so any + * variable the agent host can't itself expand must be resolved here up front. + * Variables requiring interaction (`${input:…}`, `${command:…}`) or context we + * don't have (e.g. `${workspaceFolder}` outside a folder) cause the server to + * be skipped. + */ +async function resolveConfigurationForSync( + configurationResolverService: IConfigurationResolverService, + folder: IWorkspaceFolderData | undefined, + configuration: IMcpServerConfiguration, +): Promise { + const expr = ConfigurationResolverExpression.parse(configuration); + + // Interactive variables (`${input:…}`, `${command:…}`) can only be resolved + // by prompting the user, so a server referencing them is skipped. This is + // checked up front because `resolveAsync` "resolves" them to their own + // literal text when no value mapping is supplied, which would otherwise + // leave them out of `unresolved()` below. + for (const replacement of expr.unresolved()) { + if (replacement.name === 'input' || replacement.name === 'command') { + return undefined; + } + } + + try { + // Resolves everything that can be resolved without interaction; throws + // when a variable requires context we don't have (e.g. no folder). + await configurationResolverService.resolveAsync(folder, expr); + } catch { + return undefined; + } + + // Any replacement left unresolved would require user interaction. + if (!Iterable.isEmpty(expr.unresolved())) { + return undefined; + } + + return expr.toObject(); +} + /** * Enumerates MCP servers configured directly in VS Code — i.e. those that * are not contributed by an agent plugin — so they can be bundled into the @@ -162,8 +213,15 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf * are already synced via their owning plugin's customization ref. Disabled * servers and servers whose launch cannot be expressed declaratively are * skipped. + * + * Workspace-discovered servers are also excluded by default: the agent host + * discovers workspace `.mcp.json` itself, so syncing them would duplicate. The + * exception is `.vscode/mcp.json`, which the agent host does not discover + * (despite what the SDK's `enableConfigDiscovery` docs imply) — those are + * synced, but only when their config can be resolved without requiring user + * interaction. */ -export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMcpServer[] { +export async function collectNonPluginMcpServers(mcpService: IMcpService, configurationResolverService: IConfigurationResolverService): Promise { const result: ISyncableMcpServer[] = []; for (const server of mcpService.servers.get()) { if (server.collection.id.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX)) { @@ -172,14 +230,27 @@ export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMc if (!isContributionEnabled(server.enablement.get())) { continue; } - const launch = server.readDefinitions().get().server?.launch; + const definitions = server.readDefinitions().get(); + const definition = definitions.server; + const launch = definition?.launch; if (!launch) { continue; } - const configuration = launchToMcpServerConfiguration(launch); + let configuration = launchToMcpServerConfiguration(launch); if (!configuration) { continue; } + const collection = definitions.collection; + if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection)) { + if (!McpCollectionDefinition.isVscodeMcpJson(collection)) { + continue; + } + const resolved = await resolveConfigurationForSync(configurationResolverService, definition.variableReplacement?.folder, configuration); + if (!resolved) { + continue; + } + configuration = resolved; + } result.push({ name: server.definition.label, configuration }); } return result; @@ -200,6 +271,7 @@ export async function resolveCustomizationRefs( syncProvider: ICustomizationSyncProvider, agentPluginService: IAgentPluginService, mcpService: IMcpService, + configurationResolverService: IConfigurationResolverService, bundler: SyncedCustomizationBundler, sessionType: string, ): Promise { @@ -272,7 +344,7 @@ export async function resolveCustomizationRefs( } const refs: Promise[] = [...pluginRefs.values()]; - const mcpServers = collectNonPluginMcpServers(mcpService); + const mcpServers = await collectNonPluginMcpServers(mcpService, configurationResolverService); if (looseFiles.length > 0 || mcpServers.length > 0) { refs.push(bundler.bundle(looseFiles, mcpServers).then(r => r?.ref)); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts index 1da734f7c3f..7e9e3cf30b4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, derived, IObservable, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { constObservable, derived, derivedOpts, IObservable, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -64,7 +65,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements // the summary stays empty (and self-hidden) for them. const sessionStateObs = this._subscribe(StateComponents.Session, constObservable(backendSession)); - const turnChangesetUriObs = derived(reader => { + const turnChangesetUriObs = derivedOpts({ equalsFn: isEqual }, reader => { const sessionState = sessionStateObs.read(reader).read(reader); if (!sessionState || sessionState instanceof Error) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 408f242b573..a80cf3e11c7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Delayer } from '../../../../../../base/common/async.js'; +import { Delayer, disposableTimeout } from '../../../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; @@ -14,7 +14,7 @@ import { Disposable, DisposableResourceMap, DisposableStore, IReference, Mutable import { ResourceMap } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { equals } from '../../../../../../base/common/objects.js'; -import { autorun, autorunPerKeyedItem, derived, derivedOpts, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, autorunPerKeyedItem, constObservable, derived, derivedOpts, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { Mutable } from '../../../../../../base/common/types.js'; @@ -60,7 +60,7 @@ import { type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; -import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; @@ -88,7 +88,7 @@ import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferen import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js'; export { toolDataToDefinition }; @@ -138,6 +138,7 @@ interface IObserveTurnOptions { readonly cancellationToken: CancellationToken; readonly adoptInvocations?: ReadonlyMap; readonly seedEmittedLengths?: ReadonlyMap; + readonly initialResponsePartCount?: number; readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void; /** @@ -858,6 +859,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const isNewSession = this._isNewSessionResource(sessionResource); const history: IChatSessionHistoryItem[] = []; let initialProgress: IChatProgress[] | undefined; + let initialResponsePartCount = 0; let activeTurnId: string | undefined; let sessionTitle: string | undefined; let draftInputState: ISerializableChatModelInputState | undefined; @@ -933,6 +935,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC details: lookup.toResponseDetails(activeRawModelId, sessionState.activeTurn.usage), }); initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn, this._config.connectionAuthority); + initialResponsePartCount = sessionState.activeTurn.responseParts.length; // Enrich usage entries with the actual model so the // context-usage widget resolves the right context window // on reconnection (same enrichment as _observeTurn). @@ -1043,7 +1046,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // If reconnecting to an active turn, wire up an ongoing state listener // to stream new progress into the session's progressObs. if (activeTurnId && initialProgress !== undefined) { - this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress); + this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress, initialResponsePartCount); } // For existing sessions, start watching for server-initiated turns @@ -1796,6 +1799,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; }); }); + const mcpStarting$ = derivedOpts({ equalsFn: equals }, reader => { + const state = mergedState$.read(reader); + const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer + ? [c] + : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []) ?? []; + return servers + .filter(server => server.enabled && server.state.kind === McpServerStatus.Starting) + .map((server): IChatMcpStartingServer => ({ + id: opts.sessionResource.authority + '/' + server.id, + name: server.name, + })); + }); // Subagent observation context: dedups subagent tool calls so each is // observed once. @@ -1838,6 +1853,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC case ResponsePartKind.ToolCall: this._setupToolCallPart(part$ as IObservable, partStore, opts, subagentContext); break; + case ResponsePartKind.SystemNotification: + // System notifications don't have an id, so we have to identify it by index + if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) { + const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority); + if (progress) { + opts.sink([progress]); + } + } + break; } }, )); @@ -1877,6 +1901,43 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); })); + // Surface a "Starting MCP servers …" progress hint when servers + // remain in the `Starting` state past a short grace period after the + // turn begins without any content arriving from the host. The part + // updates as servers finish and hides once every server has started, + // content starts being received, or the turn ends — whichever comes + // first. It carries no interactive affordance (no "Skip"). + { + const MCP_STARTING_GRACE_MS = 5000; + + let didAppend = false; + const hasContent$ = responseParts$.map(r => r.length > 0); + const hasServersStarting$ = mcpStarting$.map(s => s.length > 0); + const serversStartingInput = observableValue('mcpStartingServersInput', constObservable([])); + + store.add(autorun(reader => { + if (hasContent$.read(reader) || !hasServersStarting$.read(reader)) { + serversStartingInput.set(constObservable([]), undefined); + return; + } + + reader.store.add(disposableTimeout(() => { + serversStartingInput.set(mcpStarting$, undefined); + if (!didAppend) { + didAppend = true; + opts.sink([{ + kind: 'mcpServersStartingSlow', + sessionResource: opts.sessionResource, + servers: serversStartingInput.map((o, r) => o.read(r)), + }]); + } + + }, MCP_STARTING_GRACE_MS)); + })); + + store.add(toDisposable(() => serversStartingInput.set(constObservable([]), undefined))); + } + store.add(autorun(reader => { const rawUsage = usage$.read(reader); // The parent turn's usage already aggregates the parent agent's @@ -2110,7 +2171,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const delta = content.substring(lastEmitted); lastEmitted = content.length; - opts.sink([{ kind: 'markdownContent', content: rawMarkdownToString(delta, this._config.connectionAuthority) }]); + opts.sink([{ kind: 'markdownContent', content: new MarkdownString(delta) }]); })); } @@ -3176,6 +3237,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId: string, chatSession: AgentHostChatSession, initialProgress: IChatProgress[], + initialResponsePartCount: number, ): void { const sessionKey = backendSession.toString(); const chatURI = this._getChatURI(chatSession.sessionResource); @@ -3214,6 +3276,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken: cts.token, adoptInvocations, seedEmittedLengths, + initialResponsePartCount, onTurnEnded: () => { chatSession.complete(); reconnectStore.dispose(); @@ -4433,6 +4496,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC : this._ensureAdditionalChatSubscription(chatUri); } + resolveChatResponseUri(_sessionResource: URI, href: string, _kind: 'link' | 'image'): string { + return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority); + } + /** * Read the current root state. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 94cee9012a2..f3972f63a64 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -7,6 +7,8 @@ import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { MessageKind, ToolCallContributorKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type Message, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -18,6 +20,7 @@ import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachmen import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; +import product from '../../../../../../platform/product/common/product.js'; import { formatCopilotCredits, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind, AgentFeedbackReviewCommandId } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -121,12 +124,12 @@ export function isSubagentToolName(toolName: string): boolean { return SUBAGENT_TOOL_NAMES.has(toolName); } -function systemNotificationToProgress(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined { +export function systemNotificationToChatPart(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined { if (!content) { return undefined; } const value = stringOrMarkdownToString(content, connectionAuthority); - return { kind: 'progressMessage', content: typeof value === 'string' ? new MarkdownString(value) : value }; + return { kind: 'systemNotification', content: typeof value === 'string' ? new MarkdownString(value) : value }; } /** @@ -364,7 +367,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority) }); + parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); } break; case ResponsePartKind.ToolCall: { @@ -385,7 +388,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part break; case ResponsePartKind.SystemNotification: { - const progress = systemNotificationToProgress(rp.content, connectionAuthority); + const progress = systemNotificationToChatPart(rp.content, connectionAuthority); if (progress) { parts.push(progress); } @@ -649,7 +652,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority) }); + parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); } break; case ResponsePartKind.Reasoning: @@ -668,7 +671,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur } case ResponsePartKind.SystemNotification: { - const progress = systemNotificationToProgress(rp.content, connectionAuthority); + const progress = systemNotificationToChatPart(rp.content, connectionAuthority); if (progress) { parts.push(progress); } @@ -801,6 +804,7 @@ function buildTerminalToolSpecificData( ...existing, kind: 'terminal', commandLine, + intention: tc.intention ?? existing?.intention, language: existing?.language ?? getTerminalLanguage(tc), terminalToolSessionId: terminalContentUri ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) @@ -938,7 +942,7 @@ function getToolErrorString(tc: ToolCallState): string | undefined { export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string): IChatToolInvocationSerialized { const isTerminal = isTerminalToolCall(tc); const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; - let invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + let invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? tc.displayName; // Check for subagent content const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; @@ -1105,6 +1109,9 @@ const EXTERNAL_LINK_SCHEMES: ReadonlySet = new Set([ 'command', 'vscode', 'vscode-insiders', + Schemas.vscodeBrowser, + 'copilot-skill', + product.urlProtocol, AGENT_HOST_SCHEME, ]); @@ -1231,6 +1238,107 @@ export function rawMarkdownToString(content: string, connectionAuthority: string return new MarkdownString(rewritten); } +function parseAbsoluteFileLinkTarget(href: string): URI | undefined { + const fragmentIndex = href.indexOf('#'); + const rawPath = fragmentIndex >= 0 ? href.substring(0, fragmentIndex) : href; + if (rawPath.includes('?')) { + return undefined; + } + + const existingFragment = fragmentIndex >= 0 ? href.substring(fragmentIndex + 1) : ''; + const parsedPath = existingFragment ? { path: rawPath } : parseFileLocation(rawPath); + let decodedPath: string; + try { + decodedPath = decodeURIComponent(parsedPath.path); + } catch { + return undefined; + } + + const absolutePath = decodedPath; + const isWindowsPath = win32.isAbsolute(absolutePath); + if (!posix.isAbsolute(absolutePath) && !isWindowsPath) { + return undefined; + } + + const selectionFragment = formatLocationFragment(parsedPath); + const normalizedPath = isWindowsPath ? absolutePath.replaceAll('\\', '/') : absolutePath; + return URI.file(normalizedPath).with({ fragment: existingFragment || selectionFragment }); +} + +interface IFileLocation { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +function parseFileLocation(path: string): IFileLocation { + const match = /^(?.+?):(?[1-9]\d*)(?::(?[1-9]\d*))?$/.exec(path); + if (!match?.groups) { + return { path }; + } + const line = Number(match.groups.line); + const column = match.groups.column ? Number(match.groups.column) : undefined; + if ( + !Number.isSafeInteger(line) + || column !== undefined && !Number.isSafeInteger(column) + ) { + return { path }; + } + return { path: match.groups.path, line, column }; +} + +function formatLocationFragment(location: IFileLocation): string { + if (location.line === undefined) { + return ''; + } + return `L${location.line}${location.column !== undefined && location.column !== 1 ? `,${location.column}` : ''}`; +} + +function normalizeFileUriSelection(uri: URI, href: string): URI { + if (uri.scheme.toLowerCase() !== Schemas.file || uri.query || uri.fragment) { + return uri; + } + const parsedPath = parseFileLocation(href); + if (parsedPath.line === undefined) { + return uri; + } + const fragment = formatLocationFragment(parsedPath); + const suffixLength = href.length - parsedPath.path.length; + return uri.with({ path: uri.path.substring(0, uri.path.length - suffixLength), fragment }); +} + +/** Wraps an absolute path or internal URI target for the owning Agent Host connection. */ +export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string): string { + let parsed = parseAbsoluteFileLinkTarget(href); + if (!parsed) { + try { + parsed = URI.parse(href, true); + } catch { + return href; + } + const scheme = parsed.scheme.toLowerCase(); + if (!scheme || EXTERNAL_LINK_SCHEMES.has(scheme)) { + return href; + } + parsed = normalizeFileUriSelection(parsed.with({ scheme }), href); + if (!parsed.path.startsWith('/')) { + return href; + } + } + + let agentHostUri: URI; + try { + agentHostUri = toAgentHostUri(parsed, connectionAuthority); + } catch { + return href; + } + if (isSkillFileUri(parsed) && !agentHostUri.query.includes('vscodeLinkType=')) { + const existing = agentHostUri.query; + agentHostUri = agentHostUri.with({ query: existing ? `${existing}&vscodeLinkType=skill` : 'vscodeLinkType=skill' }); + } + return agentHostUri.toString(); +} + /** * Converts a protocol `StringOrMarkdown` value to a chat-layer `IMarkdownString`. * @@ -1418,7 +1526,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI } const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); - invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? tc.displayName; // Tools that render a bespoke, client-authored invocation message override // the server text here. Add new per-tool cases alongside this branch. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts index ff42251ec0c..e3606fc36b9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts @@ -12,13 +12,35 @@ import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +/** + * The kind of attention a pending approval needs. Lets consumers tailor UI + * (e.g. a summary message) to what the user is actually being asked to do. + */ +export const enum AgentSessionApprovalKind { + /** A terminal command is waiting to be run. */ + Terminal = 'terminal', + /** The agent is asking the user a question / needs a free-form response. */ + Question = 'question', + /** Some other tool invocation is waiting for confirmation. */ + Other = 'other', +} + export interface IAgentSessionApprovalInfo { + readonly kind: AgentSessionApprovalKind; readonly label: string; readonly languageId: string | undefined; readonly since: Date; confirm(): void; } +/** + * A stable identity for a specific pending approval, distinguishing it from a + * later, distinct approval on the same session (a fresh `since` yields a new id). + */ +export function agentSessionApprovalId(info: IAgentSessionApprovalInfo): string { + return `${info.kind}\u0000${info.label}\u0000${info.since.getTime()}`; +} + /** * Tracks approval state for all live chat sessions. For each session, * exposes an observable that emits {@link IAgentSessionApprovalInfo} @@ -71,7 +93,7 @@ export class AgentSessionApprovalModel extends Disposable { if (current === value) { return; } - if (current !== undefined && value !== undefined && current.label === value.label && current.languageId === value.languageId) { + if (current !== undefined && value !== undefined && current.kind === value.kind && current.label === value.label && current.languageId === value.languageId) { return; } settable.set(value, undefined); @@ -98,19 +120,24 @@ export class AgentSessionApprovalModel extends Disposable { if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { let label: string; let languageId: string | undefined; + let kind: AgentSessionApprovalKind; if (part.toolSpecificData?.kind === 'terminal') { const terminalData = migrateLegacyTerminalToolSpecificData(part.toolSpecificData); label = terminalData.presentationOverrides?.commandLine ?? terminalData.commandLine.forDisplay ?? terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; languageId = this._languageService.getLanguageIdByLanguageName(terminalData.presentationOverrides?.language ?? terminalData.language) ?? undefined; + kind = AgentSessionApprovalKind.Terminal; } else if (needsInput.detail) { label = needsInput.detail; + kind = AgentSessionApprovalKind.Question; } else { const msg = part.invocationMessage; label = typeof msg === 'string' ? msg : renderAsPlaintext(msg); + kind = AgentSessionApprovalKind.Other; } const confirmState = state; setIfChanged({ + kind, label, languageId, since: new Date(), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 8636ef6bf98..8c1bfad3e4b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -96,7 +96,7 @@ export function getAgentSessionProviderIcon(provider: AgentSessionTarget): Theme case AgentSessionProviders.Growth: return Codicon.lightbulb; case AgentSessionProviders.AgentHostCopilot: - return Codicon.copilot; + return Codicon.vm; default: return Codicon.extensions; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index d20ac96303c..a487397ed4f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -529,7 +529,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return { ...Codicon.circleFilled, color: themeColorFromId('textLink.foreground') }; } - if (!statusOnly && session.providerType === AgentSessionProviders.Local) { + if (!statusOnly && (session.providerType === AgentSessionProviders.Local || session.providerType === AgentSessionProviders.AgentHostCopilot)) { return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index ca9303d4d20..1aa26ff98c9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -39,6 +39,11 @@ export const promptIcon = registerIcon('ai-customization-prompt', Codicon.bookma */ export const hookIcon = registerIcon('ai-customization-hook', Codicon.zap, localize('aiCustomizationHookIcon', "Icon for hooks.")); +/** + * Icon for automations. + */ +export const automationIcon = registerIcon('ai-customization-automation', Codicon.watch, localize('aiCustomizationAutomationIcon', "Icon for scheduled automations.")); + /** * Icon for adding a new item. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index 18e7a2ed0f7..df56233b723 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -249,7 +249,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer); } else { - const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); + const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => descriptor); return new ItemProviderItemSource( sessionResource, itemProvider, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 07fa2afddcf..58d6eb85644 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -616,8 +616,8 @@ export class AICustomizationListWidget extends Disposable { private readonly _onDidRequestCreate = this._register(new Emitter()); readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; - private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }>()); - readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1098,7 +1098,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, }); } } else if (!override?.commandId) { @@ -1107,7 +1107,7 @@ export class AICustomizationListWidget extends Disposable { label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, enabled: hasWorkspace, tooltip: hasWorkspace ? undefined : localize('configureHooksDisabled', "Open a workspace folder to configure hooks."), - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, }); } return actions; @@ -1129,7 +1129,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.add.id}) New ${createTypeLabel} (Workspace)`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, }); addedTargets.add('workspace'); } else { @@ -1148,7 +1148,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.folder.id}) New ${createTypeLabel} (Workspace)`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 20fe87b58a8..baca64d0e00 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -33,7 +33,7 @@ import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import { AICustomizationListWidget } from './aiCustomizationListWidget.js'; @@ -42,6 +42,7 @@ import { McpListWidget } from './mcpListWidget.js'; import { PluginListWidget } from './pluginListWidget.js'; import { ToolsListWidget } from './toolsListWidget.js'; import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE } from '../agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { AutomationsListWidget } from './automationsListWidget.js'; import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, @@ -56,7 +57,8 @@ import { SIDEBAR_MAX_WIDTH, CONTENT_MIN_WIDTH, } from './aiCustomizationManagement.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon } from './aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon, automationIcon } from './aiCustomizationIcons.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../common/automations/automationsEnabled.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -65,7 +67,7 @@ import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLo import { getAttributeDefinition, getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../promptSyntax/newPromptFileActions.js'; import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; -import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory, CustomizationLocationPicker } from './customizationCreatorService.js'; +import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; @@ -88,11 +90,13 @@ import { IExtension } from '../../../extensions/common/extensions.js'; import { EmbeddedMcpServerDetail } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; const $ = DOM.$; @@ -266,11 +270,13 @@ export class AICustomizationManagementEditor extends EditorPane { private listWidget!: AICustomizationListWidget; private mcpListWidget: McpListWidget | undefined; private pluginListWidget: PluginListWidget | undefined; + private automationsListWidget: AutomationsListWidget | undefined; private modelsWidget: ChatModelsWidget | undefined; private toolsListWidget: ToolsListWidget | undefined; private promptsContentContainer!: HTMLElement; private mcpContentContainer: HTMLElement | undefined; private pluginContentContainer: HTMLElement | undefined; + private automationsContentContainer: HTMLElement | undefined; private modelsContentContainer: HTMLElement | undefined; private toolsContentContainer: HTMLElement | undefined; private modelsFooterElement: HTMLElement | undefined; @@ -368,6 +374,7 @@ export class AICustomizationManagementEditor extends EditorPane { @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IViewsService private readonly viewsService: IViewsService, + @ILabelService private readonly labelService: ILabelService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -397,6 +404,7 @@ export class AICustomizationManagementEditor extends EditorPane { [AICustomizationManagementSection.Instructions]: { label: localize('instructions', "Instructions"), icon: instructionsIcon, description: localize('instructionsDesc', "Set always-on instructions that guide AI behavior across your workspace or user profile.") }, [AICustomizationManagementSection.Prompts]: { label: localize('prompts', "Prompts"), icon: promptIcon, description: localize('promptsDesc', "Reusable prompt templates that can be invoked as slash commands.") }, [AICustomizationManagementSection.Hooks]: { label: localize('hooks', "Hooks"), icon: hookIcon, description: localize('hooksDesc', "Configure automated actions triggered by events like saving files or running tasks.") }, + [AICustomizationManagementSection.Automations]: { label: localize('automations', "Automations"), icon: automationIcon, description: localize('automationsDesc', "Schedule agent sessions to run on a cadence you choose.") }, [AICustomizationManagementSection.McpServers]: { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, description: localize('mcpServersDesc', "Connect external tool servers that extend AI capabilities with custom tools and data sources.") }, [AICustomizationManagementSection.Plugins]: { label: localize('plugins', "Plugins"), icon: pluginIcon, description: localize('pluginsDesc', "Install and manage agent plugins that add additional tools, skills, and integrations.") }, [AICustomizationManagementSection.Models]: { label: localize('models', "Models"), icon: Codicon.vm, description: localize('modelsDesc', "Configure and manage language models available for use.") }, @@ -474,6 +482,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.mcpListWidget?.layout(height - 16, width - 24); this.pluginListWidget?.layout(height - 16, width - 24); this.toolsListWidget?.layout(height - 16, width - 24); + this.automationsListWidget?.layout(height - 16, width - 24); const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; this.modelsWidget?.layout(height - 16 - modelsFooterHeight, width); if (this.viewMode === 'editor' && this.embeddedEditor && this.embeddedEditorContainer) { @@ -536,6 +545,11 @@ export class AICustomizationManagementEditor extends EditorPane { const descriptor = this.harnessService.findHarnessById(activeId); const hidden = new Set(descriptor?.hiddenSections ?? []); + // Also hide the Automations section when the feature setting is off. + if (this.configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) !== true) { + hidden.add(AICustomizationManagementSection.Automations); + } + this.sections.length = 0; for (const s of this.allSections) { if (!hidden.has(s.id)) { @@ -627,6 +641,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled)) { this.onStructuredPreviewSettingChanged(); } + if (e.affectsConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING)) { + this.rebuildVisibleSections(); + } })); } @@ -843,6 +860,13 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + // Container for Automations content + if (hasSections.has(AICustomizationManagementSection.Automations)) { + this.automationsContentContainer = DOM.append(contentInner, $('.automations-content-container')); + this.automationsListWidget = this.editorDisposables.add(this.instantiationService.createInstance(AutomationsListWidget)); + this.automationsContentContainer.appendChild(this.automationsListWidget.element); + } + // Embedded editor container this.editorContentContainer = DOM.append(contentInner, $('.editor-content-container')); this.createEmbeddedEditor(); @@ -870,6 +894,12 @@ export class AICustomizationManagementEditor extends EditorPane { })); this.pluginListWidget.fireItemCount(); } + if (this.automationsListWidget) { + this.editorDisposables.add(this.automationsListWidget.onDidChangeItemCount(count => { + this.updateSectionCount(AICustomizationManagementSection.Automations, count); + })); + this.automationsListWidget.fireItemCount(); + } if (this.modelsWidget) { this.editorDisposables.add(this.modelsWidget.onDidChangeItemCount(count => { this.updateSectionCount(AICustomizationManagementSection.Models, count); @@ -1019,6 +1049,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.modelsWidget?.focusSearch(); } else if (section === AICustomizationManagementSection.Tools) { this.toolsListWidget?.focusSearch(); + } else if (section === AICustomizationManagementSection.Automations) { + this.automationsListWidget?.focusSearch(); } else { this.listWidget?.focusSearch(); } @@ -1064,6 +1096,7 @@ export class AICustomizationManagementEditor extends EditorPane { const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; const isPluginsSection = this.selectedSection === AICustomizationManagementSection.Plugins; const isToolsSection = this.selectedSection === AICustomizationManagementSection.Tools; + const isAutomationsSection = this.selectedSection === AICustomizationManagementSection.Automations; if (this.welcomePage) { this.welcomePage.container.style.display = isWelcome && !isEditorMode && !isDetailMode ? '' : 'none'; @@ -1083,6 +1116,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.pluginContentContainer) { this.pluginContentContainer.style.display = !isEditorMode && !isDetailMode && isPluginsSection ? '' : 'none'; } + if (this.automationsContentContainer) { + this.automationsContentContainer.style.display = !isEditorMode && !isDetailMode && isAutomationsSection ? '' : 'none'; + } if (this.pluginDetailContainer) { this.pluginDetailContainer.style.display = isPluginDetailMode ? '' : 'none'; } @@ -1124,7 +1160,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Creates a new prompt file and opens it in the embedded editor. */ - private async createNewItemManual(type: PromptsType, target: 'local' | 'user' | 'workspace-root', rootFileName?: string): Promise { + private async createNewItemManual(type: PromptsType, target: 'workspace' | 'user' | 'workspace-root', rootFileName?: string): Promise { this.telemetryService.publicLog2('chatCustomizationEditor.createItem', { section: this.selectedSection ?? 'welcome', promptType: type, @@ -1175,23 +1211,15 @@ export class AICustomizationManagementEditor extends EditorPane { } return; } - const sessionResource = this.harnessService.activeSessionResource.get(); - const picker = this.instantiationService.createInstance(CustomizationLocationPicker); - const targetDir = await picker.resolveTargetDirectoryWithPicker( - sessionResource, - type, - target, - ); + + const targetDir = await this.resolveTargetDirectoryWithPicker(type, target); if (targetDir === null) { return; // User cancelled the picker } - - if (targetDir === undefined) { - // targetDir may be undefined when no matching folder exists for the - // requested storage type (e.g. skills have no user-storage folder). - await this.instantiationService.invokeFunction(showNoFoldersDialog, type); - return; - } + // targetDir may be undefined when no matching folder exists for the + // requested storage type (e.g. skills have no user-storage folder). + // Pass it through. The command handles undefined by showing its own + // folder picker via askForPromptSourceFolder. // When the active harness overrides the file extension (e.g. Claude // rules use .md instead of .instructions.md), pass it through so the @@ -1200,11 +1228,11 @@ export class AICustomizationManagementEditor extends EditorPane { const options: INewPromptOptions = { targetFolder: targetDir, - targetStorage: target === AICustomizationSources.user ? PromptsStorage.user : PromptsStorage.local, + targetStorage: target === 'user' ? PromptsStorage.user : PromptsStorage.local, fileExtension: override?.fileExtension, openFile: async (uri) => { - const isWorkspace = target === AICustomizationSources.local; - await this.showEmbeddedEditor(uri, basename(uri), type, target, isWorkspace); + const isWorkspace = target === 'workspace'; + await this.showEmbeddedEditor(uri, basename(uri), type, target === 'user' ? PromptsStorage.user : PromptsStorage.local, isWorkspace); return this.embeddedEditor; }, }; @@ -1222,6 +1250,72 @@ export class AICustomizationManagementEditor extends EditorPane { this.listWidget.refresh(); } + /** + * Resolves the target directory for creating a new customization file. + * If multiple source folders exist for the given storage type, shows a + * picker to let the user choose. Otherwise, returns the single match. + * + * Source folders come from the active harness's item provider (via the + * items model). Each session can supply its own set of customization + * locations through `ICustomizationItemProvider.provideSourceFolders`. + * + * @returns the resolved URI, `undefined` when no folder is available, + * or `null` when the user cancelled the picker. + */ + private async resolveTargetDirectoryWithPicker(type: PromptsType, target: 'workspace' | 'user'): Promise { + const sessionResource = this.harnessService.activeSessionResource.get(); + const activeDescriptor = this.harnessService.getActiveDescriptor(); + const provider = activeDescriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => activeDescriptor); + if (!provider.provideSourceFolders) { + return undefined; + } + const allFolders = await provider.provideSourceFolders(sessionResource, type, CancellationToken.None); + if (!allFolders) { + // Provider returned no source folders for this type/session. + return undefined; + } + + const projectRoot = this.workspaceService.getActiveProjectRoot(); + const matchingFolders: ICustomizationSourceFolder[] = []; + const hasSeen = new ResourceSet(); + for (const f of allFolders) { + if (target === 'workspace') { + if (projectRoot && isEqualOrParent(f.uri, projectRoot) && !hasSeen.has(f.uri)) { + hasSeen.add(f.uri); + matchingFolders.push(f); + } + } else { + if ((!projectRoot || !isEqualOrParent(f.uri, projectRoot)) && !hasSeen.has(f.uri)) { + hasSeen.add(f.uri); + matchingFolders.push(f); + } + } + } + + if (matchingFolders.length === 0) { + // No matching folders. Return undefined so the command can fall + // back to askForPromptSourceFolder (not null which means cancellation) + return undefined; + } + + if (matchingFolders.length === 1) { + return matchingFolders[0].uri; + } + + // Multiple directories. Ask the user which one to use. + const items: (IQuickPickItem & { uri: URI })[] = matchingFolders.map(folder => ({ + label: folder.label, + description: this.labelService.getUriLabel(folder.uri, { relative: true }), + uri: folder.uri, + })); + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('selectTargetDirectory', "Select a directory for the new customization file"), + }); + + return picked?.uri ?? null; + } + override updateStyles(): void { // The modal provides its own panel chrome, so the split view separator // is intentionally hidden here regardless of theme. @@ -1305,6 +1399,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.modelsWidget?.focusSearch(); } else if (this.selectedSection === AICustomizationManagementSection.Tools) { this.toolsListWidget?.focusSearch(); + } else if (this.selectedSection === AICustomizationManagementSection.Automations) { + this.automationsListWidget?.focusSearch(); } else { this.listWidget?.focusSearch(); } @@ -1702,7 +1798,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (workspaceFolder) { items.push({ label: localize('workspaceSaveTarget', "Workspace"), - description: workspaceFolder.fsPath, + description: this.labelService.getUriLabel(workspaceFolder, { relative: true }), target: 'workspace', folder: workspaceFolder, }); @@ -1712,7 +1808,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (userFolder) { items.push({ label: localize('userSaveTarget', "User"), - description: userFolder.fsPath, + description: this.labelService.getUriLabel(userFolder, { relative: true }), target: 'user', folder: userFolder, }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.ts new file mode 100644 index 00000000000..2b68e27b1b2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../../nls.js'; +import { + AccessibleContentProvider, + AccessibleViewProviderId, + AccessibleViewType, +} from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, +} from './aiCustomizationManagement.js'; + +/** + * Alt+F1 / "Open Accessibility Help" provider for the Automations + * section of the Agent Customizations editor. Mirrors the structure + * used by sibling sections (problems, scm, debug). The provider is + * activated when the editor is focused and the active section is + * `Automations`. + */ +export class AutomationsAccessibilityHelp implements IAccessibleViewImplementation { + readonly type = AccessibleViewType.Help; + readonly priority = 105; + readonly name = 'automations'; + readonly when = ContextKeyExpr.and( + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.isEqualTo(AICustomizationManagementSection.Automations), + ); + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider { + const keybindingService = accessor.get(IKeybindingService); + // Must be a real AccessibleContentProvider instance: the accessible-view + // service does `instanceof` checks and otherwise falls back to + // ExtensionContentProvider semantics, breaking verbositySettingKey + // propagation. (The repo accessibility skill discourages custom subclasses.) + return new AccessibleContentProvider( + AccessibleViewProviderId.Automations, + { type: AccessibleViewType.Help }, + () => buildAutomationsHelpContent(keybindingService), + () => { /* no-op: the accessible view returns focus to the previously focused element. */ }, + AccessibilityVerbositySettingId.Automations, + ); + } +} + +/** + * Renders the static Automations accessibility-help content. Exported so + * tests can validate the rendered string without instantiating an + * {@link AccessibleContentProvider}. + */ +export function buildAutomationsHelpContent(keybindingService: IKeybindingService): string { + const lines: string[] = []; + + lines.push(nls.localize('automations.help.header', 'Accessibility Help: Automations')); + lines.push(nls.localize('automations.help.intro', 'Automations let you schedule agent sessions to run on a cadence you choose. When an automation is due, a fresh chat session is created in the background and the prompt is sent automatically.')); + lines.push(''); + + lines.push(nls.localize('automations.help.layoutHeader', 'Layout:')); + lines.push(nls.localize('automations.help.layoutDesc', 'The Automations section shows a list of all your automations. Each row displays the automation\u2019s name, schedule (Manual, Hourly, Daily at a time, or Weekly on a day at a time), the workspace folder it targets, the next scheduled run, and a preview of the prompt. Row action buttons follow on the right.')); + lines.push(''); + + lines.push(nls.localize('automations.help.actionsHeader', 'Row Actions (Tab between them):')); + lines.push(nls.localize('automations.help.actionRun', '- Run now: spawns a new agent session immediately using the automation\u2019s prompt. The trigger is recorded as Manual.')); + lines.push(nls.localize('automations.help.actionToggle', '- Toggle enabled: pauses or resumes scheduled runs. Disabled automations skip their scheduled tick but can still be run manually.')); + lines.push(nls.localize('automations.help.actionEdit', '- Edit: opens the create/edit dialog with the current values prefilled.')); + lines.push(nls.localize('automations.help.actionDelete', '- Delete: removes the automation after a confirmation prompt. Runs already in flight continue to completion.')); + lines.push(nls.localize('automations.help.actionHistory', '- Show history: expands an inline list of recent runs for the automation, including their status and any error messages.')); + lines.push(''); + + lines.push(nls.localize('automations.help.dialogHeader', 'Create/Edit Dialog:')); + lines.push(nls.localize('automations.help.dialogFields', 'The dialog has a Name field, a multi-line Prompt field, a Schedule selector (Manual, Hourly, Daily, Weekly), Time and Day-of-Week fields that appear when relevant, a Workspace folder selector with a Browse button, a Session type selector that appears when the selected folder offers more than one session type, an Agent Mode selector (Use default, Agent, Ask, Edit), and a Permission Mode selector (Use default, Default Approvals, Bypass Approvals, Autopilot). The selected Agent Mode and Permission Mode are replayed when the scheduler starts a run, so the chat opens with the same configuration every time. The Browse button opens a folder picker so you can target any folder, even one that is not currently open. Tab and Shift+Tab move between fields; Enter activates the Save button when the form is valid.')); + lines.push(nls.localize('automations.help.dialogValidation', 'Save is disabled until Name, Prompt, and Workspace folder are all set. Activate Cancel or press Escape to dismiss without saving.')); + lines.push(''); + + lines.push(nls.localize('automations.help.historyHeader', 'Run History:')); + lines.push(nls.localize('automations.help.historyDesc', 'Each run records its status (Pending, Running, Completed, Failed), the trigger that started it (Schedule, Manual, or Catch-up after VS Code restarts), the time it started, and the time it took. Failed runs include the failure reason.')); + lines.push(''); + + lines.push(nls.localize('automations.help.statusHeader', 'Screen Reader Announcements:')); + lines.push(nls.localize('automations.help.statusDesc', 'The Automations list announces state changes when you create, update, delete, toggle, or manually run an automation. Verbose announcements can be turned off via the accessibility.verbosity.automations setting.')); + lines.push(''); + + lines.push(nls.localize('automations.help.settingsHeader', 'Settings ({0} opens Settings):', describeCommand(keybindingService, 'workbench.action.openSettings') || 'Ctrl+,')); + lines.push(nls.localize('automations.help.settingVerbosity', '- `accessibility.verbosity.automations`: Controls whether this Accessibility Help hint is announced when the Automations section is focused.')); + lines.push(''); + + lines.push(nls.localize('automations.help.closingHeader', 'Closing this dialog:')); + lines.push(nls.localize('automations.help.closingDesc', 'Press Escape to close this dialog and return focus to the Automations list.')); + + return lines.join('\n'); +} + +function describeCommand(keybindingService: IKeybindingService, commandId: string): string | undefined { + const kb = keybindingService.lookupKeybinding(commandId); + return kb?.getAriaLabel() ?? undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts new file mode 100644 index 00000000000..f79b1b747b5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts @@ -0,0 +1,736 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { fromNow, getDurationString } from '../../../../../base/common/date.js'; +import * as resources from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { IAutomation, IAutomationRun, AutomationRunStatus, AutomationRunTrigger } from '../../common/automations/automation.js'; +import { IAutomationRunner } from '../../common/automations/automationRunner.js'; +import { IAutomationService } from '../../common/automations/automationService.js'; +import { IAutomationDialogService } from '../../common/automations/automationDialogService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../common/automations/automationsEnabled.js'; +import { DAYS_OF_WEEK } from '../../common/automations/schedule.js'; + +const $ = DOM.$; + +const AUTOMATION_ROW_HEIGHT = 72; +const HISTORY_ROW_HEIGHT = 28; +const HISTORY_HEADER_HEIGHT = 32; +const HISTORY_EMPTY_HEIGHT = 28; +const HISTORY_MORE_HEIGHT = 22; +const MAX_VISIBLE_RUNS = 20; + +interface IAutomationItemEntry { + readonly type: 'automation-item'; + readonly automation: IAutomation; + readonly runs: readonly IAutomationRun[]; + readonly expanded: boolean; + readonly inFlight: boolean; +} + +export type IAutomationListEntry = IAutomationItemEntry; + +interface IAutomationRowTemplateData { + readonly container: HTMLElement; + readonly row: HTMLElement; + readonly nameEl: HTMLElement; + readonly nameTextEl: HTMLElement; + readonly disabledBadge: HTMLElement; + readonly scheduleEl: HTMLElement; + readonly sep1: HTMLElement; + readonly nextEl: HTMLElement; + readonly sepFolder: HTMLElement; + readonly folderEl: HTMLElement; + readonly sep2: HTMLElement; + readonly lastEl: HTMLElement; + readonly promptEl: HTMLElement; + readonly actions: HTMLElement; + readonly historyPanel: HTMLElement; + readonly disposables: DisposableStore; +} + +class AutomationItemDelegate implements IListVirtualDelegate { + // Initial estimate only. Actual row height is measured from the DOM because + // the list is created with `supportDynamicHeights` (meta wraps, prompt wraps, + // and run-error text is variable-height). See `hasDynamicHeight` below. + getHeight(element: IAutomationListEntry): number { + if (!element.expanded) { + return AUTOMATION_ROW_HEIGHT; + } + const runs = element.runs; + const visibleRuns = Math.min(runs.length, MAX_VISIBLE_RUNS); + if (visibleRuns === 0) { + return AUTOMATION_ROW_HEIGHT + HISTORY_EMPTY_HEIGHT; + } + let historyHeight = HISTORY_HEADER_HEIGHT + visibleRuns * HISTORY_ROW_HEIGHT; + if (runs.length > MAX_VISIBLE_RUNS) { + historyHeight += HISTORY_MORE_HEIGHT; + } + return AUTOMATION_ROW_HEIGHT + historyHeight; + } + + hasDynamicHeight(_element: IAutomationListEntry): boolean { + return true; + } + + getTemplateId(_element: IAutomationListEntry): string { + return 'automationItem'; + } +} + +class AutomationItemRenderer implements IListRenderer { + readonly templateId = 'automationItem'; + + constructor( + private readonly widget: AutomationsListWidget, + private readonly hoverService: IHoverService, + ) { } + + renderTemplate(container: HTMLElement): IAutomationRowTemplateData { + const disposables = new DisposableStore(); + container.classList.add('automations-row-wrapper'); + + const row = DOM.append(container, $('.automations-row')); + const main = DOM.append(row, $('.automations-row-main')); + const nameEl = DOM.append(main, $('.automations-row-name')); + const nameTextEl = DOM.append(nameEl, $('span.automations-row-name-text')); + const disabledBadge = DOM.append(nameEl, $('span.automations-row-disabled-badge')); + + const metaEl = DOM.append(main, $('.automations-row-meta')); + const scheduleEl = DOM.append(metaEl, $('span.automations-row-schedule')); + const sep1 = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const nextEl = DOM.append(metaEl, $('span.automations-row-next')); + const sepFolder = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const folderEl = DOM.append(metaEl, $('span.automations-row-folder')); + const sep2 = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const lastEl = DOM.append(metaEl, $('span.automations-row-last')); + + const promptEl = DOM.append(main, $('.automations-row-prompt')); + const actions = DOM.append(row, $('.automations-row-actions')); + const historyPanel = DOM.append(container, $('.automations-row-history')); + + return { container, row, nameEl, nameTextEl, disabledBadge, scheduleEl, sep1, nextEl, sepFolder, folderEl, sep2, lastEl, promptEl, actions, historyPanel, disposables }; + } + + renderElement(element: IAutomationItemEntry, _index: number, templateData: IAutomationRowTemplateData): void { + templateData.disposables.clear(); + const { automation, runs, expanded, inFlight } = element; + + templateData.nameTextEl.textContent = automation.name; + templateData.row.classList.toggle('automations-row-disabled', !automation.enabled); + templateData.disabledBadge.textContent = !automation.enabled ? localize('automationDisabled', "Disabled") : ''; + templateData.disabledBadge.style.display = !automation.enabled ? '' : 'none'; + + templateData.scheduleEl.textContent = formatSchedule(automation); + templateData.sep1.textContent = '·'; + templateData.nextEl.textContent = formatNextRun(automation); + templateData.sepFolder.textContent = '·'; + const folderLabel = this.widget.formatFolderLabel(automation.folderUri); + templateData.folderEl.textContent = localize('automationFolderLabel', "in {0}", folderLabel); + templateData.folderEl.title = automation.folderUri.toString(); + + if (automation.lastRunAt) { + templateData.sep2.textContent = '·'; + templateData.sep2.style.display = ''; + templateData.lastEl.textContent = localize('lastRun', "Last run {0}", formatRelativeTimeOrIso(automation.lastRunAt)); + templateData.lastEl.style.display = ''; + } else { + templateData.sep2.style.display = 'none'; + templateData.lastEl.style.display = 'none'; + } + + templateData.promptEl.textContent = truncate(automation.prompt, 160); + templateData.promptEl.title = automation.prompt; + + DOM.clearNode(templateData.actions); + this.renderActions(templateData, automation, expanded, inFlight); + + DOM.clearNode(templateData.historyPanel); + templateData.historyPanel.id = `automation-history-${automation.id}`; + if (expanded) { + this.renderHistoryPanel(templateData, automation, runs); + } + templateData.historyPanel.style.display = expanded ? '' : 'none'; + } + + private renderActions(templateData: IAutomationRowTemplateData, automation: IAutomation, expanded: boolean, inFlight: boolean): void { + const { actions, disposables } = templateData; + + const runBtn = this.createIconButton(actions, Codicon.play, localize('runNow', "Run now"), inFlight, disposables); + disposables.add(DOM.addStandardDisposableListener(runBtn, 'click', () => { + void this.widget.runNow(automation); + })); + + const toggleIcon = automation.enabled ? Codicon.eye : Codicon.eyeClosed; + const toggleTooltip = automation.enabled ? localize('disableAutomation', "Disable") : localize('enableAutomation', "Enable"); + const toggleBtn = this.createIconButton(actions, toggleIcon, toggleTooltip, false, disposables); + disposables.add(DOM.addStandardDisposableListener(toggleBtn, 'click', () => { + void this.widget.toggleEnabled(automation); + })); + + const editBtn = this.createIconButton(actions, Codicon.edit, localize('editAutomation', "Edit"), false, disposables); + disposables.add(DOM.addStandardDisposableListener(editBtn, 'click', () => { + void this.widget.openEditDialog(automation); + })); + + const deleteBtn = this.createIconButton(actions, Codicon.trash, localize('deleteAutomation', "Delete"), false, disposables); + disposables.add(DOM.addStandardDisposableListener(deleteBtn, 'click', () => { + void this.widget.deleteAutomation(automation); + })); + + const histIcon = expanded ? Codicon.chevronDown : Codicon.chevronRight; + const histTooltip = expanded ? localize('hideHistory', "Hide history") : localize('showHistory', "Show history"); + const histBtn = this.createIconButton(actions, histIcon, histTooltip, false, disposables); + histBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + histBtn.setAttribute('aria-controls', `automation-history-${automation.id}`); + disposables.add(DOM.addStandardDisposableListener(histBtn, 'click', () => { + this.widget.toggleExpanded(automation.id); + })); + } + + private renderHistoryPanel(templateData: IAutomationRowTemplateData, automation: IAutomation, runs: readonly IAutomationRun[]): void { + const panel = templateData.historyPanel; + panel.setAttribute('role', 'region'); + panel.setAttribute('aria-label', localize('historyAriaLabel', "Run history for {0}", automation.name)); + + if (runs.length === 0) { + const empty = DOM.append(panel, $('.automations-history-empty')); + empty.textContent = localize('noRunsYet', "No runs yet."); + return; + } + + const heading = DOM.append(panel, $('h4.automations-history-heading')); + heading.textContent = localize('runHistory', "Run history"); + + const runsList = DOM.append(panel, $('ul.automations-history-list')); + const visibleRuns = runs.slice(0, MAX_VISIBLE_RUNS); + for (const run of visibleRuns) { + this.renderRunRow(runsList, run); + } + if (runs.length > MAX_VISIBLE_RUNS) { + const more = DOM.append(panel, $('.automations-history-more')); + more.textContent = localize('historyMore', "{0} more run(s) not shown.", runs.length - visibleRuns.length); + } + } + + private renderRunRow(container: HTMLElement, run: IAutomationRun): void { + const li = DOM.append(container, $('li.automations-history-row', { + 'data-run-id': run.id, + 'data-run-status': run.status, + })); + + const statusIcon = DOM.append(li, $('span.automations-history-status.codicon')); + const { iconId, spin } = runStatusIcon(run.status); + statusIcon.classList.add(`codicon-${iconId}`); + if (spin) { + statusIcon.classList.add('codicon-modifier-spin'); + } + statusIcon.setAttribute('aria-hidden', 'true'); + + const text = DOM.append(li, $('.automations-history-row-text')); + const first = DOM.append(text, $('.automations-history-row-first')); + const statusLabel = DOM.append(first, $('span.automations-history-row-status')); + statusLabel.textContent = runStatusLabel(run.status); + const sep = DOM.append(first, $('span.automations-history-row-sep')); + sep.textContent = '·'; + const trig = DOM.append(first, $('span.automations-history-row-trigger')); + trig.textContent = runTriggerLabel(run.trigger); + const sep2 = DOM.append(first, $('span.automations-history-row-sep')); + sep2.textContent = '·'; + const started = DOM.append(first, $('span.automations-history-row-started')); + started.textContent = localize('runStarted', "Started {0}", formatRelativeTimeOrIso(run.startedAt)); + const dur = formatRunDuration(run); + if (dur) { + const sep3 = DOM.append(first, $('span.automations-history-row-sep')); + sep3.textContent = '·'; + const durEl = DOM.append(first, $('span.automations-history-row-duration')); + durEl.textContent = dur; + } + + if (run.errorMessage) { + const err = DOM.append(text, $('.automations-history-row-error')); + err.textContent = run.errorMessage; + err.setAttribute('role', 'status'); + err.setAttribute('aria-live', 'polite'); + } + } + + private createIconButton(container: HTMLElement, icon: ThemeIcon, tooltip: string, disabled: boolean, disposables: DisposableStore): HTMLElement { + const button = DOM.append(container, $('button.automations-row-action-button', { + type: 'button', + 'aria-label': tooltip, + tabindex: '0', + })) as HTMLButtonElement; + button.classList.add(...ThemeIcon.asClassNameArray(icon)); + button.disabled = disabled; + disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), button, tooltip)); + return button; + } + + disposeTemplate(templateData: IAutomationRowTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Widget that renders the Automations section of the AI Customization editor + * using a virtualized {@link WorkbenchList}. + */ +export class AutomationsListWidget extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidChangeItemCount = this._register(new Emitter()); + readonly onDidChangeItemCount = this._onDidChangeItemCount.event; + + private readonly headerEl: HTMLElement; + private readonly listContainer: HTMLElement; + private readonly emptyContainer: HTMLElement; + private list!: WorkbenchList; + private newEmptyStateButton: Button | undefined; + + private readonly newButtonHover = this._register(new MutableDisposable()); + private readonly newEmptyStateButtonHover = this._register(new MutableDisposable()); + private readonly _emptyStateStore = this._register(new DisposableStore()); + + private readonly runInFlight = new Set(); + private readonly expandedRows = new Set(); + private displayEntries: IAutomationListEntry[] = []; + + private lastHeight = 0; + private lastWidth = 0; + private _layoutDeferred = false; + private readonly _layoutRAF = this._register(new MutableDisposable()); + + constructor( + @IAutomationService private readonly automationService: IAutomationService, + @IAutomationRunner private readonly automationRunner: IAutomationRunner, + @IDialogService private readonly dialogService: IDialogService, + @IAutomationDialogService private readonly automationDialogService: IAutomationDialogService, + @IHoverService private readonly hoverService: IHoverService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this.element = $('.automations-list-widget'); + this.headerEl = DOM.append(this.element, $('.automations-header')); + this.emptyContainer = DOM.append(this.element, $('.automations-empty-state')); + this.emptyContainer.style.display = 'none'; + this.listContainer = DOM.append(this.element, $('.automations-list')); + + this.renderHeader(); + this.createList(); + + this._register(autorun(reader => { + const items = this.automationService.automations.read(reader); + this.automationService.runs.read(reader); + this.updateList(items); + this._onDidChangeItemCount.fire(items.length); + })); + } + + private renderHeader(): void { + const titleRow = DOM.append(this.headerEl, $('.automations-header-row')); + const titleEl = DOM.append(titleRow, $('h2.automations-header-title')); + titleEl.textContent = localize('automationsHeaderTitle', "Automations"); + const subtitleEl = DOM.append(this.headerEl, $('p.automations-header-subtitle')); + subtitleEl.textContent = localize('automationsHeaderSubtitle', "Schedule agent sessions to run on a cadence you choose."); + + const newButton = this._register(new Button(titleRow, { ...defaultButtonStyles, title: localize('newAutomation', "New automation") })); + newButton.label = localize('newAutomation', "New automation"); + newButton.element.classList.add('automations-new-button'); + this._register(newButton.onDidClick(() => this.openCreateDialog())); + this.newButtonHover.value = this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), newButton.element, localize('newAutomationTooltip', "Create a new automation")); + } + + private createList(): void { + const delegate = new AutomationItemDelegate(); + const renderer = new AutomationItemRenderer(this, this.hoverService); + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'AutomationsManagementList', + this.listContainer, + delegate, + [renderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + supportDynamicHeights: true, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: (element: IAutomationListEntry) => { + const a = element.automation; + return a.enabled + ? localize('automationAriaLabel', "{0}, {1}", a.name, formatSchedule(a)) + : localize('automationAriaLabelDisabled', "{0}, disabled", a.name); + }, + getWidgetAriaLabel() { + return localize('automationsListAriaLabel', "Automations"); + } + }, + identityProvider: { + getId(element: IAutomationListEntry) { + return element.automation.id; + } + } + } + )); + } + + private updateList(items: readonly IAutomation[]): void { + if (items.length === 0) { + this.element.classList.add('automations-empty'); + this.emptyContainer.style.display = ''; + this.listContainer.style.display = 'none'; + this.renderEmptyState(); + this.displayEntries = []; + this.list.splice(0, this.list.length, []); + return; + } + + this.element.classList.remove('automations-empty'); + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + this.newEmptyStateButton = undefined; + this.newEmptyStateButtonHover.clear(); + + this.displayEntries = items.map(automation => ({ + type: 'automation-item' as const, + automation, + runs: this.automationService.runsFor(automation.id).get(), + expanded: this.expandedRows.has(automation.id), + inFlight: this.runInFlight.has(automation.id), + })); + + this.list.splice(0, this.list.length, this.displayEntries); + } + + private renderEmptyState(): void { + this._emptyStateStore.clear(); + DOM.clearNode(this.emptyContainer); + this.emptyContainer.setAttribute('role', 'status'); + const title = DOM.append(this.emptyContainer, $('h3.automations-empty-title')); + title.textContent = localize('automationsEmptyTitle', "No automations yet"); + const message = DOM.append(this.emptyContainer, $('p.automations-empty-message')); + message.textContent = localize('automationsEmptyMessage', "Create an automation to schedule an agent session to run on a cadence you choose."); + + const ctaButton = this._emptyStateStore.add(new Button(this.emptyContainer, { ...defaultButtonStyles })); + ctaButton.label = localize('automationsEmptyCta', "Create automation"); + ctaButton.element.classList.add('automations-empty-cta'); + this._emptyStateStore.add(ctaButton.onDidClick(() => this.openCreateDialog())); + this.newEmptyStateButton = ctaButton; + this.newEmptyStateButtonHover.value = this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), ctaButton.element, localize('newAutomationTooltip', "Create a new automation")); + } + + toggleExpanded(automationId: string): void { + if (this.expandedRows.has(automationId)) { + this.expandedRows.delete(automationId); + } else { + this.expandedRows.add(automationId); + } + this.updateList(this.automationService.automations.get()); + } + + async runNow(automation: IAutomation): Promise { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + if (this.runInFlight.has(automation.id)) { + return; + } + this.runInFlight.add(automation.id); + this.updateList(this.automationService.automations.get()); + try { + // The runner does not support cancellation yet. + await this.automationRunner.runOnce(automation, 'manual', 0, CancellationToken.None); + status(localize('automationStartedStatus', "Started automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] runNow failed unexpectedly', err); + } finally { + this.runInFlight.delete(automation.id); + this.updateList(this.automationService.automations.get()); + } + } + + async toggleEnabled(automation: IAutomation): Promise { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.updateAutomation(automation.id, { enabled: !automation.enabled }); + status(automation.enabled + ? localize('automationDisabledStatus', "Disabled automation {0}", automation.name) + : localize('automationEnabledStatus', "Enabled automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to toggle automation', err); + } + } + + async deleteAutomation(automation: IAutomation): Promise { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.dialogService.confirm({ + type: 'warning', + message: localize('confirmDeleteAutomation', "Delete automation \u201C{0}\u201D?", automation.name), + detail: localize('confirmDeleteAutomationDetail', "Runs already in flight will continue. This cannot be undone."), + primaryButton: localize('delete', "Delete"), + }); + if (!result.confirmed) { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.deleteAutomation(automation.id); + status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to delete automation', err); + } + } + + async openEditDialog(automation: IAutomation): Promise { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.automationDialogService.showAutomationDialog({ + existing: automation, + }); + if (!result || result.kind !== 'update') { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.updateAutomation(result.id, result.value); + status(localize('automationUpdatedStatus', "Updated automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to update automation', err); + await this.dialogService.error( + localize('automationUpdateFailed', "Failed to update automation."), + err instanceof Error ? err.message : String(err), + ); + } + } + + formatFolderLabel(folderUri: URI): string { + const folders = this.workspaceContextService.getWorkspace().folders; + const match = folders.find(f => resources.isEqual(f.uri, folderUri)); + if (match) { + return match.name || match.uri.toString(); + } + const segments = folderUri.path.split('/').filter(s => s.length > 0); + return segments[segments.length - 1] ?? folderUri.toString(); + } + + private _isEnabled(): boolean { + return this.configurationService.getValue(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + } + + private async _notifyDisabled(): Promise { + await this.dialogService.info( + localize('automationsDisabledTitle', "Automations are disabled."), + localize('automationsDisabledDetail', "Enable \u201C{0}\u201D to make changes.", CHAT_AUTOMATIONS_ENABLED_SETTING), + ); + } + + private async openCreateDialog(): Promise { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.automationDialogService.showAutomationDialog({}); + if (!result || result.kind !== 'create') { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + const created = await this.automationService.createAutomation(result.value); + status(localize('automationCreatedStatus', "Created automation {0}", created.name)); + } catch (err) { + this.logService.error('[Automations] Failed to create automation', err); + await this.dialogService.error( + localize('automationCreateFailed', "Failed to create automation."), + err instanceof Error ? err.message : String(err), + ); + } + } + + layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; + + this.element.style.height = `${height}px`; + + // Measure the header to calculate the list height. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet. Defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. + const headerHeight = this.headerEl.offsetHeight; + if (headerHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + this._layoutRAF.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this._layoutDeferred = false; + this.layout(this.lastHeight, this.lastWidth); + }); + return; + } + const listHeight = Math.max(0, height - headerHeight); + + this.listContainer.style.height = `${listHeight}px`; + this.list.layout(listHeight, width); + } + + fireItemCount(): void { + this._onDidChangeItemCount.fire(this.automationService.automations.get().length); + } + + /** Test-only: number of rows currently in the virtualized list. */ + get itemCount(): number { + return this.list.length; + } + + /** + * Test-only: snapshot of the view-model rows the list is displaying. + * The virtualized {@link WorkbenchList} does not lay out rows in a unit-test + * DOM, so tests assert the derived render state (expansion, runs, in-flight) + * here instead of querying row elements. + */ + getDisplayEntriesForTest(): readonly IAutomationListEntry[] { + return this.displayEntries; + } + + focusSearch(): void { + this.newEmptyStateButton?.focus(); + } +} + +function formatSchedule(a: IAutomation): string { + switch (a.schedule.interval) { + case 'manual': + return localize('scheduleManual', "Manual"); + case 'hourly': + return localize('scheduleHourly', "Hourly"); + case 'daily': + return localize('scheduleDaily', "Daily at {0}", formatHourMinute(a.schedule.scheduleHour, a.schedule.scheduleMinute)); + case 'weekly': { + const day = dayName(a.schedule.scheduleDay); + return localize('scheduleWeekly', "Weekly on {0} at {1}", day, formatHourMinute(a.schedule.scheduleHour, a.schedule.scheduleMinute)); + } + } +} + +function formatHourMinute(hour: number, minute: number): string { + const date = new Date(); + date.setHours(Math.max(0, Math.min(23, hour | 0)), Math.max(0, Math.min(59, minute | 0)), 0, 0); + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); +} + +function dayName(day: number): string { + const idx = ((day % 7) + 7) % 7; + return DAYS_OF_WEEK[idx]; +} + +function formatNextRun(a: IAutomation): string { + if (a.schedule.interval === 'manual' || !a.nextRunAt) { + return localize('nextRunNever', "No scheduled run"); + } + return localize('nextRun', "Next run {0}", formatRelativeTimeOrIso(a.nextRunAt)); +} + +function formatRelativeTimeOrIso(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) { + return iso; + } + const date = new Date(t); + const rel = fromNow(date, true); + const absolute = date.toLocaleString(); + return `${rel} (${absolute})`; +} + +function truncate(s: string, max: number): string { + const single = s.replace(/\s+/g, ' ').trim(); + if (single.length <= max) { + return single; + } + return single.slice(0, Math.max(0, max - 1)) + '\u2026'; +} + +function runStatusIcon(status: AutomationRunStatus): { iconId: string; spin: boolean } { + switch (status) { + case 'pending': return { iconId: 'circle-outline', spin: false }; + case 'running': return { iconId: 'sync', spin: true }; + case 'completed': return { iconId: 'check', spin: false }; + case 'failed': return { iconId: 'error', spin: false }; + } +} + +function runStatusLabel(status: AutomationRunStatus): string { + switch (status) { + case 'pending': return localize('runStatusPending', "Pending"); + case 'running': return localize('runStatusRunning', "Running"); + case 'completed': return localize('runStatusCompleted', "Completed"); + case 'failed': return localize('runStatusFailed', "Failed"); + } +} + +function runTriggerLabel(trigger: AutomationRunTrigger): string { + switch (trigger) { + case 'schedule': return localize('runTriggerSchedule', "Scheduled"); + case 'manual': return localize('runTriggerManual', "Manual"); + case 'catch_up': return localize('runTriggerCatchUp', "Catch-up"); + } +} + +function formatRunDuration(run: IAutomationRun): string | undefined { + if (!run.completedAt) { + return undefined; + } + const startMs = Date.parse(run.startedAt); + const endMs = Date.parse(run.completedAt); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) { + return undefined; + } + const durationMs = Math.max(0, endMs - startMs); + return getDurationString(durationMs); +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 3ff88b2360e..58396bbd09f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -11,10 +11,12 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { getPromptFileDefaultLocations } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -130,6 +132,7 @@ export class CustomizationLocationPicker { @IQuickInputService private readonly quickInputService: IQuickInputService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @ILabelService private readonly labelService: ILabelService ) { } @@ -148,7 +151,7 @@ export class CustomizationLocationPicker { public async resolveTargetDirectoryWithPicker(sessionResource: URI, type: PromptsType, target: 'local' | 'user'): Promise { const sessionType = getChatSessionType(sessionResource); const descriptor = this.harnessService.findHarnessById(sessionType); - const provider = descriptor?.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); + const provider = descriptor?.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => descriptor ?? this.harnessService.getActiveDescriptor()); if (!provider.provideSourceFolders) { return undefined; } @@ -158,7 +161,18 @@ export class CustomizationLocationPicker { return undefined; } - const matchingFolders = allFolders.filter(f => f.source === target); + const projectRoot = this.workspaceService.getActiveProjectRoot(); + const matchingFolders: ICustomizationSourceFolder[] = []; + const seenFolders = new ResourceSet(); + for (const folder of allFolders) { + const isProjectFolder = !!projectRoot && isEqualOrParent(folder.uri, projectRoot); + if ((target === 'local' && isProjectFolder) || (target === 'user' && !isProjectFolder)) { + if (!seenFolders.has(folder.uri)) { + seenFolders.add(folder.uri); + matchingFolders.push(folder); + } + } + } if (matchingFolders.length === 0) { // No matching folders — return undefined so the command can fall // back to askForPromptSourceFolder (not null which means cancellation) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 2ccc305a564..ef3cb99a158 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -775,7 +775,8 @@ .ai-customization-management-editor .mcp-content-container, .ai-customization-management-editor .plugin-content-container, .ai-customization-management-editor .models-content-container, -.ai-customization-management-editor .tools-content-container { +.ai-customization-management-editor .tools-content-container, +.ai-customization-management-editor .automations-content-container { height: 100%; display: flex; flex-direction: column; @@ -1778,3 +1779,1040 @@ pane is first mounted. View switches inside the modal are not animated. */ } /* Welcome page styles live in the welcome page variant stylesheets. */ + + +/* Automations section + * + * The outer `.automations-content-container` shares the panel-surface + * styling with the prompts / MCP / plugin / models containers (see the + * shared rule above near line 758) so the Automations tab visually + * matches its siblings: 40px panel padding, agentsPanel background, + * rounded border. The list-widget below fills that panel. + * + * No standalone rule is needed here. The shared selector covers it. */ + +.automations-list-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.automations-list-widget .automations-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 8px; + text-align: center; + flex: 1; +} + +.automations-list-widget .automations-empty-state .automations-empty-title { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); + margin: 0; +} + +.automations-list-widget .automations-empty-state .automations-empty-message { + font-size: var(--vscode-agents-fontSize-body1); + color: var(--vscode-descriptionForeground); + max-width: 320px; + line-height: 1.4; + margin: 0; +} + +.automations-list-widget .automations-empty-state .automations-empty-cta { + margin-top: 16px; + min-width: 160px; + align-self: center; +} + +/* Header. This mirrors `.section-title-header` on the prompts side so the + * Automations tab reads with the same vertical rhythm and underline + * treatment as Agents / Skills / Prompts / Instructions. Horizontal + * padding is absent because the parent `.automations-content-container` + * already owns 40px panel padding. */ +.automations-list-widget .automations-header { + flex-shrink: 0; + padding: 0 0 32px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.automations-list-widget .automations-header-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + flex-wrap: wrap; +} + +.automations-list-widget .automations-header-title { + font-size: var(--vscode-agents-fontSize-heading2); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); + margin: 0; + min-width: 0; +} + +.automations-list-widget .automations-header-subtitle { + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.45; + color: var(--vscode-descriptionForeground); + margin: 0 0 8px 0; + /* Reserve ~2 lines so vertical rhythm matches the prompts-side + * `.section-title-description` even when our subtitle is short. */ + min-height: 38px; +} + +.automations-list-widget .automations-new-button { + width: auto; + flex: 0 0 auto; + padding: 4px 14px; +} + +/* When the widget is empty, hide the header so the empty-state CTA reads + * as the primary action and there is not a duplicate New button. */ +.automations-list-widget.automations-empty .automations-header { + display: none; +} + +/* List */ +.automations-list-widget .automations-list { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: auto; + gap: 6px; +} + +.automations-list-widget .automations-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-radius: 6px; +} + +.automations-list-widget .automations-row:not(.automations-row-disabled):hover { + background-color: var(--vscode-list-hoverBackground); +} + +.automations-list-widget .automations-row:has(:focus-visible) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.automations-list-widget .automations-row-disabled { + opacity: 0.6; +} + +.automations-list-widget .automations-row-main { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.automations-list-widget .automations-row-name { + font-size: var(--vscode-agents-fontSize-body1); + font-weight: 600; + color: var(--vscode-foreground); + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + word-break: break-word; +} + +.automations-list-widget .automations-row-disabled-badge { + font-size: 11px; + font-weight: 400; + padding: 1px 6px; + border-radius: 3px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.automations-list-widget .automations-row-meta { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + flex-wrap: wrap; +} + +.automations-list-widget .automations-row-meta-sep { + opacity: 0.6; +} + +.automations-list-widget .automations-row-prompt { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +} + +.automations-list-widget .automations-row-actions { + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + flex: 0 0 auto; +} + +.automations-list-widget .automations-row-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + font-size: 16px; +} + +.automations-list-widget .automations-row-action-button:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.automations-list-widget .automations-row-action-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.automations-list-widget .automations-row-action-button:disabled { + opacity: 0.4; + cursor: default; +} + +/* Run history (inline) */ +.automations-list-widget .automations-row-wrapper { + display: flex; + flex-direction: column; + border-radius: 6px; + overflow: hidden; +} + +/* + * When a wrapper has an expanded history panel, flatten the row's bottom + * corners so the row + history read as one visually connected card. + */ +.automations-list-widget .automations-row-wrapper:has(> .automations-row-history) .automations-row { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.automations-list-widget .automations-row-history { + padding: 4px 16px 12px 40px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.automations-list-widget .automations-history-heading { + font-size: 11px; + font-weight: 600; + margin: 4px 0; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.automations-list-widget .automations-history-empty { + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.automations-list-widget .automations-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.automations-list-widget .automations-history-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + background-color: var(--vscode-editorWidget-background, transparent); +} + +.automations-list-widget .automations-history-status { + flex: 0 0 auto; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.automations-list-widget .automations-history-row[data-run-status="completed"] .automations-history-status { + color: var(--vscode-testing-iconPassed, var(--vscode-icon-foreground)); +} + +.automations-list-widget .automations-history-row[data-run-status="failed"] .automations-history-status { + color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground)); +} + +.automations-list-widget .automations-history-row[data-run-status="running"] .automations-history-status { + color: var(--vscode-charts-blue, var(--vscode-icon-foreground)); +} + +.automations-list-widget .automations-history-row-text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.automations-list-widget .automations-history-row-first { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + color: var(--vscode-foreground); +} + +.automations-list-widget .automations-history-row-status { + font-weight: 600; +} + +.automations-list-widget .automations-history-row-sep { + color: var(--vscode-descriptionForeground); + opacity: 0.6; +} + +.automations-list-widget .automations-history-row-trigger, +.automations-list-widget .automations-history-row-started, +.automations-list-widget .automations-history-row-duration { + color: var(--vscode-descriptionForeground); +} + +.automations-list-widget .automations-history-row-error { + color: var(--vscode-errorForeground); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; +} + +.automations-list-widget .automations-history-more { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Dialog */ +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message-detail { + display: none; +} + +/* + * The dialog's built-in title (rendered into `.dialog-message-text`) + * stays in the DOM so `aria-labelledby="monaco-dialog-message-text"` + * still binds for screen readers, but is visually hidden. Our own + * `.automation-titlebar` provides the visible chrome. Visually-hidden + * pattern (not `display: none`) because some AT/screen-reader combos + * skip aria-labelledby targets when `display: none` is in effect. + * Same treatment for the unused dialog icon slot. + */ +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message { + margin: 0; + padding: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message .dialog-message-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row > .dialog-icon.codicon { + display: none; +} + +/* + * Lock the dialog box to a stable width. Without this rule the dialog + * inherits `width: min-content` from `dialog.css` and re-flows as the + * embedded chat input's chips change size (e.g. when the model picker + * resolves a shorter label). Use min(640px, 92vw) so the dialog still + * shrinks gracefully on narrow viewports. + * + * Padding is stripped (vs. the base 8px) so our `.automation-titlebar` + * stripe can paint edge-to-edge inside the dialog body. The form pane + * re-adds its own padding. + * + * Note: `extraClasses` on Dialog adds the class to `.monaco-dialog-box` + * itself (not an ancestor), so we attach the rule directly here. + */ +.monaco-dialog-box.automation-dialog { + width: min(640px, 92vw); + min-width: min(520px, 92vw); + max-width: 92vw; + padding: 0; + background-color: var(--vscode-quickInput-background); + color: var(--vscode-quickInput-foreground); + /* Anchor for the absolutely-positioned close-X (see below). */ + position: relative; +} + +/* + * Float the close-X over the titlebar so the title text sits flush + * with the top edge of the modal (QuickInput-style). Without this, + * `.dialog-toolbar-row` reserves ~24px of dead space above the title. + * `z-index` keeps the X above the sticky titlebar. + */ +.monaco-dialog-box.automation-dialog .dialog-toolbar-row { + position: absolute; + top: 0; + right: 0; + height: auto; + padding: 4px 6px 0 0; + z-index: 2; +} + +.automation-dialog-body { + display: flex; + flex-direction: column; + min-height: 0; +} + +/* + * Strip the base dialog padding on the message row and container so + * our titlebar can full-bleed. The form pane below re-adds horizontal + * padding so the form fields don't crash into the dialog edge. + * + * Container still owns the scrolling (per base/.../dialog.css), so + * the titlebar uses `position: sticky; top: 0` to stay visible as the + * form scrolls underneath it. + */ +.monaco-dialog-box.automation-dialog .dialog-message-row { + padding: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container { + padding: 0; + /* + * The base dialog leaves the message container at content size + * (`align-self: stretch` only stretches height in a horizontal flex + * row). Our form fields are `width: 100%` of this container, so they + * track the container's intrinsic min-content width. It shrinks + * over the first second as the embedded chat input's chip labels + * (model name, mode name, etc.) resolve and the toolbar's + * min-content drops. Forcing the container to fill the row width + * keeps every field at the dialog's locked width from first paint + * onwards. `min-width: 0` lets the container shrink inside the row + * (which has `overflow: hidden`) instead of being pushed by long + * chip labels. + */ + flex: 1 1 auto; + min-width: 0; +} + +/* + * QuickInput-style titlebar. The visual "line" beneath it is purely + * the background contrast between `quickInputTitle.background` (a + * translucent stripe) and `quickInput.background` (the form area). + * No `border-bottom`. Sticky so the title stays in view as the form + * scrolls (the dialog's message-container is the scroll host). + */ +.automation-titlebar { + position: sticky; + top: 0; + z-index: 1; + /* Right padding reserves room for the floating close-X chip. */ + padding: 8px 36px 8px 14px; + text-align: left; + font-weight: var(--vscode-agents-fontWeight-semiBold); + font-size: var(--vscode-agents-fontSize-heading2); + color: var(--vscode-quickInput-foreground); + background-color: var(--vscode-quickInputTitle-background); +} + +.automation-description { + padding: 8px 14px; + font-size: var(--vscode-agents-fontSize-body2, 12px); + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +.automation-form-pane { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 4px 14px 6px; +} + +/* + * Theme the native overflow scrollbar on the dialog body so it matches + * the rest of VS Code. .dialog-message-container is the scroll host + * (see base/browser/ui/dialog/dialog.css). Without this the dialog + * shows the platform's default scrollbar which clashes visually with + * the chat input embedded above. + */ +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar { + width: 10px; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-track { + background-color: transparent; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb { + min-height: 20px; + background-color: var(--vscode-scrollbarSlider-background); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-corner { + display: none; +} + +/* + * Z-index bridge for popups while the automation dialog is open. + * The dialog modal block is z-index 2575 (see base/.../dialog.css) + * and shares that value with .context-view (set inline in + * contextview.ts as `2575 + layer`). DOM order means the dialog + * shows on top, hiding any popup the chat input chips, the quick + * input, or a context menu try to open. We can't change the inline + * z-index from outside, so use `!important` on rules scoped to + * `body:has(.automation-dialog)`. They only apply while the + * automations dialog is on screen, so other dialogs' popup stacking + * is untouched. + */ +body:has(.automation-dialog) .context-view.monaco-component { + z-index: 2600 !important; +} + +body:has(.automation-dialog) .quick-input-widget { + z-index: 2600 !important; +} + +body:has(.automation-dialog) .monaco-menu-container { + z-index: 2600 !important; +} + +.automation-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 2px 2px 4px; +} + +/* + * Host for the embedded ChatInputPart. The composer brings its own + * background, border, and rounded corners (see chat.css + * `.interactive-input-part`), so we just give it room to breathe and let + * it fill the dialog's column width. `min-height` is sized for ~5 lines + * of body text + the toolbar row so the editor doesn't feel cramped on + * first open; the editor itself grows as the user types. + */ +.automation-form-prompt-host { + display: flex; + flex-direction: column; + min-height: 140px; + /* + * The host carries the `.interactive-session` class so the shared + * chat input CSS (chip colors, focus borders, etc.) matches what + * users see in a real chat session. That class also sets + * `max-width: 950px; margin: auto; height: 100%` (chat.css), which + * is meant for the full chat panel. In our flex column it makes + * the host take its content's min-content width and center, + * collapsing the chat input to ~chip-bar width. Re-assert + * full-width sizing so the host fills the dialog's form column. + */ + width: auto; + max-width: 100%; + margin: 0; + height: auto; + align-self: stretch; +} + +.automation-form-prompt-host .interactive-input-part { + /* + * `.interactive-input-part` in chat.css has `margin: 0 12px`, which + * (combined with the default `width: auto`) is designed to inset the + * composer inside a wider chat panel. In our dialog the prompt host + * is already the form column, so the margin pushes the composer 12px + * past the host on each side. Its right border ends up sitting + * outside the dialog. Zero out the margin so the composer aligns + * with the other form fields. + */ + margin: 0; + /* Don't let long custom-mode names or model identifiers push the + * modal wider than its declared max-width. */ + max-width: 100%; + min-width: 0; +} + +.automation-form-prompt-host .interactive-input-editor, +.automation-form-prompt-host .monaco-editor, +.automation-form-prompt-host .monaco-editor-background, +.automation-form-prompt-host .monaco-editor .margin { + background-color: var(--vscode-settings-textInputBackground, var(--vscode-input-background)); +} + +.automation-form-prompt-host .interactive-input-and-side-toolbar { + /* Allow the input column to shrink below its content's preferred + * width so flex children (chips, custom-mode names) don't push the + * modal wider than its declared max-width. */ + min-width: 0; +} + +/* + * Hide chips from the embedded chat input that don't fit the automation + * dialog's reduced surface: + * - "Configure Tools" (`workbench.action.chat.configureTools`, + * `.codicon-settings`). Tool selection is not relevant for a + * scheduled prompt. + * - "Attach context" (`workbench.action.chat.attachContext`, + * `.codicon-add`, the `+`). The dialog doesn't support attachments. + * the prompt is the only payload sent at run time. + * - "List MCP Servers" (`workbench.mcp.listServer`, `.codicon-server`) + * MCP server management belongs in the live chat surface, not in + * a one-off prompt definition. The action view item is custom + * (`MenuEntryActionViewItem`) but still renders the standard + * `.action-label.codicon-server` icon, so the same selector pattern + * matches. + * Other chips in this toolbar use either custom action view items + * (Model / Mode / Session pickers) or different codicons, so codicon + * selectors are unique to the chips we want to remove. The descendant + * selector (not `>`) matches even when the action-view-item wraps the + * `.action-label` in an extra container (e.g. dropdown variants), which + * keeps the rule resilient to view-item refactors. Hiding the + * `.action-item` (not just the `.action-label`) removes the slot from + * keyboard tab order too. + */ +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-settings), +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-add), +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-server) { + display: none; +} + +/* + * Suppress every inter-chip divider drawn by chat.css + * (`.chat-input-toolbar ... .action-item + .action-item::before`) inside + * the dialog. CSS `+` matches by DOM order regardless of `display:none`, + * so the divider would otherwise appear as an orphan vertical line on + * whichever visible chip follows our hidden `+`/settings items. The + * remaining visible chips (Agent, model, mode) already have enough + * intrinsic padding to read as separate without the divider. Dropping + * dividers entirely is simpler and more resilient than per-pair + * suppression rules that depend on DOM order. + */ +.automation-form-prompt-host .chat-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item::before { + display: none; +} + +/* + * Cancel the dialog's `
    ` indent for the chat input toolbars. + * + * `dialog.css:120` applies `padding-inline-start: 20px` to every `
      ` + * inside `.dialog-message-container` (meant to reduce the excessive + * default indent on bulleted lists in dialog body text). The + * `monaco-action-bar`'s `.actions-container` is rendered as a `
        `, so + * the chat input's primary and secondary toolbars inherit that 20px + * left indent. This pushes the Agent / Copilot CLI chips ~20px right of + * the chat input's bottom-left corner. + * + * `actionbar.css` sets `.actions-container { padding: 0 }`, but the + * dialog rule uses a logical property (`padding-inline-start`), which + * cascades-after the physical shorthand and wins regardless of selector + * specificity. Override the logical property explicitly here. + * + * Scoped to `.automation-form-prompt-host` so the regular chat panel + * (which isn't inside a dialog) is unaffected. Any `
          ` + * elements in the dialog's actual message body text retain the + * intended 20px indent. + */ +.automation-form-prompt-host .chat-input-toolbar .monaco-action-bar .actions-container, +.automation-form-prompt-host .chat-secondary-toolbar .monaco-action-bar .actions-container { + padding-inline-start: 0; + margin-left: 0; +} + +/* + * Sessions-layer workspace picker visual override. + * + * Two cascading sources need to be defeated to match Mode/Model: + * + * 1. `.sessions-chat-picker-slot.sessions-chat-workspace-picker + * .action-label` (sessions-layer `chatWidget.css:244-269`) sets a + * welcome-flow visual: label 18px, inner span 18px, icons 16px, + * chevron, generous padding, `color: var(--vscode-foreground)`. + * + * 2. `.monaco-action-bar .action-label` (actionbar.css:48) sets the + * base toolbar font-size to **11px**. This is what Mode/Model + * inherit. They don't set font-size themselves. The action bar + * base rule wins for their `.action-label`. The workspace picker's + * own sessions-layer rule (source 1) overrides that base, so we + * need to put the 11px back explicitly. `font-size: inherit` + * does NOT work because none of `.sessions-chat-picker-slot`'s + * ancestors carry an 11px font-size. The 11px lives on sibling + * `.action-label` elements via `.monaco-action-bar .action-label`, + * not on an ancestor we can inherit from. + * + * Result: match the 11px base, neutralize the welcome-flow oversizing, + * keep the Mode/Model color (`var(--vscode-icon-foreground)`), match + * the chip metrics from `.chat-input-toolbar .chat-input-picker-item + * .action-label` (chat.css:1822), and hide the chevron (Mode/Model + * don't render one). + * + * Scoped to `.automation-form-prompt-host` so the welcome view's + * standalone workspace picker keeps its larger sizing. + */ +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { + height: 16px; + padding: 3px 6px; + font-size: 11px; + line-height: normal; + color: var(--vscode-icon-foreground); +} + +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { + font-size: inherit; + line-height: inherit; +} + +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { + font-size: 12px; +} + +/* + * Mode and Model pickers in this toolbar don't render a dropdown + * chevron. They are recognized as chips by their padding + hover. Hide + * the chevron that `_renderTriggerLabel` always appends so the + * workspace chip reads the same way. + */ +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-chevron { + display: none; +} + +.automation-form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.automation-form-row.automation-form-checkbox-row { + flex-direction: row; + align-items: center; + gap: 8px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px solid var(--vscode-widget-border, transparent); +} + +/* + * Lay the Schedule / Time / Day controls along a single horizontal axis. + * Each control lives in its own `.automation-form-schedule-group` + * (label-above-control flex column). Time and Day join the row only + * when the interval is daily or weekly; flex-wrap keeps the layout + * stable if a translation pushes the row past the dialog width. + */ +.automation-form-row.automation-form-schedule-row { + flex-direction: row; + align-items: flex-end; + flex-wrap: wrap; + gap: 12px; +} + +.automation-form-schedule-group { + display: flex; + flex-direction: column; + gap: 4px; + /* + * Each visible group splits the schedule row equally. Hidden + * groups (`display: none` from `applyIntervalVisibility`) drop out + * of flex distribution, so: + * - manual / hourly → Schedule fills 100% of the row + * - daily → Schedule + Time split 50/50 + * - weekly → Schedule + Time + Day split 33/33/33 + * `min-width: 0` lets the contained SelectBox shrink past its + * intrinsic content width when the row is narrow, before the + * row's `flex-wrap: wrap` kicks in as a final safety net. + */ + flex: 1 1 0; + min-width: 0; +} + +/* SelectBox container sizing. Give Schedule / Time / Day the full + * width of their parent group (which itself flex-shares the row). + * + * `.monaco-select-box` ships with no intrinsic padding or height (the + * only baked-in metrics live in the `.monaco-action-bar .action-item` + * selector, which we're not inside of). Mirror the standalone-form + * pattern used by the settings editor (`settingsEditor2.css:689-694`). + * It uses `height: 26px` and `padding: 2px 6px`. This makes the dropdowns match the + * Name InputBox above them instead of reading as toolbar widgets + * crammed into a form. */ +.automation-form-schedule-select-container { + display: flex; + min-width: 0; + width: 100%; +} + +.automation-form-schedule-select-container .monaco-select-box { + height: 26px; + padding: 2px 8px; +} + +/* + * Form-row labels read as section titles ("Name", "Schedule", "Time", + * "Day of week", "Prompt"). Mirror the established agents + * design-system section-label pattern (see `.overview-section + * .section-label` at line ~734: label1 / fontWeight-medium / + * foreground) so the labels carry visual weight as section headings + * and clearly out-rank their controls. + * + * `margin-bottom` adds breathing room between the title text and + * its control so each form row reads as a discrete section. + */ +.automation-form-label { + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); + line-height: 1.4; + margin-bottom: 2px; +} + +.automation-form-hint { + font-size: var(--vscode-agents-fontSize-body2, 11px); + color: var(--vscode-descriptionForeground); + line-height: 1.4; + min-height: 1em; +} + +.automation-form-checkbox-label { + font-size: var(--vscode-agents-fontSize-body1, 13px); + color: var(--vscode-foreground); + cursor: pointer; +} + +/* + * Host for the native `InputBox` widget. The widget owns its own + * `` and styling (border, padding, focus ring); the host just + * lets it grow to the row width. + */ +.automation-form-input-host { + display: flex; + width: 100%; +} + +.automation-form-input-host > .monaco-inputbox { + flex: 1 1 auto; + min-width: 0; +} + +.automation-form-input, +.automation-form-select, +.automation-form-textarea { + font-family: inherit; + font-size: var(--vscode-agents-fontSize-body1, 13px); + color: var(--vscode-settings-textInputForeground, var(--vscode-input-foreground)); + background-color: var(--vscode-settings-textInputBackground, var(--vscode-input-background)); + border: 1px solid var(--vscode-settings-textInputBorder, var(--vscode-input-border, var(--vscode-contrastBorder, transparent))); + border-radius: 2px; + padding: 4px 6px; + box-sizing: border-box; + width: 100%; + min-height: 26px; +} + +.automation-form-select { + color: var(--vscode-settings-dropdownForeground, var(--vscode-dropdown-foreground)); + background-color: var(--vscode-settings-dropdownBackground, var(--vscode-dropdown-background)); + border-color: var(--vscode-settings-dropdownBorder, var(--vscode-dropdown-border, var(--vscode-contrastBorder, transparent))); +} + +.automation-form-textarea { + resize: vertical; + min-height: 60px; + line-height: 1.4; +} + +.automation-form-input:focus, +.automation-form-select:focus, +.automation-form-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-color: var(--vscode-focusBorder); +} + +.automation-form-empty { + font-size: 12px; + color: var(--vscode-errorForeground); + padding: 6px 0; +} + +/* + * Folder picker. This uses the shared `WorkspacePicker` from the sessions + * layer (see `sessionWorkspacePicker.ts`). The picker renders an + * `` chip trigger inside + * `.sessions-chat-picker-slot.sessions-chat-workspace-picker`. We restyle + * it to fit the dialog's compact, 13px form scale. The upstream styling + * lives in `sessions/contrib/chat/browser/media/chatWidget.css` and is + * sized for the new-session "hero" UI (18px font, larger chip) which is + * too large for our modal. + * + * The dropdown content itself renders through `IActionWidgetService`, + * which has its own global CSS. No overrides needed here. + */ +/* + * Isolation + branch group: parented into the chat input's + * `.chat-secondary-toolbar` so it sits at the bottom-right of the chat + * input, sharing a row with `Copilot CLI | Default Approvals`. + * `margin-left: auto` pushes the group to the right edge within that + * flex row. + * + * Visual styling mirrors the new-session view's + * `.new-chat-bottom-container` chip vocabulary (see + * `sessions/contrib/chat/browser/media/chatWidget.css` lines 178-209): + * 16px tall, 11px font, 12px codicons, icon-foreground color, no + * border. The `|` divider between the Folder chip and the branch slot + * uses the same `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` + * trick the new-session row uses to draw a 1px vertical separator + * without adding a DOM element. + * + * Communicates *where* the automation will run. It is presentational only (no + * click handler, no keyboard target). + */ +.automation-form-prompt-host .chat-secondary-toolbar .automation-form-isolation-group { + display: inline-flex; + align-items: center; + margin-left: auto; + min-width: 0; + gap: 5px; +} + +.automation-form-prompt-host .automation-form-isolation-chip, +.automation-form-prompt-host .automation-form-branch-slot { + display: inline-flex; + align-items: center; + gap: 4px; + height: 16px; + padding: 3px 6px; + font-size: 11px; + color: var(--vscode-icon-foreground); + background: transparent; + border: none; + min-width: 0; +} + +.automation-form-prompt-host .automation-form-isolation-chip > .codicon, +.automation-form-prompt-host .automation-form-branch-slot > .codicon { + font-size: 12px; + color: var(--vscode-icon-foreground); + flex-shrink: 0; +} + +.automation-form-prompt-host .automation-form-isolation-chip { + cursor: pointer; + border-radius: var(--vscode-cornerRadius-small); +} + +.automation-form-prompt-host .automation-form-isolation-chip:not(.automation-form-isolation-chip-disabled):hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.automation-form-prompt-host .automation-form-isolation-chip-disabled { + cursor: default; +} + +.automation-form-prompt-host .automation-form-isolation-label, +.automation-form-prompt-host .automation-form-branch-name { + font-size: 11px; +} + +.automation-form-prompt-host .automation-form-branch-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mirror the new-session `|` divider between repo-config chips: + * `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` paints + * a 1px-wide bar in the gap to the chip's left. */ +.automation-form-prompt-host .automation-form-isolation-group > .automation-form-branch-slot { + box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); +} + +.automation-form-prompt-host .chat-secondary-toolbar .automation-form-harness-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 16px; + padding: 3px 6px; + font-size: 11px; + color: var(--vscode-icon-foreground); + background: transparent; + border: none; + opacity: 0.6; + cursor: default; +} + +.automation-form-prompt-host .automation-form-harness-chip > .codicon { + font-size: 12px; + flex-shrink: 0; +} + +.automation-form-prompt-host .automation-form-harness-label { + font-size: 11px; +} + +/* Missing-branch state (no folder / no git repo / detached / empty): use + * description-foreground + italic so it visually reads as "informational + * placeholder, not a real ref name". */ +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing, +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing > .codicon { + color: var(--vscode-descriptionForeground); +} + +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing .automation-form-branch-name { + font-style: italic; +} + +.automations-list-widget .automations-row-folder { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index cd68c26ea9c..a58862c2dcc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -12,12 +12,12 @@ import { basename, dirname } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IAICustomizationWorkspaceService, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, applySourceFilter, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomAgent, IPromptsService, matchesSessionType, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { ICustomizationItem, ICustomizationItemProvider, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; +import { ICustomizationItem, ICustomizationItemProvider, ICustomizationSourceFolder, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -31,6 +31,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt readonly onDidChange: Event; constructor( + private readonly getActiveDescriptor: () => IHarnessDescriptor, @IPromptsService private readonly promptsService: IPromptsService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IProductService private readonly productService: IProductService, @@ -67,7 +68,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt return folders.map(folder => ({ uri: folder.uri, label: this.promptsService.getPromptLocationLabel(folder), - source: folder.storage })); } @@ -181,7 +181,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); } - return this.applyBuiltinGroupKeys(items, extensionInfoByUri); + return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); } private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { @@ -330,4 +330,12 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } + private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): readonly ICustomizationItem[] { + const descriptor = this.getActiveDescriptor(); + const filter = descriptor.getStorageSourceFilter(promptType); + const items = applySourceFilter(groupedItems, filter); + + return items; + } + } diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index f1b1091c3d3..e3fec1e1a88 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -39,6 +39,23 @@ export interface IWorkspacePickerItem { readonly isFolder: boolean; } +/** + * Narrow contract for a workspace picker hosted as a chip in + * {@link ChatInputPart}'s primary toolbar. Implementers own all selection + * state; the chip is purely a rendering / interaction surface. + * + * Used by the automations dialog so a single picker instance can drive both + * the form-row trigger and a toolbar chip without duplicating state. + */ +export interface IChatInputWorkspacePicker { + /** + * Renders a trigger chip into the given container. The returned disposable + * removes only this trigger; sibling triggers (e.g. the form-row trigger + * in the automations dialog) keep working. + */ + renderTrigger(container: HTMLElement): IDisposable; +} + /** * Delegate interface for the workspace picker. * Allows consumers to get and set the target workspace for chat submissions in empty window contexts. @@ -103,6 +120,14 @@ export interface ISessionTypePickerDelegate { * Used to gate cloud delegation which requires a GitHub repository. */ hasGitRepository?(): boolean; + /** + * Optional visibility filter for the session-type dropdown. When + * provided, the picker hides any session type for which this returns + * `false`. Use to scope the dropdown to a host-specific subset (e.g. + * the automations dialog, which only supports a handful of session + * types). When omitted, every contributed session type is shown. + */ + isSessionTypeVisible?(type: AgentSessionTarget): boolean; } export const IChatWidgetService = createDecorator('chatWidgetService'); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index cc9af9d6739..0e98bbcf5c2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -961,6 +961,12 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return provider.provideChatInputCompletions(sessionResource, params, token); } + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string { + const sessionType = getChatSessionType(sessionResource); + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; + return this._contentProviders.get(resolvedType)?.resolveChatResponseUri?.(sessionResource, href, kind) ?? href; + } + async getChatInputCompletionTriggerCharacters(sessionType: string): Promise { const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const provider = this._contentProviders.get(resolvedType); diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 46cc7a297ab..6e856b17650 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -7,6 +7,8 @@ import { Action } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationError } from '../../../../base/common/errors.js'; +import { untildify } from '../../../../base/common/labels.js'; +import { posix, win32 } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -17,6 +19,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../common/constants.js'; import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; @@ -36,6 +39,7 @@ export class PluginInstallService implements IPluginInstallService { @ICommandService private readonly _commandService: ICommandService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IPathService private readonly _pathService: IPathService, ) { } async installPlugin(plugin: IMarketplacePlugin): Promise { @@ -58,60 +62,29 @@ export class PluginInstallService implements IPluginInstallService { return this._installGitPlugin(plugin); } - async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise { - const reference = parseMarketplaceReference(source); - if (!reference) { - this._notificationService.notify({ - severity: Severity.Error, - message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source), - }); - return; - } - - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - this._notificationService.notify({ - severity: Severity.Error, - message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."), - }); - return; - } - - const result = await this._doInstallFromSource(reference, options); - if (!result.success && result.message) { - this._notificationService.notify({ - severity: Severity.Error, - message: result.message, - }); - } - } - validatePluginSource(source: string): string | undefined { const reference = parseMarketplaceReference(source); - if (!reference) { - return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source); + if (reference || this._isLocalPathSource(source)) { + return undefined; } - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - return localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."); - } - return undefined; + return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", source); } - async installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise { + async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise { const reference = parseMarketplaceReference(source); - if (!reference) { - return { - success: false, - message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source), - }; - } - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - return { - success: false, - message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."), - }; + if (reference && reference.kind !== MarketplaceReferenceKind.LocalFileUri) { + return this._doInstallFromSource(reference, options); } - return this._doInstallFromSource(reference, options); + const local = await this._resolveLocalDirectorySource(source); + if (local) { + return this._doInstallFromLocalSource(local.reference, local.configPath, options); + } + + return { + success: false, + message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", source), + }; } private async _doInstallFromSource(reference: IMarketplaceReference, options?: IInstallPluginFromSourceOptions): Promise { @@ -191,6 +164,79 @@ export class PluginInstallService implements IPluginInstallService { } // When targeting a specific plugin, find it, register it, and return. + return this._installDiscoveredPlugins(reference, discoveredPlugins, options); + } + + /** + * Installs a plugin from a local folder path (`file://` URI, absolute path, + * or `~`-prefixed path). Inspects the directory to decide whether it is a + * marketplace or a standalone plugin and writes to the appropriate setting: + * - a marketplace is registered under `chat.plugins.marketplaces`, + * - a standalone plugin path is registered under `chat.pluginLocations`. + */ + private async _doInstallFromLocalSource(reference: IMarketplaceReference, configPath: string, options?: IInstallPluginFromSourceOptions): Promise { + const repoDir = reference.localRepositoryUri; + if (!repoDir) { + return { + success: false, + message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", reference.rawValue), + }; + } + + let isDirectory = false; + try { + isDirectory = (await this._fileService.resolve(repoDir)).isDirectory; + } catch { + // resolve throws when the path doesn't exist — handled below. + } + if (!isDirectory) { + return { + success: false, + message: localize('localSourceNotFound', "The folder '{0}' does not exist or is not a directory.", repoDir.fsPath), + }; + } + + // A directory with a marketplace index is registered as a marketplace. + const discoveredPlugins = await this._pluginMarketplaceService.readPluginsFromDirectory(repoDir, reference); + if (discoveredPlugins.length > 0) { + // Verify trust before writing to config, mirroring the git path + // (_doInstallFromSource): declining the prompt must not persist the + // marketplace under `chat.plugins.marketplaces`. + const tempPlugin: IMarketplacePlugin = { + name: reference.displayLabel, + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: reference.displayLabel, + marketplaceReference: reference, + marketplaceType: MarketplaceType.OpenPlugin, + }; + if (!await this._ensureMarketplaceTrusted(tempPlugin)) { + return { success: false }; + } + return this._installDiscoveredPlugins(reference, discoveredPlugins, options); + } + + // Otherwise, a directory with a single-plugin manifest is registered as + // a standalone plugin location. + if (await this._pluginMarketplaceService.isPluginDirectory(repoDir)) { + await this._addPluginLocationToConfig(configPath); + return { success: true }; + } + + return { + success: false, + message: localize('localNoPlugins', "No plugin or marketplace found in '{0}'. This folder does not contain a plugin or marketplace manifest.", repoDir.fsPath), + }; + } + + /** + * Registers the marketplace and installs the discovered plugin(s): when a + * specific plugin is targeted it installs that one, when there is exactly + * one it installs it directly, and otherwise prompts the user to choose. + */ + private async _installDiscoveredPlugins(reference: IMarketplaceReference, discoveredPlugins: readonly IMarketplacePlugin[], options?: IInstallPluginFromSourceOptions): Promise { if (options?.plugin) { const matchedPlugin = discoveredPlugins.find(p => p.name === options.plugin); if (!matchedPlugin) { @@ -241,6 +287,73 @@ export class PluginInstallService implements IPluginInstallService { return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...userValues, reference.rawValue]); } + private _addPluginLocationToConfig(pathKey: string) { + const current = this._configurationService.inspect>(ChatConfiguration.PluginLocations).userValue ?? {}; + if (current[pathKey] === true) { + return; + } + return this._configurationService.updateValue(ChatConfiguration.PluginLocations, { ...current, [pathKey]: true }); + } + + /** + * Returns `true` when the source string looks like a local folder path — + * a `file://` URI, an absolute filesystem path, or a `~`-prefixed path. + * This is a synchronous format check only; existence is verified later. + */ + private _isLocalPathSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed) { + return false; + } + if (/^file:\/\//i.test(trimmed)) { + return true; + } + if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return true; + } + return win32.isAbsolute(trimmed) || posix.isAbsolute(trimmed); + } + + /** + * Resolves a local folder source string to a {@link MarketplaceReferenceKind.LocalFileUri} + * reference plus the path to persist in `chat.pluginLocations`. Tilde paths + * are expanded against the user home. Returns `undefined` when the string + * does not resolve to an absolute local folder. + */ + private async _resolveLocalDirectorySource(source: string): Promise<{ reference: IMarketplaceReference; configPath: string } | undefined> { + const trimmed = source.trim(); + + // Already a `file://` URI — parseMarketplaceReference yields a LocalFileUri reference. + const parsed = parseMarketplaceReference(trimmed); + if (parsed?.kind === MarketplaceReferenceKind.LocalFileUri && parsed.localRepositoryUri) { + return { reference: parsed, configPath: parsed.localRepositoryUri.fsPath }; + } + + if (!this._isLocalPathSource(trimmed)) { + return undefined; + } + + let resolvedPath = trimmed; + if (resolvedPath.startsWith('~')) { + const userHome = await this._pathService.userHome(); + const home = userHome.scheme === 'file' ? userHome.fsPath : userHome.path; + resolvedPath = untildify(resolvedPath, home); + } + + if (!win32.isAbsolute(resolvedPath) && !posix.isAbsolute(resolvedPath)) { + return undefined; + } + + const reference = parseMarketplaceReference(URI.file(resolvedPath).toString()); + if (reference?.kind !== MarketplaceReferenceKind.LocalFileUri) { + return undefined; + } + + // Preserve the user's original path form (e.g. `~/plugins/foo`) so that + // the persisted `chat.pluginLocations` key stays portable. + return { reference, configPath: trimmed }; + } + async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise { const kind = plugin.sourceDescriptor.kind; diff --git a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts index 54beb11dc68..b14379fa33b 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts @@ -13,6 +13,7 @@ import { ConfigurationTarget, IConfigurationService } from '../../../../platform import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IURLHandler, IURLService } from '../../../../platform/url/common/url.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -46,6 +47,7 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @IHostService private readonly _hostService: IHostService, @ILogService private readonly _logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, @IEditorService private readonly _editorService: IEditorService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { @@ -110,7 +112,10 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi return this._handleInstallTargetedPlugin(source, ref.displayLabel, pluginName); } - await this._pluginInstallService.installPluginFromSource(source); + const result = await this._pluginInstallService.installPluginFromSource(source); + if (!result.success && result.message) { + this._notificationService.notify({ severity: Severity.Error, message: result.message }); + } this._extensionsWorkbenchService.openSearch(`@agentPlugins ${ref.displayLabel}`); return true; } @@ -121,7 +126,7 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi * then opens the plugin details in a modal editor. */ private async _handleInstallTargetedPlugin(source: string, displayLabel: string, pluginName: string): Promise { - const result = await this._pluginInstallService.installPluginFromValidatedSource(source, { plugin: pluginName }); + const result = await this._pluginInstallService.installPluginFromSource(source, { plugin: pluginName }); if (!result.success) { if (result.message) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 508ce94fad2..a9ddd487ae6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -47,6 +47,7 @@ import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText } from '.. import { IEditSessionDiffStats, IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; import { IChatContentInlineReference, IChatMarkdownContent, IChatService, IChatUndoStop } from '../../../common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IChatCodeBlockInfo } from '../../chat.js'; @@ -131,6 +132,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP @IInstantiationService private readonly instantiationService: IInstantiationService, @IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService, @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, ) { super(); @@ -223,6 +225,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP breaks: true, }; + const configuredUriTransformer = markdownRenderOptions?.transformUri; + const transformUri = isResponseVM(element) + ? (href: string, kind: 'link' | 'image') => this.chatSessionsService.resolveChatResponseUri(element.sessionResource, configuredUriTransformer?.(href, kind) ?? href, kind) + : configuredUriTransformer; const result = store.add(renderer.render(this.markdown.content, { sanitizerConfig: MarkedKatexSupport.getSanitizerOptions({ allowedTags: allowedChatMarkdownHtmlTags, @@ -356,6 +362,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP markedOptions: markedOpts, markedExtensions, ...markdownRenderOptions, + transformUri, }, this.domNode)); // Ideally this would happen earlier, but we need to parse the markdown. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts new file mode 100644 index 00000000000..36a7fb477bc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatMcpServersStartingSlow, IChatMcpStartingServer } from '../../../common/chatService/chatService.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart } from './chatContentParts.js'; +import './media/chatMcpServersInteractionContent.css'; + +/** + * Renders a lightweight "Starting MCP servers …" progress hint for agent-host + * sessions. The set of servers still starting is driven by the observable on + * {@link IChatMcpServersStartingSlow.servers}; when it empties (all servers + * started, content began arriving, or the turn ended) the part hides itself. + * There is no interactive affordance — this is a progress indicator only. + */ +export class ChatMcpServersStartingContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly rendered = this._register(new MutableDisposable()); + + constructor( + private readonly data: IChatMcpServersStartingSlow, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + ) { + super(); + this.domNode = dom.$('.chat-mcp-servers-interaction'); + this._register(autorun(reader => { + this.render(this.data.servers.read(reader)); + })); + } + + private render(servers: readonly IChatMcpStartingServer[]): void { + dom.clearNode(this.domNode); + this.rendered.clear(); + + if (!servers.length) { + this.domNode.style.display = 'none'; + return; + } + this.domNode.style.display = ''; + + const links = servers + .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .join(', '); + this._renderMessage( + ThemeIcon.modify(Codicon.loading, 'spin'), + localize('mcp.starting.servers', 'Starting MCP servers {0}...', links), + ); + } + + private _renderMessage(icon: ThemeIcon, content: string): void { + const container = dom.$('.chat-mcp-servers-interaction-hint'); + const messageContainer = dom.$('.chat-mcp-servers-message'); + const iconElement = dom.$('.chat-mcp-servers-icon'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + + const rendered = this.rendered.value = this.markdownRendererService.render(new MarkdownString(content)); + messageContainer.appendChild(iconElement); + messageContainer.appendChild(rendered.element); + container.appendChild(messageContainer); + this.domNode.appendChild(container); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return other.kind === 'mcpServersStartingSlow'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 917f34a30fa..b3dd86890bd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -140,6 +140,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private _useCarouselForConfirmations: boolean = false; private toolsWaitingForCarouselConfirmation: number = 0; + /** Per-tool-invocation autoruns observing tool state; each is disposed once its tool reaches a terminal state so listeners don't accumulate for the widget's lifetime. */ + private readonly _toolStateTracking = this._register(new DisposableStore()); + // Working spinner elements for expanded state private workingSpinnerElement: HTMLElement | undefined; private workingSpinnerLabel: HTMLElement | undefined; @@ -232,13 +235,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } if (!this._openChatToolbar) { const container = $('.chat-subagent-open-chat-toolbar'); - // Sits inside the collapse button (a `ButtonWithIcon`, which activates - // on both click and touch tap); stop propagation for all of those so - // activating the pill opens the chat instead of toggling the section. - this._register(Gesture.addTarget(container)); - this._register(dom.addDisposableListener(container, dom.EventType.MOUSE_DOWN, e => e.stopPropagation())); - this._register(dom.addDisposableListener(container, dom.EventType.CLICK, e => e.stopPropagation())); - this._register(dom.addDisposableListener(container, TouchEventType.Tap, e => e.stopPropagation())); // Before the title label so the pill keeps a fixed position as the title streams. this._collapseButton.element.insertBefore(container, this._collapseButton.labelElement); this._openChatToolbarContainer = container; @@ -247,6 +243,15 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen menuOptions: { shouldForwardArgs: true }, toolbarOptions: { primaryGroup: () => true }, })); + // Stop propagation on the pill (the toolbar element) so activating it + // opens the subagent chat without also toggling the section; clicks on + // the sibling prefix / empty row space still fall through to the collapse + // button and toggle. Gesture target covers touch tap. + const pill = this._openChatToolbar.getElement(); + this._register(Gesture.addTarget(pill)); + this._register(dom.addDisposableListener(pill, dom.EventType.MOUSE_DOWN, e => e.stopPropagation())); + this._register(dom.addDisposableListener(pill, dom.EventType.CLICK, e => e.stopPropagation())); + this._register(dom.addDisposableListener(pill, TouchEventType.Tap, e => e.stopPropagation())); } // The contributed action reads the subagent chat resource (and the agent // name, shown as the pill prefix in the Agents window) from the forwarded @@ -740,7 +745,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen let wasWaitingForConfirmation = false; let wasWaitingForCarouselConfirmation = false; - this._register(autorun(r => { + const toolStateAutorun = autorun(r => { const state = toolInvocation.state.read(r); const isWaitingForConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || @@ -783,7 +788,13 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen wasWaitingForConfirmation = isWaitingForConfirmation; wasWaitingForCarouselConfirmation = isWaitingForCarouselConfirmation; - })); + + // On terminal state, dispose this autorun (deferred so we don't dispose it mid-run) to avoid leaking a listener per tool invocation. + if (state.type === IChatToolInvocation.StateKind.Completed || state.type === IChatToolInvocation.StateKind.Cancelled) { + queueMicrotask(() => this._toolStateTracking.delete(toolStateAutorun)); + } + }); + this._toolStateTracking.add(toolStateAutorun); } private getConfirmationPlaceholderText(): string { @@ -1226,7 +1237,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Dynamically add/remove icon based on confirmation state if (toolInvocation.kind === 'toolInvocation') { const shouldUseCarouselForTool = this._shouldUseCarouselForTool; - this._register(autorun(r => { + const iconAutorun = autorun(r => { const state = toolInvocation.state.read(r); const hasConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; @@ -1248,7 +1259,13 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.ensurePlaceholderAtBottom(); } } - })); + + // Terminal state is final and settles into the non-confirmation branch above, so dispose (deferred so we don't dispose it mid-run) to avoid leaking a listener per tool invocation. + if (state.type === IChatToolInvocation.StateKind.Completed || state.type === IChatToolInvocation.StateKind.Cancelled) { + queueMicrotask(() => this._toolStateTracking.delete(iconAutorun)); + } + }); + this._toolStateTracking.add(iconAutorun); } else { // For serialized invocations, always show icon (already completed) itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts new file mode 100644 index 00000000000..92f4e85b25d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatSystemNotificationPart } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart } from './chatContentParts.js'; +import { ChatProgressSubPart } from './chatProgressContentPart.js'; + +export class ChatSystemNotificationContentPart extends Disposable implements IChatContentPart { + readonly domNode: HTMLElement; + + constructor( + private readonly notification: IChatSystemNotificationPart, + renderer: IMarkdownRenderer, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const rendered = this._register(renderer.render(notification.content)); + this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined)).domNode; + } + + hasSameContent(other: IChatRendererContent): boolean { + return other.kind === 'systemNotification' && other.content.value === this.notification.content.value; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css index 11f316b28c1..94e4d8a62f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -106,10 +106,11 @@ .chat-subagent-part .chat-subagent-open-chat-toolbar { display: inline-flex; align-items: center; - margin-right: 8px; + margin-right: 2px; flex-shrink: 0; } -.chat-subagent-part .chat-subagent-open-chat-toolbar.hidden { +.chat-subagent-part .chat-subagent-open-chat-toolbar.hidden, +.chat-subagent-part .chat-subagent-open-chat-toolbar.has-no-actions { display: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index 5bd08d1c907..c6d93f1569b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -8,6 +8,50 @@ min-width: 0; } +/* + * Intention label layout: when the model supplies an intention, the collapsed + * terminal-row header shows " " as two flex cells on a + * single non-wrapping line. Each cell ellipsis-truncates and they split the + * available width equally when the content overflows the row. + */ +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .chat-used-context-label, +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .monaco-button.monaco-icon-button { + width: 100%; + max-width: 100%; + justify-content: flex-start; +} + +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .monaco-button-mdlabel { + flex: 1 1 auto; + min-width: 0; +} + +.chat-terminal-label-flex { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: baseline; + column-gap: 4px; + min-width: 0; + width: 100%; +} + +.chat-terminal-label-flex > .chat-terminal-intention, +.chat-terminal-label-flex > .chat-terminal-command { + /* Natural width so short content stays inline and adjacent; only shrink + * (dividing the available space) once the row would overflow. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-terminal-label-flex > .chat-terminal-label-suffix { + flex: 0 0 auto; + white-space: nowrap; +} + .chat-terminal-progress-row > div:first-child { display: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 98bebe70d91..957e53bbbd3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -564,6 +564,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const wrapper = this._register(this._instantiationService.createInstance( ChatTerminalThinkingCollapsibleWrapper, truncatedCommand, + this._terminalData.intention, this._terminalData.commandLine.isSandboxWrapped === true, contentElement, context, @@ -1657,6 +1658,7 @@ class ChatTerminalToolOutputSection extends Disposable { export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart { private readonly _terminalContentElement: HTMLElement; private readonly _commandText: string; + private readonly _intention: string | undefined; private readonly _isSandboxWrapped: boolean; private _isComplete: boolean; private readonly _isSkipped: boolean; @@ -1667,6 +1669,7 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte constructor( commandText: string, + intention: string | undefined, isSandboxWrapped: boolean, contentElement: HTMLElement, context: IChatContentPartRenderContext, @@ -1678,17 +1681,29 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, ) { - const title = isSkipped + // When the model supplied an intention (why it's running the command), + // use it as the descriptive text instead of the generic verb. Skipped + // commands keep the explicit "Skipped" wording since they never ran. + // The intention and command are not localizable, so they are combined + // directly; only the state suffix is externalized. + const intentionText = intention && !isSkipped ? intention : undefined; + const stateTitle = isSkipped ? localize('chat.terminal.skipped.plain', "Skipped {0}", commandText) : isRunningInBackground ? localize('chat.terminal.runningInBackground.plain', "Running {0} in background", commandText) : isComplete ? localize('chat.terminal.ran.plain', "Ran {0}", commandText) : localize('chat.terminal.running.plain', "Running {0}", commandText); + const title = intentionText + ? isRunningInBackground + ? `${intentionText} ${commandText}${localize('chat.terminal.backgroundSuffix', " in background")}` + : `${intentionText} ${commandText}` + : stateTitle; super(title, context, undefined, hoverService, configurationService); this._terminalContentElement = contentElement; this._commandText = commandText; + this._intention = intentionText; this._isSandboxWrapped = isSandboxWrapped; this._isComplete = isComplete; this._isSkipped = isSkipped; @@ -1714,36 +1729,52 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; - if (this._isSandboxWrapped) { - const prefixText = this._isSkipped - ? localize('chat.terminal.skippedInSandbox.prefix', "Skipped ") - : this._isComplete - ? localize('chat.terminal.ranInSandbox.prefix', "Ran ") - : localize('chat.terminal.runningInSandbox.prefix', "Running "); - const suffixText = this._isRunningInBackground + const suffixText = this._isSandboxWrapped + ? this._isRunningInBackground ? localize('chat.terminal.sandbox.backgroundSuffix', " in sandbox (background)") - : localize('chat.terminal.sandbox.suffix', " in sandbox"); - labelElement.appendChild(document.createTextNode(prefixText)); - const codeElement = document.createElement('code'); + : localize('chat.terminal.sandbox.suffix', " in sandbox") + : this._isRunningInBackground + ? localize('chat.terminal.backgroundSuffix', " in background") + : undefined; + + // Intention layout: the intention and the command share the row as two + // flex cells that stay on one line and each ellipsis-truncate, splitting + // the available width equally when the content overflows. + this.domNode.classList.toggle('chat-terminal-has-intention', !!this._intention); + if (this._intention) { + const row = dom.$('span.chat-terminal-label-flex'); + const intentionElement = dom.$('span.chat-terminal-intention'); + intentionElement.textContent = this._intention; + const codeElement = dom.$('code.chat-terminal-command'); codeElement.textContent = this._commandText; - labelElement.appendChild(codeElement); - labelElement.appendChild(document.createTextNode(suffixText)); + row.appendChild(intentionElement); + row.appendChild(codeElement); + if (suffixText) { + const suffixElement = dom.$('span.chat-terminal-label-suffix'); + suffixElement.textContent = suffixText; + row.appendChild(suffixElement); + } + labelElement.appendChild(row); return; } - const prefixText = this._isSkipped - ? localize('chat.terminal.skipped.prefix', "Skipped ") - : this._isComplete - ? localize('chat.terminal.ran.prefix', "Ran ") - : localize('chat.terminal.running.prefix', "Running "); - const ranText = document.createTextNode(prefixText); + const prefixText = this._isSandboxWrapped + ? this._isSkipped + ? localize('chat.terminal.skippedInSandbox.prefix', "Skipped ") + : this._isComplete + ? localize('chat.terminal.ranInSandbox.prefix', "Ran ") + : localize('chat.terminal.runningInSandbox.prefix', "Running ") + : this._isSkipped + ? localize('chat.terminal.skipped.prefix', "Skipped ") + : this._isComplete + ? localize('chat.terminal.ran.prefix', "Ran ") + : localize('chat.terminal.running.prefix', "Running "); + labelElement.appendChild(document.createTextNode(prefixText)); const codeElement = document.createElement('code'); codeElement.textContent = this._commandText; - - labelElement.appendChild(ranText); labelElement.appendChild(codeElement); - if (this._isRunningInBackground) { - labelElement.appendChild(document.createTextNode(localize('chat.terminal.backgroundSuffix', " in background"))); + if (suffixText) { + labelElement.appendChild(document.createTextNode(suffixText)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index cfaa8fe4470..0ad16aeb913 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -88,13 +88,15 @@ import { ChatExtensionsContentPart } from './chatContentParts/chatExtensionsCont import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatContentParts/chatMarkdownContentPart.js'; import { ChatMcpServersInteractionContentPart } from './chatContentParts/chatMcpServersInteractionContentPart.js'; import { ChatMcpAuthenticationContentPart } from './chatContentParts/chatMcpAuthenticationContentPart.js'; +import { ChatMcpServersStartingContentPart } from './chatContentParts/chatMcpServersStartingContentPart.js'; import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js'; import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js'; -import { ChatProgressContentPart, ChatProgressSubPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; +import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js'; import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; +import { ChatSystemNotificationContentPart } from './chatContentParts/chatSystemNotificationContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart, getEffectiveThinkingDisplayMode } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; @@ -229,6 +231,10 @@ export function shouldScheduleInitialHeightChange(normalizedHeight: number, allo return typeof allocatedHeight !== 'number' || normalizedHeight > allocatedHeight; } +export function shouldRenderInitialProgressiveContentImmediately(isComplete: boolean, hasMarkdownParts: boolean, hasRenderData: boolean): boolean { + return !isComplete && hasMarkdownParts && !hasRenderData; +} + const forceVerboseLayoutTracing = false // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed ; @@ -1319,6 +1325,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'markdownContent' && part.content.value.trim().length > 0); - if (!element.isComplete && hasMarkdownParts && (element.contentUpdateTimings ? element.contentUpdateTimings.lastWordCount : 0) === 0) { + if (shouldRenderInitialProgressiveContentImmediately(element.isComplete, hasMarkdownParts, element.renderData !== undefined)) { /** - * None of the content parts in the ongoing response have been rendered yet, + * None of the markdown in the ongoing response has been rendered yet, * so we should render all existing parts without animation. */ return { @@ -2431,6 +2438,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer widget === newWidget)) { throw new Error('Cannot register the same widget multiple times'); @@ -253,6 +277,7 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => { if (this._lastFocusedWidget === newWidget && !isEqual(previousSessionResource, currentSessionResource)) { + this.recordLastUsedSessionType(newWidget); this._onDidChangeFocusedSession.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 477705ce06a..67c41db2c92 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -8,6 +8,7 @@ import { addDisposableListener } from '../../../../../../base/browser/dom.js'; import { DEFAULT_FONT_FAMILY } from '../../../../../../base/browser/fonts.js'; import { IHistoryNavigationWidget } from '../../../../../../base/browser/history.js'; import { hasModifierKeys, StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { IActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import * as aria from '../../../../../../base/browser/ui/aria/aria.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; @@ -66,6 +67,7 @@ import { WorkbenchList } from '../../../../../../platform/list/browser/listServi import { canLog, ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; @@ -92,12 +94,12 @@ import { ChatModelConfigurationStore } from './chatModelConfigurationStore.js'; import { deserializeUntitledInputAttachments, deserializeUntitledInputState, serializeUntitledInputAttachments, serializeUntitledInputState } from './chatInputStatePersistence.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel, logChangesToStateModel } from '../../../common/model/chatModel.js'; import { filterModelsForSession, findBestMatchingModel, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, mergeModelsWithCache, resolveConfiguredModel, resolveModelFromSyncState, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel } from './chatModelSelectionLogic.js'; -import { chatSessionResourceToId, getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; -import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenAutomationsWorkspacePickerAction, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; @@ -105,7 +107,7 @@ import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentW import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate, IChatInputWorkspacePicker } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; @@ -135,15 +137,17 @@ import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate } from './modelPickerActionItem.js'; -import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; +import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; +import { WorkspacePickerInputActionItem } from './workspacePickerInputActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { ConfigureToolsAction } from '../../actions/chatToolActions.js'; import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { PlaceholderTextContribution } from '../../../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; const $ = dom.$; @@ -188,6 +192,44 @@ export interface IChatInputPartOptions { * for their chat request. This is useful for empty window contexts. */ workspacePickerDelegate?: IWorkspacePickerDelegate; + /** + * Optional workspace picker rendered as a chip in the PRIMARY toolbar + * (between the mode picker and the model picker). Used by the automations + * dialog so its existing `WorkspacePicker` instance can drive both the + * form-row trigger and a toolbar chip from a single source of truth. + * Gated by {@link ChatContextKeys.inAutomationsDialog} on the menu side + * and a defensive null-check in `actionViewItemProvider`. + */ + workspacePickerInput?: IChatInputWorkspacePicker; + /** + * Optional action view item provider for host-owned secondary toolbar + * chips registered on {@link MenuId.ChatInputSecondary}. Used by the + * automations dialog so per-instance state can stay outside the shared + * chat input part while still using menu-driven rendering. + */ + secondaryToolbarActionViewItemProvider?: (action: IAction, options?: IActionViewItemOptions) => IActionViewItem | undefined; + /** + * When true, the mode picker hides custom agents and only offers the + * built-in modes (Agent / Ask / Edit / Plan, gated by their normal + * visibility rules). Custom-agent discovery is workspace-scoped and + * doesn't follow the dialog's folder selection, so surfacing custom + * agents tied to the workbench's open folders would mislead the user + * when scheduling against a different folder. + */ + hideCustomChatModes?: boolean; + /** + * When true, suppress the autorun that switches the current language + * model to a mode's declared preferred model (`IChatMode.model`). + * Used by the automations dialog so opening "New Automation" always + * defaults to the picker's default (auto) regardless of which mode + * the dialog opens with, and so the dialog never overwrites the + * regular chat input's persisted model selection via the shared + * APPLICATION-scope storage key. + * + * User-initiated model picks (clicking the model picker, cycle + * keybindings, etc.) are unaffected. + */ + suppressModePreferredModel?: boolean; /** * Whether we are running in the sessions window. * When true, the secondary toolbar (permissions picker) is hidden. @@ -211,23 +253,39 @@ export interface IChatWidgetLocationInfo { readonly isMaximized: boolean; } -const emptyInputState = observableMemento({ - defaultValue: undefined, - key: 'chat.untitledInputState', - toStorage: serializeUntitledInputState, - fromStorage(value) { - const obj = deserializeUntitledInputState(value); - if (obj.selectedModel && !obj.selectedModel.metadata.isDefaultForLocation) { - // Migrate old `isDefault` to `isDefaultForLocation` - type OldILanguageModelChatMetadata = ILanguageModelChatMetadata & { isDefault?: boolean }; - const oldIsDefault = (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; - const isDefaultForLocation = { [ChatAgentLocation.Chat]: Boolean(oldIsDefault) }; - mixin(obj.selectedModel.metadata, { isDefaultForLocation: isDefaultForLocation } satisfies Partial); - delete (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; - } - return obj; - }, -}); +const LEGACY_SHARED_INPUT_STATE_TAGS = new Set(['view', 'editor', 'quick']); + +function getInputStateStorageKey(widgetViewKindTag: string): string { + // Legacy tags (the original chat composer surfaces) historically shared + // a single storage key. Keep them on that key so we don't invalidate + // existing user drafts. New surfaces (e.g. the automations dialog) get + // a per-tag key so their input state does not bleed into or out of the + // chat composer. + if (LEGACY_SHARED_INPUT_STATE_TAGS.has(widgetViewKindTag)) { + return 'chat.untitledInputState'; + } + return `chat.untitledInputState.${widgetViewKindTag}`; +} + +function createEmptyInputStateMemento(widgetViewKindTag: string) { + return observableMemento({ + defaultValue: undefined, + key: getInputStateStorageKey(widgetViewKindTag), + toStorage: serializeUntitledInputState, + fromStorage(value) { + const obj = deserializeUntitledInputState(value); + if (obj.selectedModel && !obj.selectedModel.metadata.isDefaultForLocation) { + // Migrate old `isDefault` to `isDefaultForLocation` + type OldILanguageModelChatMetadata = ILanguageModelChatMetadata & { isDefault?: boolean }; + const oldIsDefault = (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; + const isDefaultForLocation = { [ChatAgentLocation.Chat]: Boolean(oldIsDefault) }; + mixin(obj.selectedModel.metadata, { isDefaultForLocation: isDefaultForLocation } satisfies Partial); + delete (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; + } + return obj; + }, + }); +} const emptyInputAttachments = observableMemento({ defaultValue: [], @@ -317,6 +375,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly inputEditorMaxHeight: number; private readonly inputEditorMinHeight: number | undefined; + private readonly singleLineInputEditorHeight: number; private inputEditorHeight: number = 0; private _maxHeight: number | undefined; private container!: HTMLElement; @@ -622,6 +681,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, @IChatPhoneInputPresenter private readonly chatPhoneInputPresenter: IChatPhoneInputPresenter, + @IProductService private readonly productService: IProductService, ) { super(); @@ -636,7 +696,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge logChangesToStateModel(this._inputModel, `[DEBOUNCE] _syncTextDebounced fired -> _syncInputStateToModel in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this._syncInputStateToModel(); }, 150)); - this._emptyInputState = this._register(emptyInputState(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); + this._emptyInputState = this._register(createEmptyInputStateMemento(this.options.widgetViewKindTag)(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); this._emptyInputAttachments = this._register(emptyInputAttachments(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); @@ -707,6 +767,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; const padding = this.options.renderStyle === 'compact' ? INPUT_EDITOR_PADDING.compact : INPUT_EDITOR_PADDING.default; + this.singleLineInputEditorHeight = INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom; this.inputEditorMinHeight = this.options.inputEditorMinLines ? this.options.inputEditorMinLines * INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom : undefined; this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); @@ -876,6 +937,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const mode = this._currentModeObservable.read(r); this.chatModeKindKey.set(mode.kind); this.chatModeNameKey.set(mode.name.read(r)); + if (this.options.suppressModePreferredModel) { + return; + } const models = mode.model?.read(r); if (models) { this.switchModelByQualifiedName(models); @@ -922,6 +986,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge || hasModelsTargetingSession(this.getAllMergedModels(), sessionType); } + /** + * Returns the persisted model for the current session type, if it exists in the current model pool. + */ + private _getRememberedSessionTypeModel(): ILanguageModelChatMetadataAndIdentifier | undefined { + const sessionType = this._currentSessionType; + if (!sessionType || !this.sessionTypeHasOwnModelPool(sessionType)) { + return undefined; + } + const persisted = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); + if (!persisted) { + return undefined; + } + return this.getModels().find(m => m.identifier === persisted); + } + private initSelectedModel() { // initSelectedModel is scoped to the current storage key/session type. // Do not let a delayed restore from a previous session type apply later. @@ -1006,12 +1085,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Switch to a model by its identifier. Returns true if a matching model * was found and applied. + * + * `storeSelection` mirrors the same opt-out on {@link setChatMode}: when + * `false`, the choice is applied to the input only and is NOT persisted + * to the workbench-global "last used model" key. Surfaces that pre-seed + * a model on behalf of the user (e.g. the automations dialog opening an + * existing automation) pass `false` so they don't pollute the regular + * chat input's persisted selection. */ - public switchModelByIdentifier(identifier: string): boolean { + public switchModelByIdentifier(identifier: string, storeSelection: boolean = true): boolean { const models = this.getModels(); const model = models.find(m => m.identifier === identifier); if (model) { - this.setCurrentLanguageModel(model); + this.setCurrentLanguageModel(model, false, storeSelection); return true; } return false; @@ -1119,10 +1205,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showAutoModel: () => this._showAutoModel(), - getChatSessionId: () => { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - return sessionResource ? chatSessionResourceToId(sessionResource) : undefined; - }, modelConfiguration: this._modelConfigStore, }; } @@ -1158,10 +1240,41 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _createModePickerDelegate(): IModePickerDelegate { + // When `hideCustomChatModes` is set (e.g. the automations dialog), + // strip genuinely user-defined custom agents from the picker + // while preserving extension-contributed modes (Plan / new-Ask / + // new-Edit) that the picker categorises as built-in via + // `isModeConsideredBuiltIn`. Those live in `IChatModes.custom` but + // are part of the built-in product surface, not the + // folder-scoped agent files we want to hide. The underlying + // observable is untouched so mode validation, model picking and + // persistence continue to see the real list. + const productService = this.productService; + const currentChatModes: IObservable = this.options.hideCustomChatModes + ? derived(reader => { + const inner = this._currentChatModesObservable.read(reader); + const filteredCustom = inner.custom.filter(m => isModeConsideredBuiltIn(m, productService)); + const wrapped: IChatModes = { + onDidChange: inner.onDidChange, + builtin: inner.builtin, + custom: filteredCustom, + findModeById: (id: string) => inner.builtin.find(m => m.id === id) ?? filteredCustom.find(m => m.id === id), + findModeByName: (name: string) => inner.builtin.find(m => m.name.read(undefined) === name) ?? filteredCustom.find(m => m.name.read(undefined) === name), + waitForPendingUpdates: () => inner.waitForPendingUpdates(), + }; + return wrapped; + }) + : this._currentChatModesObservable; + return { currentMode: this._currentModeObservable, - currentChatModes: this._currentChatModesObservable, + currentChatModes, sessionResource: () => this._widget?.viewModel?.sessionResource, + // Direct setter for hosts that embed `ChatInputPart` without + // registering an `IChatWidget` (e.g. the automations dialog). + // The picker only calls this when `sessionResource()` is + // `undefined`; real chat widgets keep the command path. + setMode: (mode: IChatMode) => this.setChatMode2(mode, true), customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; return (sessionResource && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource))) ?? Target.Undefined; @@ -1349,6 +1462,38 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!state && this._chatSessionIsEmpty) { state = this._getPersistedEmptyInputState(); message = `syncing from empty input state for ${forSessionResource.toString()}`; + // Seed model/config from the last-used selection for this session type (configured default still wins). + const rememberedSessionTypeModel = this._getRememberedSessionTypeModel(); + if (rememberedSessionTypeModel) { + const base = state ?? this.getCurrentInputState(); + const rememberedModelConfiguration = this._modelConfigStore.getModelConfiguration(rememberedSessionTypeModel.identifier); + state = { ...base, selectedModel: rememberedSessionTypeModel, modelConfiguration: rememberedModelConfiguration }; + } + // A configured default model (e.g. set by enterprise policy via + // `chat.defaultModel`) starts every NEW conversation and + // must win over the remembered empty-input draft model. `initSelectedModel` + // only re-runs at construction and on session-type changes, so plain local + // sessions (whose type never changes) would otherwise keep the draft model. + // This override only applies while seeding from the draft. Once the user + // switches the model, `model.state` is set and this branch is skipped, so + // the in-conversation selection is preserved. + if (state && this.getConfiguredModelValue()) { + const configuredModel = this.getConfiguredDefaultModel(this.getModels()); + if (configuredModel) { + if (configuredModel.identifier !== state.selectedModel?.identifier) { + state = { ...state, selectedModel: configuredModel, modelConfiguration: undefined }; + } + } else if (state.selectedModel) { + // A configured default is set but its model is not registered yet + // (e.g. the Copilot extension is still fetching its model list, so + // the synthetic "Auto" model is not in the pool). Drop the remembered + // draft model so `_syncFromModel` is a no-op rather than applying the + // draft and clearing the configured-default wait set up in + // `initSelectedModel`. The wait then applies the configured default + // once the model list settles. + state = { ...state, selectedModel: undefined, modelConfiguration: undefined }; + } + } } // Detect autorun firing for a session that is no longer the widget's // active session - indicates a late/stale model.state.read() landed for @@ -1569,7 +1714,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier, isUserAction = false) { + public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier, isUserAction = false, storeSelection: boolean = true) { if (isUserAction) { // An explicit user pick must survive subsequent automatic re-evaluations // (model-list changes, late configured-default arrival) so the configured @@ -1579,7 +1724,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const modelDetails = this.getModels().map(m => `${m.identifier} (${m.metadata.id})`).join(', '); const selectedModelStorageKey = this.getSelectedModelStorageKey(); const selectedModelIsDefaultStorageKey = this.getSelectedModelIsDefaultStorageKey(); - logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, modelDetials = ${modelDetails}`, undefined, undefined, this.logService); + logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, modelDetials = ${modelDetails}, storeSelection=${storeSelection}`, undefined, undefined, this.logService); this._currentLanguageModel.set(model, undefined); if (this.cachedWidth) { @@ -1587,9 +1732,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.layout(this.cachedWidth); } - // Store as global user preference (session-specific state is in the model's inputModel) - this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); - this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefaultForLocation[this.location], StorageScope.APPLICATION, StorageTarget.USER); + // Store as global user preference (session-specific state is in the model's inputModel). + // `storeSelection: false` lets transient surfaces (e.g. the automations dialog + // applying an automation's saved model on open) apply the model to the input only, + // without overwriting the regular chat input's persisted selection. + if (storeSelection) { + this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); + this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefaultForLocation[this.location], StorageScope.APPLICATION, StorageTarget.USER); + } // Sync to model this._syncInputStateToModel(); @@ -1637,11 +1787,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._currentModeObservable.set(mode, undefined); this._onDidChangeCurrentChatMode.fire(); - // Sync to model (mode is now persisted in the model's input state) - // Log first so the upcoming _syncInputStateToModel write can be attributed - // to a mode change. - logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); - this._syncInputStateToModel(); + if (storeSelection) { + // Sync to model (mode is now persisted in the model's input state) + // Log first so the upcoming _syncInputStateToModel write can be attributed + // to a mode change. + logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); + this._syncInputStateToModel(); + } } /** @@ -1877,7 +2029,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }); } - private setCurrentLanguageModelToDefault(forSessionType?: string) { + private setCurrentLanguageModelToDefault(forSessionType?: string, storeSelection: boolean = true) { // Defer when the session owns a pool but no targeted models have loaded yet; otherwise the general default would contaminate the session-targeted storage key. `resetCurrentLanguageModelIfUnavailable` retries on arrival. const sessionType = forSessionType ?? this.getCurrentSessionType(); if (sessionType @@ -1888,9 +2040,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const allModels = this.getModelsForSessionType(sessionType); const configuredModel = this.getConfiguredDefaultModel(allModels); const defaultModel = configuredModel ?? findDefaultModel(allModels, this.location); - logChangesToStateModel(this._inputModel, `[DEFAULT] setCurrentLanguageModelToDefault called (defaultModel=${defaultModel?.identifier}, configuredModel=${configuredModel?.identifier}, currentLm=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); + logChangesToStateModel(this._inputModel, `[DEFAULT] setCurrentLanguageModelToDefault called (defaultModel=${defaultModel?.identifier}, configuredModel=${configuredModel?.identifier}, currentLm=${this._currentLanguageModel.get()?.identifier}, storeSelection=${storeSelection}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); if (defaultModel) { - this.setCurrentLanguageModel(defaultModel); + this.setCurrentLanguageModel(defaultModel, false, storeSelection); } } @@ -1949,6 +2101,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return true; } + /** Resets the language model to the location default and cancels any pending persisted-model waiter. */ + public resetLanguageModelToDefault(storeSelection: boolean = true): void { + this._waitForPersistedLanguageModel.clear(); + this.setCurrentLanguageModelToDefault(undefined, storeSelection); + } + /** * Get the current input state for history */ @@ -2973,7 +3131,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID, PlaceholderTextContribution.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -3108,6 +3266,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = this._createModePickerDelegate(); return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); + } else if (action.id === OpenAutomationsWorkspacePickerAction.ID && action instanceof MenuItemAction) { + // Toolbar chip for an externally-owned workspace picker + // (today: the automations dialog's single picker instance, + // also rendered in the dialog form row). Defensive guard: + // the menu's `when` clause should already gate visibility, + // but if a host registers the menu contribution without + // supplying the picker we hide rather than throw. + if (this.options.workspacePickerInput) { + return this.instantiationService.createInstance(WorkspacePickerInputActionItem, action, this.options.workspacePickerInput, undefined); + } + return new HiddenActionViewItem(action); } else if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { // Use provided delegate if available, otherwise create default delegate const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { @@ -3242,6 +3411,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getActionMinWidth: action => agentHostShortPickerMinWidths.get(action.id), }, actionViewItemProvider: (action, options) => { + const customSecondaryItem = this.options.secondaryToolbarActionViewItemProvider?.(action, options); + if (customSecondaryItem) { + return customSecondaryItem; + } if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { getActiveSessionProvider: () => { @@ -4393,7 +4566,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const currentEditorHeight = this.previousInputEditorDimension?.height ?? 0; const nonEditorHeight = Math.max(0, this.height.get() - currentEditorHeight); const budgetForEditor = this._maxHeight - nonEditorHeight; - return Math.min(this.inputEditorMaxHeight, Math.max(0, budgetForEditor)); + + // Floor the budget so the editor keeps at least one usable line. See #322523. + const minEditorHeight = this.inputEditorMinHeight ?? this.singleLineInputEditorHeight; + return Math.max(minEditorHeight, Math.min(this.inputEditorMaxHeight, Math.max(0, budgetForEditor))); } private previousInputEditorDimension: IDimension | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index 74195e8684c..c4b8bc0eb3c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -151,6 +151,13 @@ export class ChatSelectedTools extends Disposable { } } for (const toolSet of this._toolsService.getToolSetsForModel(lm, r)) { + // Hidden tool sets (e.g. the built-in client tool sets that only exist to group tools + // in the Chat Customizations UI) can't be toggled here and are ignored by the picker. + // Their member tools are already resolved by the loop above, so skip them entirely - + // otherwise they'd override individual tool state and re-enable disabled tools. + if (toolSet.hiddenInToolsPicker) { + continue; + } const toolSetEnabled = currentMap.toolSets.get(toolSet.id) !== false; // if unknown, it's enabled map.set(toolSet, toolSetEnabled); for (const tool of toolSet.getTools(r)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index d67f8055704..a20bd6748bb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -40,6 +40,8 @@ export interface IModePickerDelegate { readonly currentMode: IObservable; readonly currentChatModes: IObservable; readonly sessionResource: () => URI | undefined; + /** Direct mode-change callback for hosts without a registered IChatWidget (bypasses ToggleAgentModeActionId). */ + readonly setMode?: (mode: IChatMode) => void; /** * When set, the mode picker will show custom agents whose target matches this value. * Custom agents without a target are always shown in all session types. If no agents match the target, shows a default "Agent" option. @@ -136,6 +138,20 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { if (isDisabledViaPolicy) { return; // Block interaction if disabled by policy } + // Session-less hosts (e.g. the automations dialog) provide + // `setMode` and a `sessionResource` that returns undefined. + // Skip the command path because it requires a registered + // `IChatWidget`. Route the change to the host directly so the + // input's mode observable is actually updated. Real chat + // widgets always have a session URI. They always take the + // command path (telemetry, confirmation, new-chat-on-clear). + if (this.delegate.setMode && !this.delegate.sessionResource()) { + this.delegate.setMode(mode); + if (this.element) { + this.renderLabel(this.element); + } + return; + } const result = await commandService.executeCommand( ToggleAgentModeActionId, { modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs @@ -302,7 +318,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } } -function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { +export function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { if (mode.isBuiltin) { return true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index f5b3ad47c46..182ea1fdebb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -28,7 +28,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { URI } from '../../../../../../base/common/uri.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { maybeConfirmElevatedPermissionLevel } from '../../../common/chatPermissionWarnings.js'; -import { AgentSandboxEnabledValue, AgentSandboxSettingId, isAgentSandboxEnabledValue, type AgentSandboxEnabledSettingValue } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentSandboxEnabledSettingValue, AgentSandboxEnabledValue, AgentSandboxSettingId, isAgentSandboxEnabledValue } from '../../../../../../platform/sandbox/common/settings.js'; export interface IExtensionPermissionState { /** Stable identifier for the contributing chat session type, used to namespace action ids. */ @@ -188,9 +188,9 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const policyRestricted = isAutoApprovePolicyRestricted(); const sandboxToggleEnabled = this.isSandboxToggleAvailable(); const setSandboxEnabled = async (enableSandbox: boolean) => { + const target: AgentSandboxEnabledValue = enableSandbox ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; if (this.isSandboxingEnabled() !== enableSandbox) { - const value = enableSandbox ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; - await configurationService.updateValue(getSandboxEnabledSettingId(), value); + await configurationService.updateValue(getSandboxEnabledSettingId(), target); } }; const levels = delegate.availableLevels ?? DEFAULT_PERMISSION_LEVELS; @@ -270,7 +270,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { } private isSandboxingEnabled(): boolean { - const value = this.configurationService.getValue(getSandboxEnabledSettingId()); + const value = this.configurationService.getValue(getSandboxEnabledSettingId()); return isAgentSandboxEnabledValue(value); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index a7fb19353c8..3d02a6b1f20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -228,7 +228,15 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected _isVisible(type: AgentSessionTarget): boolean { - return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService); + // Hide the Extension Host Copilot CLI in the editor picker when configured. + const hideEhCopilotCli = this.configurationService.getValue(ChatConfiguration.CopilotCliHideExtensionHostEditor) ?? false; + if (hideEhCopilotCli && type === AgentSessionProviders.Background) { + return false; + } + + // Defer to the delegate when it has an opinion on visibility. Otherwise + // fall back to the shared editor-visibility check. + return this.delegate.isSessionTypeVisible?.(type) ?? isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService); } protected _isSessionTypeEnabled(type: AgentSessionTarget): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts new file mode 100644 index 00000000000..d83ebb8b225 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IChatInputWorkspacePicker } from '../../chat.js'; + +/** + * Toolbar chip that delegates rendering to an externally-owned + * {@link IChatInputWorkspacePicker}. The picker owns selection state and + * popup behavior; this view item is a thin lifecycle wrapper so the trigger + * participates in `MenuWorkbenchToolBar` responsive overflow, refresh, and + * disposal. + * + * Used by the automations dialog: its single `AutomationsWorkspacePicker` + * instance renders one trigger into the form row and another into the chat + * input's primary toolbar via this view item. + */ +export class WorkspacePickerInputActionItem extends BaseActionViewItem { + + private readonly _triggerDisposable = this._register(new MutableDisposable()); + + constructor( + action: MenuItemAction, + private readonly picker: IChatInputWorkspacePicker, + options?: IBaseActionViewItemOptions, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + // Add `chat-input-picker-item` so the container picks up the standard + // chat input toolbar chip layout (height, padding, dividers) used by + // the Mode and Model picker neighbors. Visual sizing of the inner + // `.sessions-chat-picker-slot` label/icon is overridden in + // `aiCustomizationManagement.css`. The default sessions-layer + // styling targets a 18px welcome-flow chip, which is too big here. + container.classList.add('chat-input-picker-item'); + this._triggerDisposable.value = this.picker.renderTrigger(container); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index d3b1afa932d..f4fa2c80e2c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -16,12 +16,13 @@ import * as nls from '../../../../../../nls.js'; import { ConfirmResult, IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../../common/editor.js'; import { EditorInput, IEditorCloseHandler } from '../../../../../common/editor/editorInput.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatEditorTitleMaxLength, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatEditorTitleMaxLength, ChatLastUsedEditorSessionTypeStorageKey, getDefaultNewChatSessionResource, getDefaultNewChatSessionType, getNewChatEditorSessionResource } from '../../../common/constants.js'; import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; @@ -65,6 +66,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler @IConfigurationService private readonly configurationService: IConfigurationService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -231,7 +233,8 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: true, debugOwner: 'ChatEditorInput#resolveNewLocalSession' }); } } else if (!this.options.target) { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService); + const lastUsedSessionType = this.storageService.get(ChatLastUsedEditorSessionTypeStorageKey, StorageScope.PROFILE); + const defaultResource = getNewChatEditorSessionResource(this.configurationService, this.chatSessionsService, lastUsedSessionType); if (getChatSessionType(defaultResource) === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); } else { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 1018670abac..09b1e6b6c02 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1269,6 +1269,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private layoutingBody = false; private relayout(): void { + if (!this._widget?.visible) { + return; + } + if (this.lastDimensions) { this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 645670edb8f..764b21056f2 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -108,6 +108,7 @@ export namespace ChatContextKeys { export const location = new RawContextKey('chatLocation', undefined); export const inQuickChat = new RawContextKey('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") }); export const inAgentSessionsWelcome = new RawContextKey('inAgentSessionsWelcome', false, { type: 'boolean', description: localize('inAgentSessionsWelcome', "True when the chat input is within the agent sessions welcome page.") }); + export const inAutomationsDialog = new RawContextKey('inAutomationsDialog', false, { type: 'boolean', description: localize('inAutomationsDialog', "True when the chat input is within the automations dialog.") }); export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); export const hasFileAttachments = new RawContextKey('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index e8de3da96f5..bc625c89990 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -8,7 +8,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; -import { IChatPromptSlashCommand } from './promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/promptsService.js'; export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); @@ -41,6 +41,7 @@ export const AICustomizationManagementSection = { Instructions: 'instructions', Prompts: 'prompts', Hooks: 'hooks', + Automations: 'automations', McpServers: 'mcpServers', Plugins: 'plugins', Models: 'models', @@ -53,11 +54,40 @@ export type AICustomizationManagementSection = typeof AICustomizationManagementS * Per-type filter policy controlling which storage sources are visible * for a given customization type. */ +export interface IStorageSourceFilter { + /** + * Which storage groups to display (e.g. workspace, user, extension, builtin). + */ + readonly sources: readonly AICustomizationSource[]; +} + +/** + * Controls which features are shown on the welcome page of the + * AI Customization Management Editor. + */ export interface IWelcomePageFeatures { /** Show the "Configure Your AI" getting-started banner. */ readonly showGettingStartedBanner: boolean; } +/** + * Applies a source filter to an array of items that have uri and source. + * Removes items whose source is not in the filter's source list. + */ +export function applySourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { + const sourceSet = new Set(filter.sources); + return items.filter(item => sourceSet.has(item.source)); +} + +/** + * Applies a storage filter to an array of items that have uri and storage. + * Removes items whose storage is not in the filter's source list. + */ +export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { + const sourceSet = new Set(filter.sources); + return items.filter(item => sourceSet.has(item.storage)); +} + /** * Provides workspace context for AI Customization views. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 722ad58cb6e..9423d75c990 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -259,6 +259,11 @@ export interface IChatProgressMessage { shimmer?: boolean; } +export interface IChatSystemNotificationPart { + content: IMarkdownString; + kind: 'systemNotification'; +} + export interface IChatTask extends IChatTaskDto { deferred: DeferredPromise; progress: (IChatWarningMessage | IChatContentReference)[]; @@ -580,6 +585,12 @@ export interface IChatTerminalToolInvocationData { // isSandboxWrapped boolean to run in the terminal (potentially different from original command) isSandboxWrapped?: boolean; }; + /** + * LM-generated intention describing why the command is being run, shown + * above the command in the terminal tool card. Set by the Agent Host; the + * built-in terminal tool leaves this unset. + */ + intention?: string; /** The working directory URI for the terminal */ cwd?: UriComponents; /** @@ -1209,6 +1220,29 @@ export interface IChatMcpAuthenticationRequiredServer { readonly reason?: string; } +/** + * Surfaced by agent-host sessions when one or more MCP servers are still in the + * {@link McpServerStatus.Starting starting} state a noticeable time after a + * turn began without any content arriving from the host. The part lists the + * servers still starting and updates dynamically via {@link servers}: it hides + * itself (by emptying the observable) once every server has started, content + * starts being received, or the turn ends — whichever happens first. + * + * Unlike {@link IChatMcpServersStarting} (used by the in-process MCP autostart + * flow), this is a lightweight progress hint with no interactive affordance + * (there is no "Skip" button). + */ +export interface IChatMcpServersStartingSlow { + readonly kind: 'mcpServersStartingSlow'; + readonly sessionResource: UriComponents; + readonly servers: IObservable; +} + +export interface IChatMcpStartingServer { + readonly id: string; + readonly name: string; +} + export interface IChatDisabledClaudeHooksPart { readonly kind: 'disabledClaudeHooks'; } @@ -1318,6 +1352,7 @@ export type IChatProgress = | IChatContentInlineReference | IChatCodeCitation | IChatProgressMessage + | IChatSystemNotificationPart | IChatTask | IChatTaskResult | IChatCommandButton @@ -1345,6 +1380,7 @@ export type IChatProgress = | IChatMcpServersStarting | IChatMcpServersStartingSerialized | IChatMcpAuthenticationRequired + | IChatMcpServersStartingSlow | IChatHookPart | IChatExternalToolInvocationUpdate | IChatDisabledClaudeHooksPart diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 8c1e9d902b6..e579b14239f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -14,6 +14,7 @@ import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; +import { isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; type ChatVoteEvent = { direction: 'up' | 'down'; @@ -156,6 +157,7 @@ export type ChatProviderInvokedEvent = { permissionLevel: ChatPermissionLevel | undefined; chatMode: string | undefined; sessionType: string | undefined; + harness: string | undefined; }; export type ChatProviderInvokedClassification = { @@ -177,6 +179,7 @@ export type ChatProviderInvokedClassification = { permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval permission level selected in the permission picker (default, autoApprove, or autopilot). Undefined when the picker is not applicable (e.g. ask mode or API-driven requests).' }; chatMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat mode used for the request. Built-in modes (ask, agent, edit), extension-contributed names (e.g. Plan), or a hashed identifier for user-created custom agents.' }; sessionType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type scheme (e.g. vscodeLocalChatSession for local, or remote session scheme).' }; + harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For remote agent host sessions, the underlying harness/provider (e.g. copilotcli, claude, codex) so remote activity can be split by harness. Undefined for non-remote sessions.' }; owner: 'roblourens'; comment: 'Provides insight into the performance of Chat agents.'; }; @@ -318,6 +321,7 @@ export class ChatRequestTelemetry { permissionLevel: this.opts.options?.modeInfo?.kind === ChatModeKind.Ask ? undefined : this.opts.options?.modeInfo?.permissionLevel, chatMode: this.opts.options?.modeInfo?.telemetryModeName ?? this.opts.options?.modeInfo?.telemetryModeId, sessionType: getChatSessionTypeForTelemetry(this.opts.sessionResource), + harness: getHarnessForTelemetry(this.opts.sessionResource), }); } @@ -366,5 +370,16 @@ export class ChatRequestTelemetry { function getChatSessionTypeForTelemetry(sessionResource: URI): string { const sessionType = getChatSessionType(sessionResource); - return sessionType.startsWith('remote-') ? 'remote-agent-host' : sessionType; + // Collapse the high-cardinality, host-specific authority into a single + // value (the authority is PII); the harness is reported separately. + return isRemoteAgentHostSessionType(sessionType) ? 'remote-agent-host' : sessionType; +} + +/** + * For remote agent host sessions, the underlying harness/provider so remote + * activity can be split by harness (the collapsed sessionType cannot). See + * telemetry gap #2 in #8209. Undefined for non-remote sessions. + */ +function getHarnessForTelemetry(sessionResource: URI): string | undefined { + return parseRemoteAgentHostHarness(getChatSessionType(sessionResource)); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 22c6ff55947..f0b92ed5d1a 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -257,6 +257,7 @@ export interface IChatSessionFileChange { readonly originalUri?: URI; readonly insertions: number; readonly deletions: number; + readonly reviewed?: boolean; } export interface IChatSessionFileChange2 { @@ -265,6 +266,7 @@ export interface IChatSessionFileChange2 { readonly modifiedUri?: URI; readonly insertions: number; readonly deletions: number; + readonly reviewed?: boolean; } export type IChatSessionHistoryItem = { @@ -407,6 +409,9 @@ export interface IChatSession extends IDisposable { export interface IChatSessionContentProvider { provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; + /** Resolves a parsed response Markdown URI before it is sanitized and rendered. */ + resolveChatResponseUri?(sessionResource: URI, href: string, kind: 'link' | 'image'): string; + /** * Optional. Compute completion items for an input being composed in this * session. Returning `undefined` lets the workbench fall back to its @@ -711,6 +716,8 @@ export interface IChatSessionsService { registerChatSessionContentProvider(scheme: string, provider: IChatSessionContentProvider): IDisposable; canResolveChatSession(sessionType: string): Promise; getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; + /** Resolves a parsed response Markdown URI through its session content provider. */ + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string; /** * Compute completion items for an input being composed in the chat diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 3b8994e4695..5afc665ba24 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -286,6 +286,54 @@ export function getDefaultNewChatSessionResource( : URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); } +/** + * Storage key for the last-used non-local editor chat session type (agent), persisted at profile scope. + */ +export const ChatLastUsedEditorSessionTypeStorageKey = 'chat.lastUsedEditorSessionType'; + +/** + * Resolves the session type (agent) for a new chat editor, preferring the last-used visible non-local agent when `chat.editor.defaultProvider` isn't explicitly configured. + */ +export function getNewChatEditorSessionType( + configurationService: IConfigurationService, + chatSessionsService: Pick, + lastUsedSessionType: string | undefined, +): string { + const inspected = configurationService.inspect(ChatConfiguration.EditorDefaultProvider); + const explicitlyConfigured = inspected.applicationValue !== undefined + || inspected.userValue !== undefined + || inspected.userLocalValue !== undefined + || inspected.userRemoteValue !== undefined + || inspected.workspaceValue !== undefined + || inspected.workspaceFolderValue !== undefined + || inspected.memoryValue !== undefined + || inspected.policyValue !== undefined; + + if (!explicitlyConfigured + && lastUsedSessionType + && lastUsedSessionType !== localChatSessionType + && isVisibleEditorChatSessionType(lastUsedSessionType, configurationService, chatSessionsService)) { + return lastUsedSessionType; + } + + return getDefaultNewChatSessionType(configurationService, chatSessionsService); +} + +/** + * Like {@link getDefaultNewChatSessionResource}, but prefers the user's + * last-used session type via {@link getNewChatEditorSessionType}. + */ +export function getNewChatEditorSessionResource( + configurationService: IConfigurationService, + chatSessionsService: Pick, + lastUsedSessionType: string | undefined, +): URI { + const sessionType = getNewChatEditorSessionType(configurationService, chatSessionsService, lastUsedSessionType); + return sessionType === localChatSessionType + ? LocalChatSessionUri.getNewSessionUri() + : URI.from({ scheme: sessionType, path: `/untitled-${generateUuid()}` }); +} + export function isEditorLocalAgentEnabled(configurationService: IConfigurationService): boolean { return configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) ?? true; } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index b6017b1dedd..c29b6a2ed2f 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { AICustomizationManagementSection, AICustomizationSource, BUILTIN_STORAGE } from './aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; import { IAgentSource, IChatPromptSlashCommand, ICustomAgent, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; @@ -107,6 +107,11 @@ export interface IHarnessDescriptor { * When `undefined`, the harness is always available (e.g. Local). */ readonly requiredAgentId?: string; + /** + * Returns the storage source filter that should be applied to customization + * items of the given type when this harness is active. + */ + getStorageSourceFilter(type: PromptsType): IStorageSourceFilter; /** * When set, this harness is backed by an extension-contributed provider * that can supply customization items directly (bypassing promptsService @@ -235,8 +240,6 @@ export interface ICustomizationSourceFolder { readonly uri: URI; /** Display label for the picker when multiple folders are offered. */ readonly label: string; - /** Customization source for this folder (typically 'local' or 'user' for writable creation locations). */ - readonly source: AICustomizationSource; } /** @@ -365,7 +368,14 @@ export interface ICustomizationSlashCommand { readonly sessionTypes?: readonly string[]; } -// #region Shared descriptor constants +// #region Shared filter constants + +/** + * Empty filter returned when no harness is registered yet. + */ +const EMPTY_FILTER: IStorageSourceFilter = { + sources: [], +}; /** * Empty descriptor returned when no harness is registered yet. @@ -374,6 +384,7 @@ const EMPTY_DESCRIPTOR: IHarnessDescriptor = { id: '', label: '', icon: Codicon.sparkle, + getStorageSourceFilter: () => EMPTY_FILTER, }; @@ -394,6 +405,7 @@ const EMPTY_DESCRIPTOR: IHarnessDescriptor = { * with no user-root restrictions. */ export function createVSCodeHarnessDescriptor(): IHarnessDescriptor { + const filter: IStorageSourceFilter = { sources: AICustomizationSources.all }; return { id: SessionType.Local, label: localize('harness.local', "Local"), @@ -405,6 +417,7 @@ export function createVSCodeHarnessDescriptor(): IHarnessDescriptor { rootFileShortcuts: [AGENT_MD_FILENAME], }], ]), + getStorageSourceFilter: () => filter, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index b28e1aeb58e..6efac954fa1 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -31,7 +31,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; import { ChatPerfMark, markChat } from '../chatPerf.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatPlanReview, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatInfoMessage, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPlanReview, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatSystemNotificationPart, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ChatPlanReviewData } from './chatProgressTypes/chatPlanReviewData.js'; @@ -191,6 +191,7 @@ export type IChatProgressHistoryResponseContent = | IChatMultiDiffDataSerialized | IChatContentInlineReference | IChatProgressMessage + | IChatSystemNotificationPart | IChatCommandButton | IChatWarningMessage | IChatInfoMessage @@ -224,6 +225,7 @@ export type IChatProgressResponseContent = | IChatMcpServersStarting | IChatMcpServersStartingSerialized | IChatMcpAuthenticationRequired + | IChatMcpServersStartingSlow | IChatDisabledClaudeHooksPart; export type IChatProgressResponseContentSerialized = Exclude; @@ -616,12 +619,16 @@ class AbstractResponse implements IResponse { case 'multiDiffData': case 'mcpServersStarting': case 'mcpAuthenticationRequired': + case 'mcpServersStartingSlow': case 'questionCarousel': case 'planReview': case 'disabledClaudeHooks': case 'autoModeResolution': // Ignore continue; + case 'systemNotification': + segment = { text: part.content.value, isBlock: true }; + break; case 'toolInvocation': case 'toolInvocationSerialized': // Include tool invocations in the copy text diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 482a8c7e6c2..8d2b2276bd5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -10,7 +10,7 @@ import { isEqual as _urisEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { IChatMarkdownContent, IChatMcpAuthenticationRequired, ResponseModelState } from '../chatService/chatService.js'; +import { IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStartingSlow, ResponseModelState } from '../chatService/chatService.js'; import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatPendingRequest, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, ISerializablePendingRequestData, SerializedChatResponsePart, serializeSendOptions } from './chatModel.js'; @@ -38,7 +38,7 @@ const toJson = (obj: T): T extends { toJSON?(): infer R } ? R : T => { return (cast && typeof cast.toJSON === 'function' ? cast.toJSON() : obj) as any; }; -const responsePartSchema = Adapt.v, SerializedChatResponsePart>( +const responsePartSchema = Adapt.v, SerializedChatResponsePart>( (obj): SerializedChatResponsePart => obj.kind === 'markdownContent' ? obj.content : toJson(obj), (a, b) => { if (isMarkdownString(a) && isMarkdownString(b)) { @@ -76,6 +76,7 @@ const responsePartSchema = Adapt.v undefined), // deprecated, always undefined for new data isCanceled: Adapt.v(() => undefined), // deprecated, modelState is used instead - response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is Exclude => p.kind !== 'mcpAuthenticationRequired'), Adapt.array(responsePartSchema)), + response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is Exclude => p.kind !== 'mcpAuthenticationRequired' && p.kind !== 'mcpServersStartingSlow'), Adapt.array(responsePartSchema)), responseId: Adapt.v(m => m.response?.id), result: Adapt.v(m => m.response?.result, objectsEqual), responseMarkdownInfo: Adapt.v( diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 44fdc4a1be1..03acab3b21f 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -13,7 +13,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatFollowup, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatPlanReview, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsage, IChatUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatFollowup, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSlow, IChatPlanReview, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsage, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; @@ -224,7 +224,7 @@ export interface IChatChangesSummaryPart { /** * Type for content parts rendered by IChatListRenderer (not necessarily in the model) */ -export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatMcpAuthenticationRequired | IChatQuestionCarousel | IChatPlanReview | IChatDisabledClaudeHooksPart; +export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow | IChatQuestionCarousel | IChatPlanReview | IChatDisabledClaudeHooksPart; export interface IChatResponseViewModel { readonly model: IChatResponseModel; diff --git a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md index c73142d629f..3545048a6a0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -125,7 +125,7 @@ Auto-detection logic: if a `.plugin/plugin.json` manifest exists, the Open Plugi Manages the catalog of available and installed plugins: -- **Fetch** — reads `chat.pluginMarketplaces` config (GitHub shorthand, Git URLs, or file URIs), fetches `marketplace.json` from each, and returns parsed `IMarketplacePlugin` entries. +- **Fetch** — reads `chat.plugins.marketplaces` config (GitHub shorthand, Git URLs, or file URIs), fetches `marketplace.json` from each, and returns parsed `IMarketplacePlugin` entries. - **Installed storage** — persists installed plugins in application-scoped storage (`chat.plugins.installed.v1`). Each entry tracks `{ pluginUri, plugin, enabled }`. - **Trust** — marketplace canonical IDs must be explicitly trusted before install proceeds (`chat.plugins.trustedMarketplaces.v1`). - **Auto-update** — checks for upstream changes approximately every 24 hours when `extensions.autoUpdate` is enabled; sets `hasUpdatesAvailable` observable. @@ -144,6 +144,7 @@ Checked in order per repository: Orchestrates install and update workflows: - `installPlugin()` — checks marketplace trust, delegates to the appropriate source strategy to ensure files are locally available, and registers the plugin in installed storage. +- `installPluginFromSource()` — installs from a source string: GitHub shorthand (`owner/repo`), a git clone URL, or a local folder path (`file://` URI, absolute path, or `~`-prefixed path). Local folders are inspected to decide whether they are a marketplace (registered under `chat.plugins.marketplaces`) or a standalone plugin (registered under `chat.pluginLocations`). - `updatePlugin()` / `updateAllPlugins()` — pulls latest changes for cloned repositories and re-runs package-manager installs where applicable. ### Plugin Source Strategies (IPluginSource) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 3174d30853e..7458cdc42cc 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -63,14 +63,20 @@ export interface IPluginInstallService { /** * Installs a plugin directly from a source location string. Accepts - * GitHub shorthand (`owner/repo`) or a full git clone URL. Clones the - * repository, reads marketplace metadata to discover plugins, and - * registers the selected plugin. + * GitHub shorthand (`owner/repo`), a full git clone URL, or a local + * folder path (`file://` URI, absolute path, or `~`-prefixed path). + * For git sources, clones the repository, reads marketplace metadata to + * discover plugins, and registers the selected plugin. For local folders, + * detects whether the folder is a marketplace or a standalone plugin and + * registers it under the appropriate configuration. * - * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets - * a specific plugin, installs it, and returns it. + * Returns a result with an optional error message (e.g. invalid source or + * no plugins found); callers are responsible for surfacing it. When + * {@link IInstallPluginFromSourceOptions.plugin} is set, targets a specific + * plugin, installs it, and returns it in + * {@link IInstallPluginFromSourceResult.matchedPlugin}. */ - installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise; + installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise; /** * Synchronously validates the format of a plugin source string. @@ -78,17 +84,6 @@ export interface IPluginInstallService { */ validatePluginSource(source: string): string | undefined; - /** - * Installs a plugin from an already-validated source string. - * Handles trust, cloning, scanning, and registration. Returns a result - * with an optional error message (e.g. no plugins found). - * - * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets - * a specific plugin, installs it, and returns it in - * {@link IInstallPluginFromSourceResult.matchedPlugin}. - */ - installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise; - /** * Pulls the latest changes for an already-cloned marketplace repository. */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 761c0e2b2da..beba468785f 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -199,6 +199,14 @@ export interface IPluginMarketplaceService { * root. */ readSinglePluginManifest(repoDir: URI, reference: IMarketplaceReference): Promise; + /** + * Returns whether the given directory is a standalone plugin — i.e. it + * contains a single-plugin manifest (e.g. `.plugin/plugin.json`, + * `.claude-plugin/plugin.json`, or `plugin.json`) at its root but is not a + * marketplace. Used by direct-install flows to route a local folder to the + * appropriate configuration. + */ + isPluginDirectory(repoDir: URI): Promise; } /** @@ -889,6 +897,15 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return undefined; } + async isPluginDirectory(repoDir: URI): Promise { + for (const def of SINGLE_PLUGIN_MANIFEST_DEFINITIONS) { + if (await this._fileService.exists(joinPath(repoDir, def.path))) { + return true; + } + } + return false; + } + private async _readPluginsFromDirectory(repoDir: URI, reference: IMarketplaceReference, token?: CancellationToken): Promise { return this._readPluginsFromDefinitions(reference, async (defPath) => { if (token?.isCancellationRequested) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts index 0777f22c9fb..7929a699f07 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -22,14 +22,20 @@ class FakeAgentConnection extends mock() { private readonly _emitters = new Map>(); private readonly _values = new Map(); + private readonly _subscriptionCounts = new Map(); setState(resource: string, value: unknown): void { this._values.set(resource, value); this._emitters.get(resource)?.fire(value); } + getSubscriptionCount(resource: string): number { + return this._subscriptionCounts.get(resource) ?? 0; + } + override getSubscription(_kind: T, resource: URI, _owner: string): IReference> { const key = resource.toString(); + this._subscriptionCounts.set(key, (this._subscriptionCounts.get(key) ?? 0) + 1); let emitter = this._emitters.get(key); if (!emitter) { emitter = new Emitter(); @@ -93,6 +99,24 @@ suite('AgentHostResponseFileChangesProvider', () => { ]); }); + test('keeps the changeset subscription when session state updates', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => backendSession)); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Ready, files: [] } satisfies ChangesetState); + observe(provider, ds); + const subscriptionCountBeforeUpdate = conn.getSubscriptionCount(turnChangesetUri('t1')); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + + assert.deepStrictEqual([ + subscriptionCountBeforeUpdate, + conn.getSubscriptionCount(turnChangesetUri('t1')), + ], [1, 1]); + }); + test('returns empty when the agent does not advertise a turn changeset', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 45913a0bf59..6110eb91ea7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -2680,6 +2680,23 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(totalContent, 'hello world'); })); + test('system notification response parts become live system notifications', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ + type: 'chat/responsePart', + session, + turnId, + part: { kind: ResponsePartKind.SystemNotification, content: 'Background command completed' }, + } as ChatAction); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + const notifications = collected.flat().filter(part => part.kind === 'systemNotification'); + assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']); + })); + test('live turn marks chat session complete after turnComplete', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -6319,7 +6336,7 @@ suite('AgentHostChatContribution', () => { suite('reconnection to active turn', () => { - function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string }>): SeededSessionState { + function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string; systemNotification: string }>): SeededSessionState { const summary: SessionSummary = { resource: sessionUri, provider: 'copilot', @@ -6333,6 +6350,9 @@ suite('AgentHostChatContribution', () => { if (reasoningText) { activeTurnParts.push({ kind: ResponsePartKind.Reasoning as const, id: 'reasoning-1', content: reasoningText }); } + if (overrides?.systemNotification) { + activeTurnParts.push({ kind: ResponsePartKind.SystemNotification as const, content: overrides.systemNotification }); + } activeTurnParts.push({ kind: ResponsePartKind.Markdown as const, id: 'md-active', content: overrides?.streamingText ?? 'Partial response so far' }); return { ...createSessionState(summary), @@ -6397,6 +6417,21 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(markdownPart!.content.value, 'Partial response so far'); }); + test('does not duplicate system notification progress when reconnecting', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'reconnect-system-notification'); + agentHostService.sessionStates.set(sessionUri.toString(), makeSessionStateWithActiveTurn(sessionUri.toString(), { + systemNotification: 'Background command completed', + })); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/reconnect-system-notification' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + const notifications = (session.progressObs?.get() ?? []).filter(part => part.kind === 'systemNotification'); + assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']); + }); + test('provides interruptActiveResponseCallback when reconnecting', async () => { const { sessionHandler, agentHostService } = createContribution(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ff1ee861755..726f4a8e425 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2171,7 +2171,7 @@ suite('AgentSessions', () => { test('should return correct icon for AgentHostCopilot provider', () => { const icon = getAgentSessionProviderIcon(AgentSessionProviders.AgentHostCopilot); - assert.strictEqual(icon.id, Codicon.copilot.id); + assert.strictEqual(icon.id, Codicon.vm.id); }); test('should return simplified AgentHostCopilot name', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index eebe64cbe24..9ace81a0c43 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { resolveCustomizationRefs } from '../../../browser/agentSessions/agentHost/agentHostLocalCustomizations.js'; @@ -19,13 +20,39 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { type IAgentPlugin, type IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { type IPromptPath, type IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { type IMcpServer, type IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { type IMcpServer, type IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { SessionType } from '../../../common/chatSessionsService.js'; function makePromptPath(uri: URI, type: PromptsType, storage: PromptsStorage): IPromptPath { return { uri, type, storage } as IPromptPath; } +/** + * A fake {@link IConfigurationResolverService} whose `resolveAsync` mirrors the + * real service: it resolves the given `${...}` variables from `resolutions` and + * leaves any others (e.g. `${input:…}`) untouched so they remain unresolved. + */ +function makeConfigurationResolverService(resolutions: Record = {}): IConfigurationResolverService { + return { + async resolveAsync(_folder: unknown, config: unknown) { + const expr = ConfigurationResolverExpression.parse(config as object); + for (const replacement of expr.unresolved()) { + if (Object.prototype.hasOwnProperty.call(resolutions, replacement.id)) { + expr.resolve(replacement, resolutions[replacement.id]); + } else if (replacement.name === 'input' || replacement.name === 'command') { + // Mirror the real resolver: without a value mapping, interactive + // variables "resolve" to their own literal text, dropping out of + // `unresolved()`. + expr.resolve(replacement, replacement.id); + } + } + return expr.toObject(); + }, + } as unknown as IConfigurationResolverService; +} + function makePromptsService(files: ReadonlyMap): IPromptsService { return { async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage): Promise { @@ -72,9 +99,10 @@ function makeFileService(stats: ReadonlyMap = new Map } as unknown as IFileService; } -function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined }): IMcpServer { - const { id, collectionId, label = id, enabled = true, launch } = options; - const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection: undefined }); +function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined; configTarget?: ConfigurationTarget }): IMcpServer { + const { id, collectionId, label = id, enabled = true, launch, configTarget = ConfigurationTarget.USER } = options; + const collection = { id: collectionId, label: collectionId, order: 0, configTarget } as unknown as McpCollectionDefinition; + const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection }); return { definition: { id, label }, collection: { id: collectionId, label: collectionId, order: 0 }, @@ -100,6 +128,26 @@ const stdioLaunch: McpServerLaunch = { sandbox: undefined, }; +const stdioLaunchWithInput: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--token', '${input:token}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + +const stdioLaunchWithFolder: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--root', '${workspaceFolder}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + type LocalSyncableFile = { readonly uri: URI; readonly type: PromptsType }; class FakeBundler { @@ -133,6 +181,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -162,6 +211,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([disabled.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -184,6 +234,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -211,6 +262,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([builtin.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -230,6 +282,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'MCP Only', mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -248,6 +301,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false, mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -265,6 +319,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -279,6 +334,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([pluginUri.toString()])), makeAgentPluginService([makePlugin(pluginUri, { mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -297,6 +353,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'Combined', mcpServers: 2 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -313,6 +370,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -333,6 +391,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -357,6 +416,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -378,10 +438,165 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); assert.strictEqual(bundler.received.length, 0); }); + + test('excludes workspace-discovered `.mcp.json` servers (the agent host discovers those itself)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wsdot.srv', collectionId: 'workspace-dot-mcp.0', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('excludes `.code-workspace` configured servers', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wscfg.srv', collectionId: 'mcp.config.workspace', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers that resolve without user interaction', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.my-server', collectionId: 'mcp.config.ws0', label: 'my-server', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + assert.strictEqual(refs[0].name, 'Open Plugin'); + }); + + test('excludes `.vscode/mcp.json` servers with variables that require interaction (e.g. ${input:…})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.needs-input', collectionId: 'mcp.config.ws0', label: 'needs-input', launch: stdioLaunchWithInput, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers after resolving non-interactive variables (e.g. ${workspaceFolder})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService({ '${workspaceFolder}': '/ws' }), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'folder-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--root', '/ws'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + }); + + test('excludes `.vscode/mcp.json` servers when variable resolution throws', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + const throwingResolver = { + async resolveAsync() { throw new Error('no workspace folder'); }, + } as unknown as IConfigurationResolverService; + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + throwingResolver, + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('still syncs extension-contributed servers (workspace scope, user config target)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'ext.foo.srv', collectionId: 'ext.foo', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.USER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0].map(s => s.name), ['srv']); + assert.strictEqual(refs.length, 1); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 910bf34f908..62033aa2299 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -8,10 +8,11 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas, formatTurnResponseDetails } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -106,6 +107,63 @@ suite('stateToProgressAdapter', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('rewriteAgentHostLinkTarget', () => { + test('supports absolute paths and file URIs with validated locations', () => { + const unwrap = (href: string) => fromAgentHostUri(URI.parse(rewriteAgentHostLinkTarget(href, 'my-host'))).toString(); + assert.deepStrictEqual( + [ + unwrap('C:\\remote\\windows.ts:42'), + unwrap('\\\\server\\share\\unc.ts:42'), + unwrap('FILE:///remote/upper.ts:42'), + unwrap('/remote/zero.ts:0'), + unwrap('/remote/zero-column.ts:42:0'), + unwrap('/remote/numeric-segment.ts:42:name.ts'), + unwrap('/remote/scientific.ts:1e2'), + unwrap('/remote/encoded%3A42'), + unwrap('/remote/encoded%3A42:10'), + unwrap('file:///remote/encoded%3A42'), + unwrap('file:///remote/encoded%3A42:10'), + unwrap('file:///remote/queried.ts?rev=1:42'), + unwrap('/remote/range.ts:42-48'), + ], + [ + URI.file('C:/remote/windows.ts').with({ fragment: 'L42' }).toString(), + URI.file('//server/share/unc.ts').with({ fragment: 'L42' }).toString(), + URI.file('/remote/upper.ts').with({ fragment: 'L42' }).toString(), + URI.file('/remote/zero.ts:0').toString(), + URI.file('/remote/zero-column.ts:42:0').toString(), + URI.file('/remote/numeric-segment.ts:42:name.ts').toString(), + URI.file('/remote/scientific.ts:1e2').toString(), + URI.file('/remote/encoded:42').toString(), + URI.file('/remote/encoded:42').with({ fragment: 'L10' }).toString(), + URI.file('/remote/encoded:42').toString(), + URI.file('/remote/encoded:42').with({ fragment: 'L10' }).toString(), + URI.file('/remote/queried.ts').with({ query: 'rev=1:42' }).toString(), + URI.file('/remote/range.ts:42-48').toString(), + ], + ); + }); + + test('preserves client-handled link schemes', () => { + assert.deepStrictEqual( + [ + rewriteAgentHostLinkTarget('vscode-browser://example.com', 'my-host'), + rewriteAgentHostLinkTarget('copilot-skill:/plan', 'my-host'), + rewriteAgentHostLinkTarget('C:relative', 'my-host'), + rewriteAgentHostLinkTarget('git:foo', 'my-host'), + rewriteAgentHostLinkTarget('urn:isbn:123', 'my-host'), + ], + [ + 'vscode-browser://example.com', + 'copilot-skill:/plan', + 'C:relative', + 'git:foo', + 'urn:isbn:123', + ], + ); + }); + }); + suite('turnsToHistory', () => { test('empty turns produces empty history', () => { @@ -152,7 +210,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(history[0].systemInitiatedLabel, undefined); }); - test('system notification response part restores as progress message', () => { + test('system notification response part restores as system notification', () => { const turn = createTurn({ responseParts: [{ kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }], }); @@ -161,8 +219,9 @@ suite('stateToProgressAdapter', () => { const response = history[1]; assert.strictEqual(response.type, 'response'); if (response.type !== 'response') { return; } - const progress = response.parts[0] as IChatProgressMessage; - assert.strictEqual(progress.kind, 'progressMessage'); + const progress = response.parts[0]; + assert.strictEqual(progress.kind, 'systemNotification'); + if (progress.kind !== 'systemNotification') { return; } assert.strictEqual(progress.content.value, 'Shell command completed'); }); @@ -374,6 +433,31 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandState.exitCode, 0); }); + test('terminal tool call in history carries the LM intention', () => { + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ + intention: 'List files in the repo root', + toolInput: 'ls', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///intent', title: 'Terminal' }, + { type: ToolResultContentType.Text, text: 'a\nb' }, + ], + success: true, + }) + } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; intention?: string }; + assert.strictEqual(termData.intention, 'List files in the repo root'); + }); + test('terminal tool call in history does not set pastTenseMessage (avoids duplicate render)', () => { const turn = createTurn({ responseParts: [{ @@ -502,12 +586,13 @@ suite('stateToProgressAdapter', () => { assert.strictEqual((response.parts[0] as IChatMarkdownContent).content.value, 'Hello world'); }); - test('markdown links in response content are rewritten through the agent host scheme', () => { + test('markdown links in response content stay raw until rendering', () => { + const content = 'See [local](file:///a/b.ts), [external](https://example.com) and [rel](./foo.md).'; const turn = createTurn({ responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-links', - content: 'See [local](file:///a/b.ts), [content](agenthost-content:///s/x), [external](https://example.com) and [rel](./foo.md).', + content, }], }); @@ -516,12 +601,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(response.type, 'response'); if (response.type !== 'response') { return; } const part = response.parts[0] as IChatMarkdownContent; - assert.deepStrictEqual(part.content.value, - 'See [](vscode-agent-host://my-host/a/b.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0), ' + - '[](vscode-agent-host://my-host/s/x?_ah%3DeyJzY2hlbWUiOiJhZ2VudGhvc3QtY29udGVudCJ9), ' + - '[external](https://example.com) and ' + - '[rel](./foo.md).' - ); + assert.strictEqual(part.content.value, content); }); test('markdown link syntax inside fenced code blocks is preserved verbatim', () => { @@ -534,15 +614,7 @@ suite('stateToProgressAdapter', () => { '', 'And then [another](file:///c.ts).', ].join('\n'); - const turn = createTurn({ - responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-code', content: input }], - }); - - const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); - const response = history[1]; - assert.strictEqual(response.type, 'response'); - if (response.type !== 'response') { return; } - const value = (response.parts[0] as IChatMarkdownContent).content.value; + const value = rewriteMarkdownLinks(input, 'my-host'); assert.ok(value.includes('[](vscode-agent-host://my-host/a.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)')); assert.ok(value.includes('[](vscode-agent-host://my-host/c.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)')); // The link inside the fenced code block must NOT be rewritten. @@ -552,34 +624,14 @@ suite('stateToProgressAdapter', () => { test('markdown link syntax inside inline code spans is preserved verbatim', () => { const input = 'Real [one](file:///a.ts) and literal `[two](file:///b.ts)` here.'; - const turn = createTurn({ - responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-codespan', content: input }], - }); - - const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); - const response = history[1]; - assert.strictEqual(response.type, 'response'); - if (response.type !== 'response') { return; } - const value = (response.parts[0] as IChatMarkdownContent).content.value; + const value = rewriteMarkdownLinks(input, 'my-host'); assert.strictEqual(value, 'Real [](vscode-agent-host://my-host/a.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0) and literal `[two](file:///b.ts)` here.' ); }); test('preserves label and tags vscodeLinkType=skill for SKILL.md links', () => { - const turn = createTurn({ - responseParts: [{ - kind: ResponsePartKind.Markdown, - id: 'md-skill', - content: 'Loaded [plan](file:///abs/repo/skills/plan/SKILL.md) and [other](file:///abs/repo/foo.ts).', - }], - }); - - const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); - const response = history[1]; - assert.strictEqual(response.type, 'response'); - if (response.type !== 'response') { return; } - const value = (response.parts[0] as IChatMarkdownContent).content.value; + const value = rewriteMarkdownLinks('Loaded [plan](file:///abs/repo/skills/plan/SKILL.md) and [other](file:///abs/repo/foo.ts).', 'my-host'); assert.strictEqual(value, 'Loaded [plan](vscode-agent-host://my-host/abs/repo/skills/plan/SKILL.md?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0%26vscodeLinkType%3Dskill) ' + 'and [](vscode-agent-host://my-host/abs/repo/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0).' @@ -587,19 +639,7 @@ suite('stateToProgressAdapter', () => { }); test('preserves alt text for image tokens', () => { - const turn = createTurn({ - responseParts: [{ - kind: ResponsePartKind.Markdown, - id: 'md-image', - content: 'See ![diagram](file:///a/b.png).', - }], - }); - - const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); - const response = history[1]; - assert.strictEqual(response.type, 'response'); - if (response.type !== 'response') { return; } - const value = (response.parts[0] as IChatMarkdownContent).content.value; + const value = rewriteMarkdownLinks('See ![diagram](file:///a/b.png).', 'my-host'); assert.strictEqual(value, 'See ![diagram](vscode-agent-host://my-host/a/b.png?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0).'); }); @@ -1261,13 +1301,14 @@ suite('stateToProgressAdapter', () => { assert.strictEqual((result[0] as IChatMarkdownContent).content.value, 'Hello world'); }); - test('produces progress message for system notification', () => { + test('produces system notification for system notification response part', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }, ]), undefined); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].kind, 'progressMessage'); - assert.strictEqual((result[0] as IChatProgressMessage).content.value, 'Shell command completed'); + assert.strictEqual(result[0].kind, 'systemNotification'); + if (result[0].kind !== 'systemNotification') { return; } + assert.strictEqual(result[0].content.value, 'Shell command completed'); }); test('produces thinking progress for reasoning', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 3f780412e22..490e47f41f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -16,7 +16,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { AICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; -import { AICustomizationManagementSection, AICustomizationSources, BUILTIN_STORAGE, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, BUILTIN_STORAGE, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, ICustomizationSyncProvider, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPluginService, type IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; @@ -49,6 +49,7 @@ suite('AICustomizationItemsModel', () => { id, label: id, icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: provider, syncProvider, }; @@ -538,6 +539,7 @@ suite('AICustomizationItemsModel', () => { id: 'A', label: 'A', icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: provider, }; const sessionResource = URI.parse('A:///active-session'); @@ -778,6 +780,7 @@ suite('AICustomizationItemsModel', () => { id: sessionType, label: 'Agent Host Test', icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [] }), itemProvider: provider, }; const sessionResource = URI.parse(`${sessionType}:///active-session`); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 4f04bb2c641..a863b1026ef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -16,12 +16,12 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { AICustomizationListWidget } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; @@ -164,6 +164,7 @@ suite('aiCustomizationListWidget', () => { id: 'test', label: 'Test', icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: { onDidChange: Event.None, provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => Promise.resolve(undefined), diff --git a/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts new file mode 100644 index 00000000000..f952ac4283f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../../../platform/accessibility/browser/accessibleView.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { AutomationsAccessibilityHelp, buildAutomationsHelpContent } from '../../../browser/aiCustomization/automationsAccessibilityHelp.js'; + +class FakeKeybindingService extends mock() { + override lookupKeybinding(): undefined { + return undefined; + } +} + +suite('AutomationsAccessibilityHelp', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + test('implementation declares the Help type and a when clause', () => { + const impl = new AutomationsAccessibilityHelp(); + assert.strictEqual(impl.type, AccessibleViewType.Help); + assert.strictEqual(impl.name, 'automations'); + assert.ok(impl.when, 'expected a when clause so the help only activates in the Automations section'); + }); + + test('getProvider returns an AccessibleContentProvider with the Automations id and verbosity setting', () => { + // Regression: the provider must be an actual `AccessibleContentProvider` + // instance so the accessible-view service's `instanceof` branches + // (e.g. `_updateLastProvider`, `showAccessibleViewHelp`) take the + // correct path and propagate `verbositySettingKey` properly. + const instantiationService = teardown.add(new TestInstantiationService()); + instantiationService.stub(IKeybindingService, new FakeKeybindingService()); + + const impl = new AutomationsAccessibilityHelp(); + const provider = instantiationService.invokeFunction(accessor => impl.getProvider(accessor)); + teardown.add(provider); + + assert.ok(provider instanceof AccessibleContentProvider, 'provider must be an AccessibleContentProvider instance'); + assert.strictEqual(provider.id, AccessibleViewProviderId.Automations); + assert.strictEqual(provider.verbositySettingKey, AccessibilityVerbositySettingId.Automations); + assert.strictEqual(provider.options.type, AccessibleViewType.Help); + }); + + test('help content covers actions, dialog, history and settings', () => { + const content = buildAutomationsHelpContent(new FakeKeybindingService()); + assert.match(content, /Automations/); + assert.match(content, /Run now/); + assert.match(content, /Show history/); + assert.match(content, /Create\/Edit Dialog/); + assert.match(content, /Workspace folder/); + assert.match(content, /Run History/); + assert.match(content, /accessibility\.verbosity\.automations/); + }); + + test('help content does not contain unresolved placeholders', () => { + const content = buildAutomationsHelpContent(upcastPartial({ lookupKeybinding: () => undefined })); + // The string template uses {0} for keybinding insertion; if any + // placeholder leaks through unresolved that is a content bug. + assert.doesNotMatch(content, /\{0\}/); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts new file mode 100644 index 00000000000..95fa7a1c859 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts @@ -0,0 +1,514 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IConfirmation, IConfirmationResult, IDialogService, IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IListService, ListService } from '../../../../../../platform/list/browser/listService.js'; +import { ILayoutService } from '../../../../../../platform/layout/browser/layoutService.js'; +import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from '../../../../../../platform/workspace/common/workspace.js'; +import { AutomationsListWidget } from '../../../browser/aiCustomization/automationsListWidget.js'; +import { IAutomation, IAutomationRun, IAutomationSchedule, AutomationRunTrigger } from '../../../common/automations/automation.js'; +import { IAutomationRunner } from '../../../common/automations/automationRunner.js'; +import { IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../common/automations/automationService.js'; +import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../common/automations/automationDialogService.js'; + +const FOLDER = URI.parse('file:///workspace'); + +function hourly(): IAutomationSchedule { + return { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; +} + +/** + * In-memory IAutomationService for the widget tests. Replaces the concrete + * AutomationService, which now lives in the sessions layer. Importing it from + * a workbench-layer test trips the `code-import-patterns` rule. The widget only + * reads the `automations`/`runs` observables and drives create/update/delete, + * so this fake keeps an unpersisted reactive store with just those mutations + * plus the run-recording the tests exercise directly. + */ +class FakeAutomationService extends mock() { + + private readonly _automations = observableValue(this, []); + private readonly _runs = observableValue(this, []); + private readonly _runsForCache = new Map>(); + createError: Error | undefined; + updateError: Error | undefined; + + override readonly automations: IObservable = this._automations; + override readonly runs: IObservable = this._runs; + + override getAutomation(id: string): IAutomation | undefined { + return this._automations.get().find(a => a.id === id); + } + + override runsFor(automationId: string): IObservable { + let cached = this._runsForCache.get(automationId); + if (!cached) { + cached = derived(this, reader => this._runs.read(reader).filter(r => r.automationId === automationId)); + this._runsForCache.set(automationId, cached); + } + return cached; + } + + override async createAutomation(options: ICreateAutomationOptions): Promise { + if (this.createError) { + throw this.createError; + } + const now = new Date().toISOString(); + const automation: IAutomation = Object.freeze({ + id: generateUuid(), + name: options.name, + prompt: options.prompt, + schedule: options.schedule, + folderUri: options.folderUri, + providerId: options.providerId, + sessionTypeId: options.sessionTypeId, + modelId: options.modelId, + mode: options.mode, + permissionLevel: options.permissionLevel, + enabled: options.enabled ?? true, + createdAt: now, + updatedAt: now, + lastRunAt: undefined, + nextRunAt: undefined, + }); + this._automations.set([automation, ...this._automations.get()], undefined); + return automation; + } + + override async updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise { + if (this.updateError) { + throw this.updateError; + } + const current = this.getAutomation(id); + if (!current) { + throw new Error(`Automation not found: ${id}`); + } + const updated: IAutomation = Object.freeze({ + ...current, + name: patch.name ?? current.name, + prompt: patch.prompt ?? current.prompt, + schedule: patch.schedule ?? current.schedule, + enabled: patch.enabled ?? current.enabled, + updatedAt: new Date().toISOString(), + }); + this._automations.set(this._automations.get().map(a => a.id === id ? updated : a), undefined); + return updated; + } + + override async deleteAutomation(id: string): Promise { + this._automations.set(this._automations.get().filter(a => a.id !== id), undefined); + this._runsForCache.delete(id); + } + + override async recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise { + const run: IAutomationRun = Object.freeze({ + id: generateUuid(), + automationId, + status: 'pending', + trigger, + startedAt: new Date().toISOString(), + leaderWindowId, + }); + this._runs.set([run, ...this._runs.get()], undefined); + return run; + } + + override async updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise { + const current = this._runs.get().find(r => r.id === runId); + if (!current) { + return undefined; + } + const merged: IAutomationRun = Object.freeze({ + ...current, + status: patch.status ?? current.status, + sessionId: patch.sessionId ?? current.sessionId, + completedAt: patch.completedAt ?? current.completedAt, + errorMessage: patch.errorMessage ?? current.errorMessage, + }); + this._runs.set(this._runs.get().map(r => r.id === runId ? merged : r), undefined); + return merged; + } +} + +class RecordingRunner extends mock() { + readonly calls: { automationId: string; trigger: AutomationRunTrigger }[] = []; + error: Error | undefined; + + override async runOnce( + automation: IAutomation, + trigger: AutomationRunTrigger, + _leaderWindowId: number, + _token?: CancellationToken, + ): Promise { + this.calls.push({ automationId: automation.id, trigger }); + if (this.error) { + throw this.error; + } + } +} + +class FakeDialogService extends mock() { + confirmResult = true; + readonly confirmations: IConfirmation[] = []; + readonly errors: { message: string; detail: string }[] = []; + + override async confirm(confirmation: IConfirmation): Promise { + this.confirmations.push(confirmation); + return { confirmed: this.confirmResult }; + } + + override async error(message: string, detail?: string): Promise { + this.errors.push({ message, detail: detail ?? '' }); + } + + override async info(): Promise { /* no-op */ } +} + +class FakeAutomationDialogService extends mock() { + result: IAutomationDialogResult | undefined; + lastOptions: IShowAutomationDialogOptions | undefined; + + override async showAutomationDialog(options: IShowAutomationDialogOptions): Promise { + this.lastOptions = options; + return this.result; + } +} + +class FakeWorkspaceContextService extends mock() { + + private readonly _onDidChangeWorkspaceFolders = new Emitter(); + override readonly onDidChangeWorkspaceFolders: Event = this._onDidChangeWorkspaceFolders.event; + + private _folders: IWorkspaceFolder[]; + + constructor(folders: readonly URI[] = [FOLDER]) { + super(); + this._folders = folders.map((uri, i) => upcastPartial({ uri, name: `folder-${i}`, index: i })); + } + + override getWorkspace(): IWorkspace { + return upcastPartial({ folders: this._folders }); + } + + setFolders(uris: readonly URI[]): void { + this._folders = uris.map((uri, i) => upcastPartial({ uri, name: `folder-${i}`, index: i })); + this._onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + } + + dispose(): void { + this._onDidChangeWorkspaceFolders.dispose(); + } +} + +suite('AutomationsListWidget', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup() { + const log = new NullLogService(); + const service = new FakeAutomationService(); + const runner = new RecordingRunner(); + const dialog = new FakeDialogService(); + const automationDialogService = new FakeAutomationDialogService(); + + const instantiation = teardown.add(new TestInstantiationService()); + instantiation.stub(IAutomationService, service); + instantiation.stub(IAutomationRunner, runner); + instantiation.stub(IDialogService, dialog); + instantiation.stub(IFileDialogService, upcastPartial({ showOpenDialog: async () => undefined })); + instantiation.stub(IAutomationDialogService, automationDialogService); + instantiation.stub(IHoverService, NullHoverService); + const workspace = new FakeWorkspaceContextService(); + teardown.add({ dispose: () => workspace.dispose() }); + instantiation.stub(IWorkspaceContextService, workspace); + instantiation.stub(IKeybindingService, upcastPartial({})); + instantiation.stub(IContextKeyService, new MockContextKeyService()); + instantiation.stub(IListService, teardown.add(new ListService())); + instantiation.stub(ILayoutService, upcastPartial({ activeContainer: document.createElement('div') })); + instantiation.stub(IHostService, upcastPartial({})); + instantiation.stub(ILogService, log); + instantiation.stub(IQuickInputService, upcastPartial({ pick: async () => undefined })); + // Enable the Automations feature so mutation handlers don't + // short-circuit with the "feature disabled" toast. The runtime + // gating is exercised in a dedicated test below. + const configService = new TestConfigurationService({ chat: { automations: { enabled: true } } }); + instantiation.stub(IConfigurationService, configService); + + const widget = teardown.add(instantiation.createInstance(AutomationsListWidget)); + return { widget, service, runner, dialog, workspace, configService, automationDialogService }; + } + + test('renders empty state when there are no automations', () => { + const { widget } = setup(); + const empty = widget.element.querySelector('.automations-empty-state'); + assert.ok(empty, 'expected empty-state element to be present'); + const rows = widget.element.querySelectorAll('.automations-row'); + assert.strictEqual(rows.length, 0); + }); + + // The Automations list is a virtualized WorkbenchList, which does not lay + // out rows in a unit-test DOM (no height). Mirroring the sibling + // aiCustomizationListWidget test, these cases assert the widget's public + // API and view-model (via getDisplayEntriesForTest / itemCount) rather than + // querying or clicking virtualized row elements. + + test('exposes one display entry per automation', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'First', prompt: 'p1', schedule: hourly(), folderUri: FOLDER }); + await service.createAutomation({ name: 'Second', prompt: 'p2', schedule: hourly(), folderUri: FOLDER }); + + assert.strictEqual(widget.itemCount, 2); + + const entries = widget.getDisplayEntriesForTest(); + assert.strictEqual(entries.length, 2); + const names = entries.map(e => e.automation.name).sort(); + assert.deepStrictEqual(names, ['First', 'Second']); + }); + + test('disabled automations surface in the view-model as not enabled', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'D', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: false }); + + const entries = widget.getDisplayEntriesForTest(); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].automation.enabled, false, 'disabled badge is rendered from this flag'); + }); + + test('runNow invokes the runner with trigger=manual', async () => { + const { widget, service, runner } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + await widget.runNow(a); + + assert.strictEqual(runner.calls.length, 1); + assert.strictEqual(runner.calls[0].automationId, a.id); + assert.strictEqual(runner.calls[0].trigger, 'manual'); + }); + + test('runNow clears inFlight when the runner fails', async () => { + const { widget, service, runner } = setup(); + runner.error = new Error('boom'); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + await widget.runNow(automation); + + assert.strictEqual(runner.calls.length, 1); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].inFlight, false); + }); + + test('mutating actions short-circuit when chat.automations.enabled is off', async () => { + const { widget, service, runner, configService, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: true }); + + // Flip the setting off, then drive each mutating action through the + // public API. None of them should reach the service / runner. + configService.setUserConfiguration('chat.automations.enabled', false); + dialog.confirmResult = true; + + await widget.runNow(a); + await widget.toggleEnabled(a); + await widget.deleteAutomation(a); + + assert.strictEqual(runner.calls.length, 0, 'runNow must not call the runner when disabled'); + const reloaded = service.getAutomation(a.id); + assert.ok(reloaded, 'automation must not be deleted'); + assert.strictEqual(reloaded?.enabled, true, 'toggle must not mutate enabled flag'); + }); + + test('toggleEnabled flips the enabled state', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: true }); + + await widget.toggleEnabled(a); + + const updated = service.getAutomation(a.id); + assert.ok(updated); + assert.strictEqual(updated.enabled, false); + }); + + test('openEditDialog surfaces update errors without crashing', async () => { + const { widget, service, dialog, automationDialogService } = setup(); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + automationDialogService.result = { kind: 'update', id: automation.id, value: { name: 'Updated' } }; + service.updateError = new Error('update failed'); + + await widget.openEditDialog(automation); + + assert.strictEqual(service.getAutomation(automation.id)?.name, 'A'); + assert.deepStrictEqual(dialog.errors, [{ + message: 'Failed to update automation.', + detail: 'update failed', + }]); + }); + + test('openCreateDialog creates an automation when the dialog returns a create result', async () => { + const { widget, automationDialogService } = setup(); + automationDialogService.result = { + kind: 'create', + value: { name: 'Created', prompt: 'p', schedule: hourly(), folderUri: FOLDER } + }; + + const openCreateDialog = Reflect.get(widget, 'openCreateDialog') as (() => Promise) | undefined; + assert.ok(openCreateDialog); + await Reflect.apply(openCreateDialog, widget, []); + + assert.strictEqual(widget.itemCount, 1); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].automation.name, 'Created'); + }); + + test('openCreateDialog surfaces creation errors without crashing', async () => { + const { widget, service, dialog, automationDialogService } = setup(); + automationDialogService.result = { + kind: 'create', + value: { name: 'Created', prompt: 'p', schedule: hourly(), folderUri: FOLDER } + }; + service.createError = new Error('create failed'); + + const openCreateDialog = Reflect.get(widget, 'openCreateDialog') as (() => Promise) | undefined; + assert.ok(openCreateDialog); + await Reflect.apply(openCreateDialog, widget, []); + + assert.strictEqual(widget.itemCount, 0); + assert.deepStrictEqual(dialog.errors, [{ + message: 'Failed to create automation.', + detail: 'create failed', + }]); + }); + + test('deleteAutomation only deletes when the confirmation is accepted', async () => { + const { widget, service, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + dialog.confirmResult = false; + await widget.deleteAutomation(a); + + assert.strictEqual(dialog.confirmations.length, 1); + assert.ok(service.getAutomation(a.id), 'expected automation to still exist after declined delete'); + }); + + test('deleteAutomation removes the automation when the confirmation is accepted', async () => { + const { widget, service, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + dialog.confirmResult = true; + await widget.deleteAutomation(a); + + assert.strictEqual(service.getAutomation(a.id), undefined); + assert.strictEqual(widget.itemCount, 0); + assert.strictEqual(widget.getDisplayEntriesForTest().length, 0); + }); + + test('fires onDidChangeItemCount when automations change', async () => { + const { widget, service } = setup(); + const seen: number[] = []; + teardown.add(widget.onDidChangeItemCount(c => seen.push(c))); + + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + await service.createAutomation({ name: 'B', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + assert.ok(seen.length >= 2, `expected at least 2 emissions, got ${seen.length}`); + assert.strictEqual(seen[seen.length - 1], 2); + }); + + test('fireItemCount reflects current service size', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + let captured = -1; + teardown.add(widget.onDidChangeItemCount(c => { captured = c; })); + widget.fireItemCount(); + + assert.strictEqual(captured, 1); + }); + + test('history is collapsed by default and toggleExpanded flips the row expansion', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, false); + + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, true); + + // Collapse again. + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, false); + }); + + test('expanded row exposes no runs when there are none', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + widget.toggleExpanded(a.id); + + const entry = widget.getDisplayEntriesForTest()[0]; + assert.strictEqual(entry.expanded, true); + assert.strictEqual(entry.runs.length, 0, 'history empty-state is rendered from an empty runs list'); + }); + + test('expanded row exposes runs newest-first with status and error message', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + // Record three runs in different states. + const r1 = await service.recordRunStart(a.id, 'schedule', 1); + await service.updateRun(r1.id, { status: 'completed', completedAt: new Date().toISOString() }); + + const r2 = await service.recordRunStart(a.id, 'manual', 1); + await service.updateRun(r2.id, { status: 'failed', errorMessage: 'boom', completedAt: new Date().toISOString() }); + + await service.recordRunStart(a.id, 'catch_up', 1); + + widget.toggleExpanded(a.id); + + const runs = widget.getDisplayEntriesForTest()[0].runs; + assert.strictEqual(runs.length, 3); + + // Newest-first: catch_up pending, manual failed, schedule completed. + const statuses = runs.map(r => r.status); + assert.deepStrictEqual(statuses, ['pending', 'failed', 'completed']); + + const triggers = runs.map(r => r.trigger); + assert.deepStrictEqual(triggers, ['catch_up', 'manual', 'schedule']); + + // The failed run surfaces the error message. + const failed = runs.find(r => r.status === 'failed'); + assert.strictEqual(failed?.errorMessage, 'boom'); + }); + + test('expanded row re-derives its runs when a run is added', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].runs.length, 0); + + await service.recordRunStart(a.id, 'schedule', 1); + await Promise.resolve(); + + const entry = widget.getDisplayEntriesForTest()[0]; + assert.strictEqual(entry.expanded, true); + assert.strictEqual(entry.runs.length, 1); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index a0b7f92a14e..5f9b224dce4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -15,6 +15,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; import { PluginInstallService } from '../../../browser/pluginInstallService.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js'; @@ -84,6 +85,16 @@ suite('PluginInstallService', () => { configuredMarketplaces: string[]; /** Updated marketplace config values */ updatedMarketplaces: string[] | undefined; + /** Whether readResult resolves to a directory (IFileService.resolve) */ + resolveIsDirectory: boolean; + /** Whether the directory is a standalone plugin (isPluginDirectory) */ + isPluginDirectoryResult: boolean; + /** Current configured plugin location values */ + configuredPluginLocations: Record; + /** Updated plugin location config values */ + updatedPluginLocations: Record | undefined; + /** User home directory used to expand `~` paths */ + userHome: string; } function createDefaults(): MockState { @@ -109,6 +120,11 @@ suite('PluginInstallService', () => { quickInputResult: undefined, configuredMarketplaces: [], updatedMarketplaces: undefined, + resolveIsDirectory: true, + isPluginDirectoryResult: false, + configuredPluginLocations: {}, + updatedPluginLocations: undefined, + userHome: '/home/user', }; } @@ -124,6 +140,7 @@ suite('PluginInstallService', () => { } return state.fileExistsResult; }, + resolve: async (resource: URI) => ({ resource, isDirectory: state.resolveIsDirectory }), } as unknown as IFileService); // INotificationService @@ -278,6 +295,7 @@ suite('PluginInstallService', () => { }, readPluginsFromDirectory: async () => state.readPluginsResult, readSinglePluginManifest: async () => state.singlePluginManifestResult, + isPluginDirectory: async () => state.isPluginDirectoryResult, } as unknown as IPluginMarketplaceService); // IConfigurationService @@ -286,21 +304,35 @@ suite('PluginInstallService', () => { if (key === ChatConfiguration.PluginMarketplaces) { return state.configuredMarketplaces; } + if (key === ChatConfiguration.PluginLocations) { + return state.configuredPluginLocations; + } return undefined; }, inspect: (key: string) => { if (key === ChatConfiguration.PluginMarketplaces) { return { userValue: state.configuredMarketplaces, defaultValue: undefined, policyValue: undefined }; } + if (key === ChatConfiguration.PluginLocations) { + return { userValue: state.configuredPluginLocations, defaultValue: undefined, policyValue: undefined }; + } return { userValue: undefined, defaultValue: undefined, policyValue: undefined }; }, updateValue: async (key: string, value: unknown) => { if (key === ChatConfiguration.PluginMarketplaces) { state.updatedMarketplaces = value as string[]; } + if (key === ChatConfiguration.PluginLocations) { + state.updatedPluginLocations = value as Record; + } }, } as unknown as IConfigurationService); + // IPathService + instantiationService.stub(IPathService, { + userHome: async () => URI.file(state.userHome), + } as unknown as IPathService); + // IQuickInputService instantiationService.stub(IQuickInputService, { input: async () => state.quickInputResult, @@ -853,16 +885,131 @@ suite('PluginInstallService', () => { test('rejects invalid source strings', async () => { const { service, state } = createService(); - await service.installPluginFromSource('not a valid source'); + const result = await service.installPluginFromSource('not a valid source'); + assert.strictEqual(result.success, false); + assert.ok(result.message); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); }); - test('rejects local file URIs', async () => { - const { service, state } = createService(); - await service.installPluginFromSource('file:///some/local/path'); + test('validatePluginSource accepts git and local sources and rejects garbage', () => { + const { service } = createService(); + assert.strictEqual(service.validatePluginSource('owner/repo'), undefined); + assert.strictEqual(service.validatePluginSource('https://github.com/owner/repo.git'), undefined); + assert.strictEqual(service.validatePluginSource('file:///some/path'), undefined); + assert.strictEqual(service.validatePluginSource('/abs/path'), undefined); + assert.strictEqual(service.validatePluginSource('~/plugins/foo'), undefined); + assert.ok(service.validatePluginSource('not a valid source')); + }); + + test('installs a local folder marketplace and registers it under chat.plugins.marketplaces', async () => { + const ref = makeMarketplaceRef('file:///some/marketplace'); + const discoveredPlugin = createPlugin({ + name: 'local-marketplace-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: ref.displayLabel, + marketplaceReference: ref, + marketplaceType: MarketplaceType.OpenPlugin, + }); + const { service, state } = createService({ + readPluginsResult: [discoveredPlugin], + }); + + await service.installPluginFromSource('file:///some/marketplace'); + + assert.strictEqual(state.notifications.length, 0); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.addedPlugins[0].plugin.name, 'local-marketplace-plugin'); + assert.deepStrictEqual(state.updatedMarketplaces, ['file:///some/marketplace']); + assert.strictEqual(state.updatedPluginLocations, undefined); + }); + + test('does not persist a local marketplace to config when trust is declined', async () => { + const ref = makeMarketplaceRef('file:///some/marketplace'); + const discoveredPlugin = createPlugin({ + name: 'local-marketplace-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: ref.displayLabel, + marketplaceReference: ref, + marketplaceType: MarketplaceType.OpenPlugin, + }); + const { service, state } = createService({ + readPluginsResult: [discoveredPlugin], + marketplaceTrusted: false, + dialogConfirmResult: false, + }); + + const result = await service.installPluginFromSource('file:///some/marketplace'); + + assert.strictEqual(result.success, false); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); + assert.strictEqual(state.updatedMarketplaces, undefined); + }); + + test('registers a local folder standalone plugin under chat.pluginLocations', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + }); + + await service.installPluginFromSource('/abs/my-plugin'); + + assert.strictEqual(state.notifications.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + assert.deepStrictEqual(state.updatedPluginLocations, { '/abs/my-plugin': true }); + assert.strictEqual(state.updatedMarketplaces, undefined); + }); + + test('expands ~ paths but persists the original form in chat.pluginLocations', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + userHome: '/home/user', + }); + + await service.installPluginFromSource('~/my-plugin'); + + assert.deepStrictEqual(state.updatedPluginLocations, { '~/my-plugin': true }); + }); + + test('registers a file:// standalone plugin using its filesystem path', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + }); + + await service.installPluginFromSource('file:///some/plugin'); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.ok(state.updatedPluginLocations); + assert.deepStrictEqual(Object.values(state.updatedPluginLocations!), [true]); + assert.strictEqual(Object.keys(state.updatedPluginLocations!).length, 1); + }); + + test('shows error when local folder does not exist', async () => { + const { service, state } = createService({ + resolveIsDirectory: false, + }); + + const result = await service.installPluginFromSource('/abs/missing'); + + assert.strictEqual(result.success, false); + assert.ok(result.message); + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.updatedPluginLocations, undefined); + }); + + test('shows error when local folder is neither a marketplace nor a plugin', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: false, + }); + + const result = await service.installPluginFromSource('/abs/empty'); + + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugin or marketplace found')); + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.updatedPluginLocations, undefined); }); test('installs single plugin from GitHub shorthand with marketplace.json', async () => { @@ -892,11 +1039,11 @@ suite('PluginInstallService', () => { readPluginsResult: [], }); - await service.installPluginFromSource('owner/cool-tool'); + const result = await service.installPluginFromSource('owner/cool-tool'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); test('shows quick pick for multi-plugin repos', async () => { @@ -971,11 +1118,11 @@ suite('PluginInstallService', () => { readPluginsResult: [], }); - await service.installPluginFromSource('https://github.com/owner/my-tool.git'); + const result = await service.installPluginFromSource('https://github.com/owner/my-tool.git'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); test('shows error when clone directory does not exist', async () => { @@ -984,10 +1131,11 @@ suite('PluginInstallService', () => { fileExistsResult: false, }); - await service.installPluginFromSource('owner/missing'); + const result = await service.installPluginFromSource('owner/missing'); + assert.strictEqual(result.success, false); + assert.ok(result.message); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); }); test('adds marketplace to config after installing single plugin', async () => { @@ -1095,7 +1243,7 @@ suite('PluginInstallService', () => { singlePluginManifestResult: singlePlugin, }); - const result = await service.installPluginFromValidatedSource('owner/single-plugin-repo', { plugin: 'requested-name' }); + const result = await service.installPluginFromSource('owner/single-plugin-repo', { plugin: 'requested-name' }); assert.strictEqual(result.success, false); assert.ok(result.message?.includes('not found')); @@ -1109,11 +1257,11 @@ suite('PluginInstallService', () => { singlePluginManifestResult: undefined, }); - await service.installPluginFromSource('owner/empty-repo'); + const result = await service.installPluginFromSource('owner/empty-repo'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts index a4ce29e96e8..e034357747d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts @@ -13,6 +13,7 @@ import { IDialogService } from '../../../../../../platform/dialogs/common/dialog import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IURLService } from '../../../../../../platform/url/common/url.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; @@ -36,7 +37,8 @@ suite('PluginUrlHandler', () => { configUpdates: { key: string; value: unknown; target: ConfigurationTarget }[]; openedEditorInputs: AgentPluginEditorInput[]; openSearchQueries: string[]; - installFromValidatedSourceResult: IInstallPluginFromSourceResult; + installFromSourceResult: IInstallPluginFromSourceResult; + notifications: { severity: number; message: string }[]; } function createHandler(stateOverrides?: Partial): { handler: PluginUrlHandler; state: MockState } { @@ -46,7 +48,8 @@ suite('PluginUrlHandler', () => { configUpdates: [], openedEditorInputs: [], openSearchQueries: [], - installFromValidatedSourceResult: { success: false }, + installFromSourceResult: { success: true }, + notifications: [], ...stateOverrides, }; @@ -57,8 +60,10 @@ suite('PluginUrlHandler', () => { } as unknown as IURLService); instantiationService.stub(IPluginInstallService, { - installPluginFromSource: async (source: string) => { state.installedSources.push(source); }, - installPluginFromValidatedSource: async (_source: string, _options?: IInstallPluginFromSourceOptions) => state.installFromValidatedSourceResult, + installPluginFromSource: async (source: string, _options?: IInstallPluginFromSourceOptions) => { + state.installedSources.push(source); + return state.installFromSourceResult; + }, } as unknown as IPluginInstallService); instantiationService.stub(IDialogService, { @@ -97,6 +102,13 @@ suite('PluginUrlHandler', () => { instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { + notify: (notification: { severity: number; message: string }) => { + state.notifications.push({ severity: notification.severity, message: notification.message }); + return undefined; + }, + } as unknown as INotificationService); + const handler = store.add(instantiationService.createInstance(PluginUrlHandler)); return { handler, state }; } @@ -257,15 +269,14 @@ suite('PluginUrlHandler', () => { }; } - test('install with plugin param delegates to installPluginFromValidatedSource and opens editor', async () => { + test('install with plugin param targets the plugin and opens editor', async () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); - // Broad install (installPluginFromSource) is not used on the targeted path. - assert.deepStrictEqual(state.installedSources, []); + assert.deepStrictEqual(state.installedSources, ['acme/plugins']); // Plugin editor was opened assert.strictEqual(state.openedEditorInputs.length, 1); assert.strictEqual(state.openedEditorInputs[0].item.name, 'my-plugin'); @@ -275,7 +286,7 @@ suite('PluginUrlHandler', () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ dialogConfirmResult: false, - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); @@ -287,7 +298,7 @@ suite('PluginUrlHandler', () => { test('install with base64-encoded plugin param opens editor', async () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const encodedPlugin = toBase64('my-plugin'); const result = await handler.handleURL(uri('/install', `source=acme/plugins&plugin=${encodedPlugin}`)); @@ -298,7 +309,7 @@ suite('PluginUrlHandler', () => { test('install with plugin param falls back to search on failure', async () => { const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: false, message: 'Plugin not found' }, + installFromSourceResult: { success: false, message: 'Plugin not found' }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=nonexistent')); assert.strictEqual(result, true); @@ -309,7 +320,7 @@ suite('PluginUrlHandler', () => { test('install with plugin param falls back to search when no matchedPlugin', async () => { const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true }, + installFromSourceResult: { success: true }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 21f55713b50..511d5da8bcf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -16,20 +16,25 @@ import { SymbolKind, SymbolTag } from '../../../../../../../editor/common/langua import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { toAgentHostUri } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatMarkdownContentPart } from '../../../../browser/widget/chatContentParts/chatMarkdownContentPart.js'; +import { ChatContentMarkdownRenderer } from '../../../../browser/widget/chatContentMarkdownRenderer.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { CodeBlockPart, ICodeBlockData } from '../../../../browser/widget/chatContentParts/codeBlockPart.js'; import { IChatOutputRendererService, type RenderedOutputPart } from '../../../../browser/chatOutputItemRenderer.js'; import { IChatOutputPartStateCache, IOutputPartState } from '../../../../browser/widget/chatContentParts/chatOutputPartStateCache.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; import { IChatContentInlineReference } from '../../../../common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; +import { rewriteAgentHostLinkTarget } from '../../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { IAiEditTelemetryService } from '../../../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { IViewDescriptorService } from '../../../../../../common/views.js'; import { IDisposableReference } from '../../../../browser/widget/chatContentParts/chatCollections.js'; +import { MockChatSessionsService } from '../../../common/mockChatSessionsService.js'; suite('ChatMarkdownContentPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -38,6 +43,7 @@ suite('ChatMarkdownContentPart', () => { let instantiationService: ReturnType; let editorPool: EditorPool; let renderer: IMarkdownRenderer; + let chatSessionsService: MockChatSessionsService; /** Data captured from each CodeBlockPart.render() call */ const renderedCodeBlocks: ICodeBlockData[] = []; @@ -135,6 +141,8 @@ suite('ChatMarkdownContentPart', () => { setup(() => { disposables = store.add(new DisposableStore()); instantiationService = workbenchInstantiationService(undefined, disposables); + chatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, chatSessionsService); renderedCodeBlocks.length = 0; renderedCodeBlockOutputs.length = 0; outputStateCache = new Map(); @@ -210,8 +218,7 @@ suite('ChatMarkdownContentPart', () => { getViewLocationById: () => null, }); - // Use the real markdown renderer service - renderer = instantiationService.get(IMarkdownRendererService); + renderer = instantiationService.createInstance(ChatContentMarkdownRenderer); // Create a mock editor pool editorPool = createMockEditorPool(); @@ -221,6 +228,36 @@ suite('ChatMarkdownContentPart', () => { disposables.dispose(); }); + test('transforms accumulated response Markdown while preserving link text', () => { + disposables.add(chatSessionsService.registerChatSessionContentProvider('chat-session', { + provideChatSessionContent: async () => { throw new Error('Unexpected session resolution'); }, + resolveChatResponseUri: (_resource, href) => rewriteAgentHostLinkTarget(href, 'my-host'), + })); + + const part = createMarkdownPart('`[foo.ts](/code.ts)` [a[b].ts](/remote/a.ts "/remote/a.ts"), [a\\*b.ts](/remote/b.ts), [line.ts](/remote/line.ts:42), [column.ts](/remote/column.ts:42:7), [windows.ts](C:/remote/windows.ts:42), [unc.ts](//server/share/unc.ts:42), [skill](/remote/skill/SKILL.md), and [file-uri.ts](file:///remote/file-uri.ts:42). ![image](/remote/image.png)'); + const links = Array.from(part.domNode.querySelectorAll('a')); + const skillUri = toAgentHostUri(URI.file('/remote/skill/SKILL.md'), 'my-host'); + assert.deepStrictEqual( + { + links: links.map(link => ({ text: link.textContent, href: link.dataset.href })), + imageSource: part.domNode.querySelector('img')?.getAttribute('src'), + }, + { + links: [ + { text: 'a[b].ts', href: toAgentHostUri(URI.file('/remote/a.ts'), 'my-host').toString() }, + { text: 'a*b.ts', href: toAgentHostUri(URI.file('/remote/b.ts'), 'my-host').toString() }, + { text: 'line.ts', href: toAgentHostUri(URI.file('/remote/line.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'column.ts', href: toAgentHostUri(URI.file('/remote/column.ts').with({ fragment: 'L42,7' }), 'my-host').toString() }, + { text: 'windows.ts', href: toAgentHostUri(URI.file('C:/remote/windows.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'unc.ts', href: toAgentHostUri(URI.file('//server/share/unc.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'skill', href: skillUri.with({ query: `${skillUri.query}&vscodeLinkType=skill` }).toString() }, + { text: 'file-uri.ts', href: toAgentHostUri(URI.file('/remote/file-uri.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + ], + imageSource: null, + }, + ); + }); + test('renders plain markdown without code blocks', () => { const part = createMarkdownPart('Hello, world!'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts new file mode 100644 index 00000000000..438e5448168 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatMcpServersStartingContentPart } from '../../../../browser/widget/chatContentParts/chatMcpServersStartingContentPart.js'; +import { IChatMcpServersStartingSlow, IChatMcpStartingServer } from '../../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; + +suite('ChatMcpServersStartingContentPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + + setup(() => { + disposables = store.add(new DisposableStore()); + instantiationService = workbenchInstantiationService(undefined, disposables); + }); + + function createPart(servers: readonly IChatMcpStartingServer[]) { + const servers$ = observableValue('servers', servers); + const data: IChatMcpServersStartingSlow = { + kind: 'mcpServersStartingSlow', + sessionResource: URI.parse('chat-session://test/session1'), + servers: servers$, + }; + const part = disposables.add(instantiationService.createInstance(ChatMcpServersStartingContentPart, data)); + return { part, servers$ }; + } + + test('reflects the starting servers and hides when empty as the observable updates', () => { + const { part, servers$ } = createPart([{ id: 'a', name: 'alpha' }, { id: 'b', name: 'beta' }]); + + const snapshot = () => ({ hidden: part.domNode.style.display === 'none', text: part.domNode.textContent ?? '' }); + + const initial = snapshot(); + + servers$.set([{ id: 'a', name: 'alpha' }], undefined); + const afterOneFinished = snapshot(); + + servers$.set([], undefined); + const afterAllFinished = snapshot(); + + assert.deepStrictEqual({ initial, afterOneFinished, afterAllFinished }, { + initial: { hidden: false, text: 'Starting MCP servers alpha, beta...' }, + afterOneFinished: { hidden: false, text: 'Starting MCP servers alpha...' }, + afterAllFinished: { hidden: true, text: '' }, + }); + }); + + test('hasSameContent matches only the same kind', () => { + const { part } = createPart([{ id: 'a', name: 'alpha' }]); + + assert.deepStrictEqual( + [ + part.hasSameContent({ kind: 'mcpServersStartingSlow' } as IChatRendererContent, [], null!), + part.hasSameContent({ kind: 'mcpAuthenticationRequired' } as IChatRendererContent, [], null!), + ], + [true, false], + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index b1e3678d06b..e1d56f34139 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -8,6 +8,8 @@ import { isHTMLElement } from '../../../../../../../base/browser/dom.js'; import { Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal +import { BaseObservable } from '../../../../../../../base/common/observableInternal/observables/baseObservable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; @@ -1331,6 +1333,39 @@ suite('ChatSubagentContentPart', () => { 'Should auto-expand when tool needs confirmation'); }); + test('should stop tracking a tool invocation once it reaches a terminal state', async () => { + const toolInvocation = createMockToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + const stateObservable = observableValue('state', createState(IChatToolInvocation.StateKind.Executing)); + const childTool: IChatToolInvocation = { + ...createMockToolInvocation({ + toolId: 'readFile', + subAgentInvocationId: toolInvocation.subAgentInvocationId + }), + state: stateObservable, + invocationMessage: 'Reading file' + }; + + part.trackToolState(childTool); + const observerCount = () => (stateObservable as unknown as BaseObservable).debugGetObservers().size; + assert.strictEqual(observerCount(), 1, 'Tracking autorun should observe the tool state'); + + // Complete the tool; disposal of the tracking autorun is deferred via a microtask. + stateObservable.set(createState(IChatToolInvocation.StateKind.Completed), undefined); + await Promise.resolve(); + + assert.strictEqual(observerCount(), 0, 'Tracking autorun should be disposed once the tool reaches a terminal state'); + }); + test('should auto-collapse when confirmation is addressed', () => { const toolInvocation = createMockToolInvocation({ toolSpecificData: { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts new file mode 100644 index 00000000000..54d20bc00be --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IRenderedMarkdown, renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatSystemNotificationContentPart } from '../../../../browser/widget/chatContentParts/chatSystemNotificationContentPart.js'; + +suite('ChatSystemNotificationContentPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders persistent checked notification content', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const renderer: IMarkdownRenderer = { + render: (markdown: IMarkdownString): IRenderedMarkdown => { + const element = mainWindow.document.createElement('div'); + element.textContent = renderAsPlaintext(markdown); + return { element, dispose: () => { } }; + }, + }; + const part = disposables.add(instantiationService.createInstance( + ChatSystemNotificationContentPart, + { kind: 'systemNotification', content: new MarkdownString('Background command completed') }, + renderer, + )); + + assert.deepStrictEqual({ + text: part.domNode.textContent, + hasCheck: !!part.domNode.querySelector('.codicon-check'), + sameContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }), + differentContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Different') }), + }, { + text: 'Background command completed', + hasCheck: true, + sameContent: true, + differentContent: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 53f81dc742c..cd169e190e7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, shouldHideChatUserIdentity, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, shouldHideChatUserIdentity, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; @@ -32,6 +32,22 @@ suite('ChatListRenderer', () => { }); }); + suite('shouldRenderInitialProgressiveContentImmediately', () => { + test('renders accumulated markdown immediately only when progressive rendering has not started', () => { + assert.deepStrictEqual([ + shouldRenderInitialProgressiveContentImmediately(false, true, false), + shouldRenderInitialProgressiveContentImmediately(false, true, true), + shouldRenderInitialProgressiveContentImmediately(true, true, false), + shouldRenderInitialProgressiveContentImmediately(false, false, false), + ], [ + true, + false, + false, + false, + ]); + }); + }); + suite('shouldHideChatUserIdentity', () => { test('hides local Copilot and Agent Host Copilot response identity', () => { assert.deepStrictEqual([ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts index 77e21145127..2ebc5dd95d0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts @@ -181,4 +181,55 @@ suite('ChatSelectedTools', () => { assert.strictEqual(userSelectedTools[toolData3.id], true); }); }); + + test('Can disable a tool from a hidden tool set #324006', () => { + return runWithFakedTimers({}, async () => { + const toolData1: IToolData = { + id: 'testTool1', + modelDescription: 'Test Tool 1', + displayName: 'Test Tool 1', + canBeReferencedInPrompt: true, + toolReferenceName: 't1', + source: ToolDataSource.Internal, + }; + + const toolData2: IToolData = { + id: 'testTool2', + modelDescription: 'Test Tool 2', + displayName: 'Test Tool 2', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + toolReferenceName: 't2', + }; + + // A tool set that is hidden from the tools picker (e.g. a built-in client tool set). + // The user can not toggle it, so it always resolves to enabled and must not force its + // member tools back on when they are individually disabled. + const toolset = toolsService.createToolSet( + ToolDataSource.Internal, + 'hiddenToolSet', 'hiddenToolSet', + { hiddenInToolsPicker: true } + ); + + store.add(toolsService.registerToolData(toolData1)); + store.add(toolsService.registerToolData(toolData2)); + + store.add(toolset); + store.add(toolset.addTool(toolData1)); + store.add(toolset.addTool(toolData2)); + + await timeout(1000); // UGLY the tools service updates its state sync but emits the event async (750ms) delay. This affects the observable that depends on the event + + // Disable tool 2 individually. The hidden tool set has no stored state (the picker + // never surfaces it), so it defaults to enabled. + const toSet = ToolAndToolSetEnablementMap.fromEntries([[toolData1, true], [toolData2, false]]); + selectedTools.set(toSet, false); + + const userSelectedTools = selectedTools.userSelectedTools.get(); + + // The individually disabled tool stays disabled even though its owning tool set resolves to enabled. + assert.strictEqual(userSelectedTools[toolData1.id], true); + assert.strictEqual(userSelectedTools[toolData2.id], false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index 59b59d2928c..600215bcfc6 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -14,6 +14,7 @@ import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/prom import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; +import { AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; suite('CustomizationHarnessService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -46,6 +47,7 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -71,6 +73,7 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -97,6 +100,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -118,6 +122,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -139,6 +144,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -162,6 +168,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -183,10 +190,12 @@ suite('CustomizationHarnessService', () => { const service = createService(); const emitter = new Emitter(); store.add(emitter); + const customFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; const externalDescriptor: IHarnessDescriptor = { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => customFilter, itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -196,6 +205,7 @@ suite('CustomizationHarnessService', () => { store.add(service.registerExternalHarness(externalDescriptor)); service.setActiveSession(activeSessionResource); + assert.deepStrictEqual(service.getActiveDescriptor().getStorageSourceFilter(PromptsType.agent), customFilter); }); test('external harness item provider returns items', async () => { @@ -215,6 +225,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider, }; const activeSessionResource = URI.parse('test-ext://session'); @@ -235,6 +246,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -248,6 +260,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -268,6 +281,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -280,6 +294,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -300,6 +315,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -312,6 +328,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -343,6 +360,7 @@ suite('CustomizationHarnessService', () => { id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ @@ -372,6 +390,7 @@ suite('CustomizationHarnessService', () => { id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ @@ -462,6 +481,7 @@ suite('CustomizationHarnessService', () => { id: testSessionType1, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c344272965e..76e524a7214 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -172,6 +172,11 @@ export class MockChatSessionsService implements IChatSessionsService { return provider.provideChatInputCompletions(sessionResource, params, token); } + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string { + const sessionType = getChatSessionType(sessionResource); + return this.contentProviders.get(sessionType)?.resolveChatResponseUri?.(sessionResource, href, kind) ?? href; + } + async getChatInputCompletionTriggerCharacters(sessionType: string): Promise { const provider = this.contentProviders.get(sessionType); if (!provider) { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index dd7d97e8844..10408fa892e 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -407,6 +407,20 @@ suite('Response', () => { await assertSnapshot(response.value); }); + test('system notification remains distinct from later response content', () => { + const response = store.add(new Response([])); + response.updateContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }); + response.updateContent({ kind: 'markdownContent', content: new MarkdownString('Finished processing output.') }); + + assert.deepStrictEqual({ + kinds: response.value.map(part => part.kind), + text: response.toString(), + }, { + kinds: ['systemNotification', 'markdownContent'], + text: 'Background command completed\n\nFinished processing output.', + }); + }); + test('inline reference', async () => { const response = store.add(new Response([])); response.updateContent({ content: new MarkdownString('text before '), kind: 'markdownContent' }); diff --git a/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts index 97a73d0cf3d..76f65c731a7 100644 --- a/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts +++ b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts @@ -7,22 +7,14 @@ import * as nls from '../../../../nls.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { equals } from '../../../../base/common/arrays.js'; -import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService, INativeSystemWideKeybinding } from '../../../../platform/native/common/native.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; -/** - * Persisted flag recording that the user has been shown the one-time heads-up explaining that their - * `systemWide` keybindings are captured globally. Set once the notice has been acknowledged. - */ -const ACKNOWLEDGED_STORAGE_KEY = 'systemWideKeybindings.acknowledged'; - export interface ISystemWideKeybindingCandidate { readonly accelerator: string; readonly commandId: string; @@ -88,9 +80,8 @@ export function selectSystemWideKeybindings(items: readonly ResolvedKeybindingIt /** * Watches the resolved keybindings for entries opted into `systemWide` and mirrors them to the - * main process (which owns Electron's `globalShortcut`). The mechanism is always active; a one-time - * notice the first time such a keybinding is registered makes the user aware the combo is captured - * globally. + * main process (which owns Electron's `globalShortcut`). The mechanism is always active for any + * user keybinding marked `systemWide`. */ export class SystemWideKeybindingsContribution extends Disposable implements IWorkbenchContribution { @@ -104,14 +95,9 @@ export class SystemWideKeybindingsContribution extends Disposable implements IWo /** User settings labels whose ignored `when` clause we already warned about. */ private readonly warnedWhenLabels = new Set(); - /** Guards against showing the one-time notice more than once concurrently. */ - private noticeInFlight = false; - constructor( @IKeybindingService private readonly keybindingService: IKeybindingService, @INativeHostService private readonly nativeHostService: INativeHostService, - @IStorageService private readonly storageService: IStorageService, - @IDialogService private readonly dialogService: IDialogService, @INotificationService private readonly notificationService: INotificationService, @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService, @@ -140,21 +126,6 @@ export class SystemWideKeybindingsContribution extends Disposable implements IWo return; } - // Show a one-time heads-up before the very first registration so the user is aware the combo - // is captured globally (fires while unfocused). Informational only - the feature stays on. - if (!this.storageService.getBoolean(ACKNOWLEDGED_STORAGE_KEY, StorageScope.APPLICATION, false)) { - if (this.noticeInFlight) { - return; - } - this.noticeInFlight = true; - try { - await this.notifyFirstRun(candidates); - } finally { - this.noticeInFlight = false; - } - this.storageService.store(ACKNOWLEDGED_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - this.warnAboutIgnoredWhenClauses(candidates); await this.pushToMainProcess(candidates); } @@ -197,16 +168,12 @@ export class SystemWideKeybindingsContribution extends Disposable implements IWo userSettingsLabel: candidate.userSettingsLabel, })); - let failed: string[]; try { const result = await this.nativeHostService.syncSystemWideKeybindings(payload); - failed = result.failed; + this.reportFailures(result.failed); } catch (error) { this.logService.error('[SystemWideKeybindings] failed to sync system-wide keybindings with the main process', error); - return; } - - this.reportFailures(failed); } private reportFailures(failed: string[]): void { @@ -226,19 +193,6 @@ export class SystemWideKeybindingsContribution extends Disposable implements IWo }); } - private async notifyFirstRun(candidates: readonly ISystemWideKeybindingCandidate[]): Promise { - const labels = candidates.map(candidate => candidate.userSettingsLabel).join(', '); - await this.dialogService.prompt({ - type: Severity.Info, - message: nls.localize('systemWideKeybindings.notice.message', "{0} is registering system-wide keybindings", this.productName()), - detail: nls.localize('systemWideKeybindings.notice.detail', "The keybindings you marked with \"systemWide\" ({0}) are captured by the operating system and trigger their command even when {1} is not focused, taking the key combination away from other applications.", labels, this.productName()), - buttons: [{ - label: nls.localize({ key: 'systemWideKeybindings.notice.acknowledge', comment: ['&& denotes a mnemonic'] }, "&&I Understand"), - run: () => { }, - }], - }); - } - private productName(): string { return this.productService.nameLong; } diff --git a/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md new file mode 100644 index 00000000000..83031d252cf --- /dev/null +++ b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md @@ -0,0 +1,194 @@ +# System-wide (OS global) keybindings + +This document is the canonical spec for the **system-wide keybindings** feature: user +`keybindings.json` entries that fire even when VS Code is not the focused application, backed by +Electron's [`globalShortcut`](https://www.electronjs.org/docs/latest/api/accelerator) module. + +History: introduced in PR microsoft/vscode#323871; the first-run notice dialog was later removed in +PR microsoft/vscode#324045 (it could appear in multiple windows at once, and the single-window +election meant to prevent that was racy). The feature is **desktop only** and **always on**. + +## User-facing contract + +A user adds `"systemWide": true` to a keybinding entry in **`keybindings.json`**: + +```jsonc +{ + "key": "ctrl+cmd+a", + "command": "workbench.action.openAgentsWindow", + "systemWide": true +} +``` + +While VS Code is running (even unfocused), pressing that combination runs the command. Rules: + +- **User keybindings only.** Default and extension-contributed keybindings can never be + system-wide — this prevents extensions from silently grabbing OS-global shortcuts. +- **Single key combinations only.** Chords (`ctrl+k ctrl+c`) and single-modifier bindings cannot be + expressed as an Electron accelerator; they are skipped with a `warn` log. +- **`when` clauses are ignored** for the global trigger — an OS-global shortcut has no editor/UI + context, so it is always active while VS Code runs. The user is warned once per binding label. +- **First binding wins** on accelerator conflicts (deduped both in the renderer selection and in the + main-process per-window payload). +- **Desktop only.** The flag is inert on web/server (there is no `globalShortcut` there, and the + contribution is `electron-browser`). +- **Always on.** There is no setting to enable/disable it and no first-run dialog. + +The `systemWide` boolean is declared in the `keybindings.json` JSON schema in +`src/vs/workbench/services/keybinding/browser/keybindingService.ts`. + +## Architecture at a glance + +The feature spans the renderer (electron-browser) and the Electron main process, connected by the +existing native-host IPC channel. + +```mermaid +sequenceDiagram + participant KB as IKeybindingService (renderer) + participant C as SystemWideKeybindingsContribution (renderer) + participant NH as NativeHostMainService (main) + participant G as GlobalKeybindingsMainService (main) + participant OS as Electron globalShortcut / OS + participant W as window.ts vscode:runAction (renderer) + + KB->>C: onDidUpdateKeybindings (also fires on layout change) + C->>C: selectSystemWideKeybindings() (debounced 200ms) + C->>NH: syncSystemWideKeybindings(payload) [per window] + NH->>G: updateKeybindings(windowId, payload) + G->>OS: register / unregister accelerators (union across windows) + G-->>C: { failed: string[] } (surfaced as a warning notification) + OS->>G: accelerator pressed -> onTrigger(accelerator) + G->>W: sendWhenReady('vscode:runAction', { id, from:'systemWideKeybinding', args }) + W->>W: commandService.executeCommand(id, ...args) +``` + +### Renderer + +**`src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts`** — +the heart of the renderer side. Registered as a workbench contribution at +`WorkbenchPhase.AfterRestored` (never blocks startup). + +- `selectSystemWideKeybindings(items)` — a **pure** function that filters the full set of resolved + keybindings down to eligible candidates. Eligibility: `item.systemWide && !item.isDefault && + item.command && item.resolvedKeybinding && getElectronAccelerator() !== null` and the accelerator + has not already been claimed. Returns `{ candidates, unsupported, duplicates }` so the caller can + log why entries were dropped. Kept pure and separately unit-tested. +- `SystemWideKeybindingsContribution` — subscribes to `IKeybindingService.onDidUpdateKeybindings` + (which also fires on keyboard-layout changes, since accelerator strings depend on layout), + debounces via a 200ms `RunOnceScheduler`, and on each `sync()`: + 1. collects candidates (logging unsupported/duplicate entries), + 2. warns once per label about ignored `when` clauses (`warnedWhenLabels` guard), + 3. pushes the payload to the main process via `INativeHostService.syncSystemWideKeybindings`, + 4. reports registration failures via `INotificationService`, deduped against the last reported set + (`lastReportedFailures`) so unchanged failures are not re-notified. + +**`src/vs/workbench/electron-browser/window.ts`** — handles the `vscode:runAction` IPC in the +renderer. For `request.from === 'systemWideKeybinding'` it runs the command with **exactly** the +args configured in `keybindings.json` — unlike the `menu`/`mouse` senders it does **not** append a +`{ from }` sentinel, so a command taking positional args receives the same payload it would from a +normal in-window keybinding. Emits the standard `workbenchActionExecuted` telemetry. + +### IPC contract + +**`src/vs/platform/native/common/native.ts`** + +- `INativeSystemWideKeybinding` — `{ accelerator, commandId, args?, userSettingsLabel? }`. The + `accelerator` is in Electron accelerator format (e.g. `Control+Cmd+A`). +- `INativeSystemWideKeybindingResult` — `{ failed: string[] }`. `failed` lists user-settings labels + (or accelerators) that could not be registered. +- `ICommonNativeHostService.syncSystemWideKeybindings(keybindings)` — the method exposed to the + renderer. + +**`src/vs/platform/window/common/window.ts`** — `INativeRunActionInWindowRequest.from` includes +`'systemWideKeybinding'` in its union. + +### Main process + +**`src/vs/platform/native/electron-main/nativeHostMainService.ts`** — `syncSystemWideKeybindings` +delegates to `IGlobalKeybindingsMainService.updateKeybindings(windowId, ...)`. If there is no +`windowId` it returns `{ failed: [] }`. + +**`src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts`** — owns the OS +registrations. Wired up in `src/vs/code/electron-main/app.ts` with the real Electron global: +`services.set(IGlobalKeybindingsMainService, new SyncDescriptor(GlobalKeybindingsMainService, [globalShortcut]))`. + +- `IGlobalShortcutRegistry` — the tiny `register/unregister/isRegistered` subset of Electron's + `globalShortcut`, injected as a constructor arg (not imported directly) so tests can supply a fake. +- State: + - `registry: Map>` — each window's desired bindings. + - `registeredAccelerators: Set` — accelerators this service currently owns an OS + registration for. + - `failedAccelerators: Set` — desired accelerators that failed to register (e.g. already + taken); retried on the next reconcile. +- `updateKeybindings(windowId, keybindings)` — validates + dedups the payload for that window, + replaces (or clears) the window's entry, calls `reconcile()`, and returns the `failed` labels for + that window. +- `reconcile()` — computes the union of desired accelerators across **all** windows. Unregisters + only accelerators this service owns that are no longer desired (never touches shortcuts owned + elsewhere), then registers newly desired ones (retrying previously failed). Each accelerator is + registered **once** with a stable callback `() => this.onTrigger(accelerator)` that reads the + **current** registry at fire time, so command/args are never captured from a stale snapshot. +- `onTrigger(accelerator)` — resolves the target window: the focused window if it owns the + accelerator, otherwise the deterministic winner (lowest window id) among alive owners. Sends + `vscode:runAction` via `target.sendWhenReady(...)`. It deliberately does **not** force-focus the + routing window — a system-wide keybinding fires while VS Code is typically unfocused, and pulling + the routing window forward would flicker when the command opens/reveals a *different* window + (e.g. `workbench.action.openAgentsWindow`). This matches every other `vscode:runAction` sender. +- Lifecycle: on `IWindowsMainService.onDidDestroyWindow` it drops the window's entry and reconciles; + on `ILifecycleMainService.onWillShutdown` and on dispose it unregisters everything. + +### How the `systemWide` flag is plumbed + +The boolean travels from `keybindings.json` to `ResolvedKeybindingItem`: + +- `src/vs/workbench/services/keybinding/common/keybindingIO.ts` — parses `systemWide` from each + entry (`IUserKeybindingItem`) and writes it back out on serialization. +- `src/vs/platform/keybinding/common/keybinding.ts` — `IUserKeybindingItem.systemWide?: boolean`. +- `src/vs/platform/keybinding/common/resolvedKeybindingItem.ts` — `ResolvedKeybindingItem.systemWide` + (defaults `false`; only ever `true` for user keybindings). +- `src/vs/workbench/services/keybinding/browser/keybindingService.ts` — threads `item.systemWide` + into every `ResolvedKeybindingItem` it builds, and declares the schema property. + +## Design decisions and rationale + +1. **User-only, single-combo, `when`-ignored** — hard constraints of OS-global shortcuts; each is + enforced and surfaced (log for unsupported/duplicate, notification for ignored `when`). +2. **Per-window registry + union registration** — multiple windows may each request the same or + different system-wide bindings. The main service registers the union and routes a press to a + single window (focused owner, else deterministic lowest id). It only ever unregisters + accelerators it owns, so it never stomps global shortcuts registered by the OS or other apps. +3. **No force-focus on trigger** — avoids visible flicker and lets the invoked command decide what + to surface/focus. +4. **Always-on, no dialog** — the feature is unconditionally active; the earlier one-time notice was + removed because it leaked into multiple windows and its single-window election was racy. Users who + opt a binding into `systemWide` are assumed to understand the implication. +5. **Deduped failure notifications** — `reportFailures` only notifies when the failed set changes, so + a persistent conflict is reported once, not on every re-sync. +6. **Stable trigger callback reading live state** — registering once per accelerator (rather than + re-registering on every payload change) avoids races and stale command/args capture. + +## Testing + +- `src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts` — the + main service against a fake `IGlobalShortcutRegistry` and fake windows service: register/reconcile, + dedup within a window, failed-registration reporting + retry, trigger routing (focused owner, + deterministic lowest-id, no force-focus, undefined args), cross-window conflict resolution, window + destroy unregistration, and shutdown unregister-all. Pure electron-main (node-safe, no DOM/CSS). +- `src/vs/workbench/contrib/keybindings/test/electron-browser/systemWideKeybindings.test.ts` — the + pure `selectSystemWideKeybindings`: eligibility filtering, unsupported (chords/modifiers), + duplicates. +- `keybindingIO.test.ts` / `keybindingEditing.test.ts` — round-trip of the `systemWide` flag through + parse/serialize. + +## Gotchas for future work + +- Adding another `vscode:runAction` sender kind requires extending the `from` union in + `INativeRunActionInWindowRequest` (`src/vs/platform/window/common/window.ts`) and handling it in + `window.ts`. +- Accelerator strings are keyboard-layout dependent; the contribution re-syncs on + `onDidUpdateKeybindings`, which also fires on layout changes. +- `globalShortcut.register` returns `false` (or throws) when an accelerator is already taken by the + OS or another application; such accelerators land in `failedAccelerators`, are retried on the next + reconcile, and are surfaced to the user as a warning. +- Keep `selectSystemWideKeybindings` pure — it is the primary unit-tested seam for renderer + eligibility logic. diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 5731e1e3b0e..0b0ed1c1180 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { IMcpConfigPath, IMcpWorkbenchService, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -77,7 +77,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const config = server.config; const mcpConfigPath = await mcpConfigPathPromise; - const collectionId = `mcp.config.${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; + const collectionId = `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; let definitions = collections.get(collectionId); if (!definitions) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 6ca7dc0d6a7..f538b653055 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -27,7 +27,7 @@ import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServe import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolder, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; @@ -37,6 +37,14 @@ import { UriTemplate } from '../../../../base/common/uriTemplate.js'; export const extensionMcpCollectionPrefix = 'ext.'; +/** + * Prefix of the collection id used for MCP servers configured via the various + * `mcp.json`-style config files (user, remote user, workspace, and + * `.vscode/mcp.json` workspace-folder configs). The suffix is the + * {@link IMcpConfigPath.id} of the originating config path. + */ +export const MCP_CONFIGURATION_COLLECTION_ID_PREFIX = 'mcp.config.'; + export function extensionPrefixedIdentifier(identifier: ExtensionIdentifier, id: string): string { return ExtensionIdentifier.toKey(identifier) + '/' + id; } @@ -119,6 +127,27 @@ export namespace McpCollectionDefinition { && a.trustBehavior === b.trustBehavior && objectsEqual(a.sandbox, b.sandbox); } + + /** + * Returns `true` when the collection was discovered from the workspace (its + * config target is the workspace or a workspace folder). This is + * intentionally based on the config target and not the storage scope: + * extension-contributed collections use a workspace storage scope but are + * configured at the user level, so they are not workspace-discovered. + */ + export function isWorkspaceDiscovered(collection: McpCollectionDefinition): boolean { + return collection.configTarget === ConfigurationTarget.WORKSPACE + || collection.configTarget === ConfigurationTarget.WORKSPACE_FOLDER; + } + + /** + * Returns `true` when the collection originates from a `.vscode/mcp.json` + * workspace-folder config, identified by its collection id prefix (the + * shared `mcp.config.` prefix plus the workspace-folder config id). + */ + export function isVscodeMcpJson(collection: McpCollectionDefinition): boolean { + return collection.id.startsWith(`${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${WORKSPACE_FOLDER_CONFIG_ID_PREFIX}`); + } } export interface McpServerDefinition { diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts index 72514432fcf..eea4b5e31c1 100644 --- a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -30,7 +30,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IWorkbenchEditorConfiguration, EditorResourceAccessor, isEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from '../../../services/editor/common/editorService.js'; -import { Range, IRange } from '../../../../editor/common/core/range.js'; +import { IRange } from '../../../../editor/common/core/range.js'; import { ThrottledDelayer } from '../../../../base/common/async.js'; import { top } from '../../../../base/common/arrays.js'; import { FileQueryCacheState } from '../common/cacheState.js'; @@ -1109,7 +1109,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider<#|:|(><#|:|,><:?> -const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?:?\s*$/; +// Supports patterns of <#|:|(><#|:|,>> optionally followed by a range suffix <-<#|:|,>> +const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?(?:-(\d*)(?:[#:,](\d*))?)?\)?:?\s*$/; export interface IFilterAndRange { filter: string; @@ -194,6 +194,20 @@ export function extractRangeFromFilter(filter: string, unless?: string[]): IFilt endColumn: startColumn }; } + + // End Line Number (range selection, e.g. "20-40") + const endLineNumber = parseInt(patternMatch[3] ?? '', 10); + if (isNumber(endLineNumber)) { + + // End Column Number (e.g. "20:3-40:5"), defaults to the start of the end line + const endColumn = parseInt(patternMatch[4] ?? '', 10); + range = { + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: endLineNumber, + endColumn: isNumber(endColumn) ? endColumn : 1 + }; + } } // User has typed "something:" or "something#" without a line number, in this case treat as start of file diff --git a/src/vs/workbench/contrib/search/test/common/extractRange.test.ts b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts index 9072b36cdac..44f212bdaad 100644 --- a/src/vs/workbench/contrib/search/test/common/extractRange.test.ts +++ b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts @@ -46,6 +46,34 @@ suite('extractRangeFromFilter', () => { assert.strictEqual(res?.range.startColumn, 20); }); + suite('ranges', function () { + const base = '/some/path/file.txt'; + const testSpecs = [ + // line range: "20-40" + { filter: `${base}:20-40`, range: { startLineNumber: 20, startColumn: 1, endLineNumber: 40, endColumn: 1 } }, + // line and column range: "20:3-40:5" + { filter: `${base}:20:3-40:5`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // end column defaults to start of the end line: "20:3-40" + { filter: `${base}:20:3-40`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 1 } }, + // mixed separators: "20#3-40,5" + { filter: `${base}#20#3-40,5`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // paren style: "(20,3-40,5)" + { filter: `${base}(20,3-40,5)`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // dangling separator falls back to single line: "20-" + { filter: `${base}:20-`, range: { startLineNumber: 20, startColumn: 1, endLineNumber: 20, endColumn: 1 } }, + ]; + for (const { filter, range } of testSpecs) { + test(filter, () => { + assert.deepStrictEqual(extractRangeFromFilter(filter), { filter: base, range }); + }); + } + + test('hyphen in path is not treated as a range', () => { + assert.ok(!extractRangeFromFilter('/some/path/my-file.txt')); + assert.ok(!extractRangeFromFilter('/some/path/file-2.txt')); + }); + }); + suite('unless', function () { const testSpecs = [ // alpha-only symbol after unless diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index 72cb2e0dfed..37eeb6ec1da 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -262,7 +262,6 @@ box-sizing: border-box; height: 24px; line-height: 24px; - margin-bottom: 4px; } .style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label::before, @@ -285,7 +284,6 @@ height: 24px; padding: 0; width: 24px; - margin-bottom: 4px; } .style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .active-item-indicator, @@ -304,7 +302,7 @@ gap: 4px; } -.style-override.monaco-workbench .pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item, -.style-override.monaco-workbench .pane-composite-part > .title.has-composite-bar > .global-actions .monaco-action-bar .action-item { - margin-bottom: 4px; +.style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact, +.style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { + overflow: visible; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index ee21b7b3594..5318d663ec5 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -45,16 +45,31 @@ width: auto; } -/* Tighten the part title bar gutters: flush-left, small right inset. */ +/* Tighten the part title bar gutters: flush-left, small right inset. Reduce height from 35px to 32px. + * KEEP IN SYNC WITH: part.ts PartLayout.TITLE_HEIGHT_STYLE_OVERRIDE */ .style-override.monaco-workbench .part > .title { + height: 32px; padding-left: var(--vscode-spacing-size40, 4px); padding-right: var(--vscode-spacing-size40, 4px); } +.style-override.monaco-workbench .part > .title > .title-label { + line-height: 32px; +} + +.style-override.monaco-workbench .part > .title > .title-actions { + height: 32px; +} + .style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions { padding: 0 4px; } +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions .actions-container > li:last-child, +.style-override.monaco-workbench .part > .title .title-actions .actions-container > li:last-child { + margin-right: 0; +} + /* Inset the part title label (e.g. "Explorer") to align with the content below. */ .style-override.monaco-workbench .part > .title > .title-label { padding-left: var(--vscode-spacing-size80, 8px); @@ -68,6 +83,16 @@ .agent-session-item .agent-session-title-toolbar .actions-container { gap: 4px; } + &.chat-view-location-auxiliarybar { + .chat-view-title-inner { + padding: var(--vscode-spacing-size40, 4px); + } + } + &.chat-view-location-sidebar, &.chat-view-location-panel { + .chat-view-title-inner { + padding: 0 var(--vscode-spacing-size40, 4px) 0 var(--vscode-spacing-size80, 8px); + } + } } .style-override.monaco-workbench .monaco-pane-view .pane > .pane-header > .actions { @@ -92,5 +117,7 @@ .style-override.monaco-workbench .part.basepanel.top .composite.title, .style-override.monaco-workbench .part.basepanel.left .composite.title, .style-override.monaco-workbench .part.basepanel.right .composite.title { - padding-right: 0; + padding-right: 2px; + overflow: visible; } + diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index c8b11ef2011..9154e628d93 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -70,6 +70,12 @@ border-radius: var(--vscode-cornerRadius-small) !important; } +/* Update indicator (title-bar "Update" button — base CSS uses medium, but as a + * control it belongs to the controls tier). */ +.style-override .monaco-action-bar .update-indicator { + border-radius: var(--vscode-cornerRadius-small) !important; +} + /* * Agent status pill (experimental title-bar widget). A connected pill/badge * group whose segments hardcode 5-6px per-corner radii, with a few intentional @@ -230,3 +236,7 @@ .style-override .monaco-sash:not(.disabled) > .orthogonal-drag-handle { border-radius: var(--vscode-sash-size); } + +.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .terminal-tabs-chat-entry .terminal-tabs-entry { + border-radius: var(--vscode-cornerRadius-small); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 69573363f63..011c164e14b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -52,7 +52,6 @@ position: relative; } -.style-override .monaco-list > .monaco-scrollable-element::before, .style-override .monaco-list > .monaco-scrollable-element::after { content: ""; position: absolute; @@ -63,13 +62,17 @@ z-index: 10; } +.style-override .monaco-list > .monaco-scrollable-element::after { + bottom: 0; + background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); +} + /* ============================================================================= * Editor area. The main editor viewport scrolls inside `.editor-scrollable`; * pin the fades to that scrollable element so they share the scrollbar's * stacking context and sit below it. Scope to the editor / panel parts so the * fade never leaks onto inline editors such as the chat input. * ========================================================================== */ -.style-override .part.editor .monaco-editor .editor-scrollable::before, .style-override .part.editor .monaco-editor .editor-scrollable::after { content: ""; position: absolute; @@ -81,10 +84,23 @@ z-index: 10; } -.style-override .part.editor .monaco-editor .editor-scrollable::before, -.style-override .part.panel .monaco-editor .editor-scrollable::before { +/* Editor top shadow: delegate to the built-in scroll-decoration element. */ +.style-override .part.editor .monaco-editor .scroll-decoration { + position: absolute; top: 0; - background: linear-gradient(to bottom, var(--scroll-shadow-surface), transparent); + left: 0; + height: 8px; + box-shadow: var(--vscode-editor-background) 0 8px 8px -8px inset; +} + +.style-override .part.panel .monaco-editor .editor-scrollable::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 8px; + pointer-events: none; + z-index: 10; } .style-override .part.editor .monaco-editor .editor-scrollable::after, diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 641ae45d4dc..5368edb2ec3 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -211,3 +211,12 @@ .style-override .settings-editor > .settings-header > .settings-header-controls { border-bottom: none !important; } + +.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .tabs-list .terminal-tabs-entry { + padding-left: 8px; + padding-right: 8px; +} + +.style-override.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry { + padding: 4px; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 2c194efdc08..d8629b23db4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -418,7 +418,7 @@ suite('TerminalSandboxService - network domains', () => { ok(configContent, 'Config file should be created'); const config = JSON.parse(configContent); - deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [], enabled: false }); + deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [], enabled: false, allowUnixSockets: true }); strictEqual(config.allowPty, true, 'Non-network runtime settings should still be merged'); }); diff --git a/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts b/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts new file mode 100644 index 00000000000..4ae5d29b7c2 --- /dev/null +++ b/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IAgentEditorCommentsBridge = createDecorator('agentEditorCommentsBridge'); + +/** A comment to render on top of an editor for a session-scoped resource. */ +export interface IAgentEditorComment { + readonly id: string; + readonly range: IRange; + readonly body: string; +} + +/** + * Supplies the session comments for a resource. Implemented by the sessions + * layer (backed by the agent feedback store) and registered into the bridge. + */ +export interface IAgentEditorCommentsProvider { + readonly onDidChangeComments: Event; + getComments(resource: URI): readonly IAgentEditorComment[]; + addComment(resource: URI, range: IRange, body: string): void; +} + +/** + * Workbench-layer seam that lets the (globally registered) main-thread + * extension host customer read and contribute session editor comments without + * depending on the sessions layer directly. When no provider is registered + * (e.g. the regular workbench window) the bridge is a no-op, so the customer + * degrades gracefully. + */ +export interface IAgentEditorCommentsBridge { + readonly _serviceBrand: undefined; + + /** Fired when comments change, or when a provider is registered/unregistered. */ + readonly onDidChangeComments: Event; + + getComments(resource: URI): readonly IAgentEditorComment[]; + addComment(resource: URI, range: IRange, body: string): void; + + /** Install the provider that backs this bridge. Only one provider is active at a time. */ + registerProvider(provider: IAgentEditorCommentsProvider): IDisposable; +} + +export class AgentEditorCommentsBridge extends Disposable implements IAgentEditorCommentsBridge { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeComments = this._register(new Emitter()); + readonly onDidChangeComments = this._onDidChangeComments.event; + + private _provider: IAgentEditorCommentsProvider | undefined; + private readonly _providerListener = this._register(new MutableDisposable()); + + registerProvider(provider: IAgentEditorCommentsProvider): IDisposable { + this._provider = provider; + this._providerListener.value = provider.onDidChangeComments(() => this._onDidChangeComments.fire()); + this._onDidChangeComments.fire(); + return toDisposable(() => { + if (this._provider === provider) { + this._provider = undefined; + this._providerListener.clear(); + this._onDidChangeComments.fire(); + } + }); + } + + getComments(resource: URI): readonly IAgentEditorComment[] { + return this._provider?.getComments(resource) ?? []; + } + + addComment(resource: URI, range: IRange, body: string): void { + this._provider?.addComment(resource, range, body); + } +} + +registerSingleton(IAgentEditorCommentsBridge, AgentEditorCommentsBridge, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/keybinding/browser/keybindingService.ts b/src/vs/workbench/services/keybinding/browser/keybindingService.ts index 8ac8f4f00ae..5db49b2be12 100644 --- a/src/vs/workbench/services/keybinding/browser/keybindingService.ts +++ b/src/vs/workbench/services/keybinding/browser/keybindingService.ts @@ -930,7 +930,7 @@ class KeybindingsJsonSchema { 'systemWide': { 'type': 'boolean', 'default': false, - 'markdownDescription': nls.localize('keybindings.json.systemWide', "When `true`, registers this keybinding as a system-wide (OS global) shortcut that fires even when the application is not focused. Desktop only. Only single key combinations are supported (no chords), and any `when` clause is ignored for the global trigger. The first time such a keybinding is registered you will see a one-time notice.") + 'markdownDescription': nls.localize('keybindings.json.systemWide', "When `true`, registers this keybinding as a system-wide (OS global) shortcut that fires even when the application is not focused. Desktop only. Only single key combinations are supported (no chords), and any `when` clause is ignored for the global trigger.") } }, '$ref': '#/definitions/commandsSchemas' diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 99405e3d855..44f587aeb63 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -450,7 +450,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme private installPreferredSchemeListener() { this._register(this.hostColorService.onDidChangeColorScheme(() => { - if (this.settings.isDetectingColorScheme()) { + if (this.settings.isDetectingColorScheme() || this.settings.isDetectingHighContrast()) { this.restoreColorTheme(); } })); diff --git a/src/vs/workbench/services/themes/common/themeConfiguration.ts b/src/vs/workbench/services/themes/common/themeConfiguration.ts index 34902a14c79..4e2a83c10dd 100644 --- a/src/vs/workbench/services/themes/common/themeConfiguration.ts +++ b/src/vs/workbench/services/themes/common/themeConfiguration.ts @@ -343,6 +343,10 @@ export class ThemeConfiguration { return undefined; } + public isDetectingHighContrast(): boolean { + return this.configurationService.getValue(ThemeSettings.DETECT_HC); + } + public isDetectingColorScheme(): boolean { return this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME); } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts index f17a9cabe2a..4ee3ec58189 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts @@ -31,7 +31,7 @@ function createMockContext(): IChatContentPartRenderContext { }; } -function renderCollapsible(context: ComponentFixtureContext, commandText: string, isSandboxWrapped: boolean, isComplete: boolean, isSkipped: boolean = false, isRunningInBackground: boolean = false): void { +function renderCollapsible(context: ComponentFixtureContext, commandText: string, isSandboxWrapped: boolean, isComplete: boolean, isSkipped: boolean = false, isRunningInBackground: boolean = false, intention: string | undefined = undefined): void { const { container, disposableStore } = context; const instantiationService = createEditorServices(disposableStore, { @@ -53,6 +53,7 @@ function renderCollapsible(context: ComponentFixtureContext, commandText: string const wrapper = disposableStore.add(instantiationService.createInstance( ChatTerminalThinkingCollapsibleWrapper, commandText, + intention, isSandboxWrapped, contentElement, createMockContext(), @@ -94,4 +95,22 @@ export default defineThemedFixtureGroup({ path: 'chat/terminalCollapsible/' }, { 'Ran sandbox - powershell backticks': defineComponentFixture({ render: ctx => renderCollapsible(ctx, 'Get-Process | Where-Object {$_.Name -eq `"notepad`"}', true, true), }), + 'Ran - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', false, true, false, false, 'List files in the repo root'), + }), + 'Running - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'npm test', false, false, false, false, 'Run the test suite'), + }), + 'Ran sandbox - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', true, true, false, false, 'List files in the repo root'), + }), + 'Ran - long intention and command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'grep -rn deprecatedHelper ./src --include=*.ts --color=never | head -50', false, true, false, false, 'Search the entire repository for references to the deprecated helper function'), + }), + 'Ran - long intention short command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'pwd', false, true, false, false, 'Print the absolute path of the current working directory so I know where I am'), + }), + 'Ran - short intention long command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'find . -type f -name "*.ts" -not -path "*/node_modules/*" -newer package.json', false, true, false, false, 'Find changed files'), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts index 45c4a252e56..7ebe04b20de 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts @@ -21,7 +21,7 @@ import { AgentSessionRenderer, AgentSessionSectionRenderer, IAgentSessionRendere import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; import { AgentSessionStatus, IAgentSession, AgentSessionSection, IAgentSessionSection } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { AgentSessionProviders } from '../../../../contrib/chat/browser/agentSessions/agentSessions.js'; -import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -599,6 +599,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-json'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Other, label: '{ "action": "deleteFile", "path": "/src/old-module.ts" }', languageId: 'json', since: new Date(), @@ -623,6 +624,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-bash'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', since: new Date(), @@ -647,6 +649,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-powershell'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'Start-Job -ScriptBlock { Set-Location \'c:\\some\\path\'; npm install } | Out-Null', languageId: 'pwsh', since: new Date(), @@ -671,6 +674,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-long'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'rm -rf node_modules && npm cache clean --force && npm install --legacy-peer-deps --ignore-scripts', languageId: 'sh', since: new Date(), @@ -697,6 +701,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-1line'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', since: new Date(), @@ -721,6 +726,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-2lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install', languageId: 'sh', since: new Date(), @@ -745,6 +751,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build', languageId: 'sh', since: new Date(), @@ -769,6 +776,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-4lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', languageId: 'sh', since: new Date(), @@ -793,6 +801,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', languageId: 'sh', since: new Date(), diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts new file mode 100644 index 00000000000..5a15d0a4549 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; +// eslint-disable-next-line local/code-import-patterns +import { IChat, IGitHubInfo, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionsList } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsList.js'; +import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IAgentSession, IAgentSessionsModel } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// The blocked-sessions list reuses the shared session-row styles. +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/sessionsList.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockWorkspace(label: string, branchName: string, pullRequest?: IGitHubInfo['pullRequest']): ISessionWorkspace { + const root = URI.file(`/home/user/projects/${label}`); + const gitHubInfo: IGitHubInfo | undefined = pullRequest ? { owner: 'microsoft', repo: label, pullRequest } : undefined; + + const gitRepository: ISessionGitRepository = { + uri: root, + workTreeUri: undefined, + branchName, + baseBranchName: 'main', + hasGitHubRemote: true, + gitHubInfo: constObservable(gitHubInfo), + }; + + const folder: ISessionFolder = { + root, + workingDirectory: root, + name: label, + description: undefined, + gitRepository, + }; + + return { + uri: root, + label, + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createMockChangesSummary(files: number, additions: number, deletions: number): ISessionChangesSummary { + return { files, additions, deletions }; +} + +interface IBlockedSessionOptions { + title: string; + status: SessionStatus; + /** How long ago the session was last updated, in minutes. */ + minutesAgo: number; + workspace?: ISessionWorkspace; + /** Rendered in the details row for sessions that need input. */ + description?: string; + /** Diff stats shown for completed sessions. */ + changesSummary?: ISessionChangesSummary; + /** A terminal command awaiting approval; renders an approval row with an Allow button. */ + approvalCommand?: string; +} + +function createBlockedSession(options: IBlockedSessionOptions, approvals?: Map): ISession { + const updatedAt = new Date(Date.now() - options.minutesAgo * 60 * 1000); + const description: IMarkdownString | undefined = options.description ? new MarkdownString(options.description) : undefined; + + // A session awaiting a tool approval carries a chat whose resource the (mock) + // approval model keys the pending approval on. + let chats: readonly IChat[] = []; + if (options.approvalCommand !== undefined && approvals) { + const chatResource = URI.parse(`vscode-chat://chat/${Math.random().toString(36).slice(2)}`); + approvals.set(chatResource.toString(), { + kind: AgentSessionApprovalKind.Terminal, + label: options.approvalCommand, + languageId: undefined, + since: new Date(), + confirm: () => { }, + }); + chats = [new class extends mock() { + override readonly resource = chatResource; + }()]; + } + + return new class extends mock() { + override readonly sessionId = `local:${options.title}`; + override readonly resource = URI.parse(`vscode-session://session/${Math.random().toString(36).slice(2)}`); + override readonly providerId = 'local'; + override readonly sessionType = 'local'; + override readonly icon = Codicon.account; + override readonly createdAt = updatedAt; + override readonly title: IObservable = constObservable(options.title); + override readonly updatedAt: IObservable = constObservable(updatedAt); + override readonly status: IObservable = constObservable(options.status); + override readonly workspace: IObservable = constObservable(options.workspace); + override readonly isArchived: IObservable = constObservable(false); + override readonly isRead: IObservable = constObservable(true); + override readonly changes: IObservable = constObservable([]); + override readonly changesSummary: IObservable = constObservable(options.changesSummary); + override readonly description: IObservable = constObservable(description); + override readonly chats: IObservable = constObservable(chats); + }(); +} + +function createApprovalModel(approvals: Map): AgentSessionApprovalModel { + return new class extends mock() { + override getApproval(resource: URI): IObservable { + return constObservable(approvals.get(resource.toString())); + } + }(); +} + +/** + * Build a set of sessions together with a matching approval model: each session + * whose spec has an `approvalCommand` shows a pending terminal approval row. + */ +function buildApprovalScenario(specs: readonly IBlockedSessionOptions[]): { sessions: ISession[]; approvalModel: AgentSessionApprovalModel } { + const approvals = new Map(); + const sessions = specs.map(spec => createBlockedSession(spec, approvals)); + return { sessions, approvalModel: createApprovalModel(approvals) }; +} + +function createMockListModelService(): ISessionsListModelService { + return new class extends mock() { + override readonly onDidChange = Event.None; + override isSessionRead(): boolean { return true; } + override isSessionPinned(): boolean { return false; } + override markRead(): void { } + override getStatusIcon(status: SessionStatus, _isRead: boolean, isArchived: boolean, completedStateIcon?: ThemeIcon): ThemeIcon { + switch (status) { + case SessionStatus.InProgress: + return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') }; + case SessionStatus.NeedsInput: + return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') }; + case SessionStatus.Error: + return { ...Codicon.error, color: themeColorFromId('errorForeground') }; + default: + if (isArchived) { + return { ...Codicon.passFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + if (completedStateIcon) { + return completedStateIcon; + } + return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + } + }(); +} + +// A failing-CI pull request (red) and a pull request with unresolved comments +// (yellow) — the two non-needs-input reasons a session counts as blocked. +const failingChecksPr: IGitHubInfo['pullRequest'] = { + number: 4821, + uri: URI.parse('https://github.com/microsoft/vscode/pull/4821'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.red') }, +}; + +const unresolvedCommentsPr: IGitHubInfo['pullRequest'] = { + number: 4750, + uri: URI.parse('https://github.com/microsoft/vscode/pull/4750'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.yellow') }, +}; + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderBlockedList(ctx: ComponentFixtureContext, sessions: readonly ISession[], approvalModel?: AgentSessionApprovalModel): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + // `SessionsFlatList` creates an `AgentSessionApprovalModel` (reads + // `IChatService.chatModels`) and observes each session through the + // agent-sessions model. Both are stubbed to no-ops for the fixture. + reg.defineInstance(IChatService, new class extends mock() { + override readonly chatModels: IObservable> = constObservable>([]); + }()); + reg.defineInstance(IAgentSessionsService, new class extends mock() { + override readonly model = new class extends mock() { + override observeSession(): IObservable { + return constObservable(undefined); + } + }(); + }()); + reg.defineInstance(ISessionsService, new class extends mock() { + override readonly visibleSessions: IObservable = constObservable([]); + }()); + reg.defineInstance(ISessionsListModelService, createMockListModelService()); + reg.defineInstance(ISessionsProvidersService, new class extends mock() { + override readonly onDidChangeProviders = Event.None; + override getProviders() { return []; } + override getProvider() { return undefined; } + }()); + }, + }); + + // Render terminal-approval labels as real (monospace) code blocks — otherwise + // the markdown renderer emits empty code-block spans and the command is blank. + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('editor', { fontFamily: 'monospace' }); + instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); + + // The blocked-sessions list is shown as a floating dropdown anchored below + // the command center box in the agents window; approximate that surface (and + // its backdrop) here so the widget's own background/border/shadow read as + // they do in production. + container.style.width = '392px'; + container.style.padding = '16px'; + container.style.backgroundColor = 'var(--vscode-titleBar-activeBackground, var(--vscode-editor-background))'; + + const list = disposableStore.add(instantiationService.createInstance(BlockedSessionsList, container, { + onSessionOpen: () => { }, + approvalModel, + })); + list.setSessions(sessions); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + // A mix of the three reasons a session is "blocked": it needs input, its PR + // has failing CI checks, or its PR has unresolved comments. + BlockedSessionsList_Mixed: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ + title: 'Fix authentication redirect loop', + status: SessionStatus.NeedsInput, + minutesAgo: 3, + workspace: createMockWorkspace('vscode', 'feature/auth-fix'), + description: 'Waiting for you to confirm running the database migration.', + }), + createBlockedSession({ + title: 'Add telemetry for startup performance', + status: SessionStatus.Completed, + minutesAgo: 62, + workspace: createMockWorkspace('vscode', 'perf/startup-telemetry', failingChecksPr), + changesSummary: createMockChangesSummary(8, 240, 58), + }), + createBlockedSession({ + title: 'Refactor the notification service', + status: SessionStatus.Completed, + minutesAgo: 184, + workspace: createMockWorkspace('vscode', 'refactor/notifications', unresolvedCommentsPr), + changesSummary: createMockChangesSummary(12, 96, 140), + }), + ]), + }), + + // A single session that needs input — the most common blocked state. + BlockedSessionsList_SingleNeedsInput: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ + title: 'Update the onboarding walkthrough copy', + status: SessionStatus.NeedsInput, + minutesAgo: 1, + workspace: createMockWorkspace('vscode', 'docs/onboarding'), + description: 'Which tone should the welcome step use — formal or friendly?', + }), + ]), + }), + + // Enough sessions to fill the dropdown and show the bounded, scrollable height. + BlockedSessionsList_Many: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ title: 'Fix authentication redirect loop', status: SessionStatus.NeedsInput, minutesAgo: 3, workspace: createMockWorkspace('vscode', 'feature/auth-fix'), description: 'Waiting for you to confirm running the database migration.' }), + createBlockedSession({ title: 'Add telemetry for startup performance', status: SessionStatus.Completed, minutesAgo: 62, workspace: createMockWorkspace('vscode', 'perf/startup-telemetry', failingChecksPr), changesSummary: createMockChangesSummary(8, 240, 58) }), + createBlockedSession({ title: 'Refactor the notification service', status: SessionStatus.Completed, minutesAgo: 184, workspace: createMockWorkspace('vscode', 'refactor/notifications', unresolvedCommentsPr), changesSummary: createMockChangesSummary(12, 96, 140) }), + createBlockedSession({ title: 'Migrate settings sync to the new store', status: SessionStatus.NeedsInput, minutesAgo: 240, workspace: createMockWorkspace('vscode', 'feature/settings-store'), description: 'Should I keep the legacy keys for one more release?' }), + createBlockedSession({ title: 'Investigate flaky terminal integration test', status: SessionStatus.Completed, minutesAgo: 320, workspace: createMockWorkspace('vscode', 'fix/flaky-terminal-test', failingChecksPr), changesSummary: createMockChangesSummary(3, 41, 12) }), + createBlockedSession({ title: 'Polish the command center hover states', status: SessionStatus.Completed, minutesAgo: 600, workspace: createMockWorkspace('vscode', 'polish/command-center', unresolvedCommentsPr), changesSummary: createMockChangesSummary(5, 64, 9) }), + ]), + }), + + // One session with a pending terminal approval — shows the approval row + Allow button. + BlockedSessionsList_OneApproval: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Build the production bundle', status: SessionStatus.NeedsInput, minutesAgo: 1, workspace: createMockWorkspace('vscode', 'release/prod-build'), approvalCommand: 'npm run build:prod' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), + + // Two sessions awaiting approval — a short command and a long single-line command. + BlockedSessionsList_TwoApprovals: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Push the auth fix', status: SessionStatus.NeedsInput, minutesAgo: 2, workspace: createMockWorkspace('vscode', 'feature/auth-fix'), approvalCommand: 'git push --force-with-lease origin feature/auth-fix' }, + { title: 'Publish the release image', status: SessionStatus.NeedsInput, minutesAgo: 6, workspace: createMockWorkspace('vscode', 'release/docker'), approvalCommand: 'docker run --rm -it -v "$(pwd)":/workspace -w /workspace -e NODE_ENV=production -e REGISTRY=ghcr.io/microsoft --network host node:20-alpine npm run build:image -- --push --tag latest --no-cache' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), + + // Five sessions awaiting approval, spanning short, long single-line and + // multi-line terminal commands (the approval row shows up to three lines). + BlockedSessionsList_FiveApprovals: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Install dependencies', status: SessionStatus.NeedsInput, minutesAgo: 1, workspace: createMockWorkspace('vscode', 'chore/deps'), approvalCommand: 'npm ci' }, + { title: 'Rebase onto main', status: SessionStatus.NeedsInput, minutesAgo: 3, workspace: createMockWorkspace('vscode', 'feature/rebase'), approvalCommand: 'git rebase --onto main feature/old-base feature/new-work' }, + { title: 'Provision the review environment', status: SessionStatus.NeedsInput, minutesAgo: 7, workspace: createMockWorkspace('vscode', 'infra/review-env'), approvalCommand: 'kubectl apply -f ./deploy/review.yaml --namespace review-pr-4821 && kubectl rollout status deployment/web --namespace review-pr-4821 --timeout=180s && kubectl get pods --namespace review-pr-4821 -o wide' }, + { title: 'Format changed files', status: SessionStatus.NeedsInput, minutesAgo: 12, workspace: createMockWorkspace('vscode', 'chore/format'), approvalCommand: 'for f in $(git diff --name-only main); do\n npx prettier --write "$f"\n git add "$f"\ndone' }, + { title: 'Reset and reinstall', status: SessionStatus.NeedsInput, minutesAgo: 20, workspace: createMockWorkspace('vscode', 'fix/clean-install'), approvalCommand: 'rm -rf node_modules\nrm -f package-lock.json\nnpm cache clean --force\nnpm install\nnpm run test:integration' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts new file mode 100644 index 00000000000..ef0d3884b09 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts @@ -0,0 +1,527 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { localize2 } from '../../../../../nls.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IViewPaneOptions } from '../../../../browser/parts/views/viewPane.js'; +import { IViewContainerModel, IViewDescriptor, IViewDescriptorService, IViewPaneContainer, ViewContainer, ViewContainerLocation } from '../../../../common/views.js'; +import { isIChatSessionFileChange2 } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { ILifecycleService, LifecyclePhase, StartupKind } from '../../../../services/lifecycle/common/lifecycle.js'; +import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { FixtureMenuService } from '../chat/chatFixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import { ActiveSessionState, IChangesViewService } from '../../../../../sessions/contrib/changes/common/changesViewService.js'; +// eslint-disable-next-line local/code-import-patterns +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewMode, IsolationMode } from '../../../../../sessions/contrib/changes/common/changes.js'; +// eslint-disable-next-line local/code-import-patterns +import { ChangesViewPane } from '../../../../../sessions/contrib/changes/browser/changesView.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionChangesService, SessionChangesService } from '../../../../../sessions/contrib/changes/browser/sessionChangesService.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestCIModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestCIModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../../../../sessions/contrib/github/common/types.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { BRANCH_CHANGES_CHANGESET_ID, IChat, IGitHubInfo, ISessionCapabilities, ISessionChangeset, ISessionChangesetOperation, ISessionFile, ISessionFileChange, ISessionGitRepository, ISessionWorkspace, SessionFileOperation, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; + +interface IChangesViewFixtureOptions { + readonly viewMode: ChangesViewMode; + readonly changes: readonly ISessionFileChange[]; + readonly otherFiles?: readonly ISessionFile[]; + readonly checks?: readonly IGitHubCICheck[]; + readonly reviewCommentCounts?: ReadonlyMap; + readonly agentFeedbackCounts?: ReadonlyMap; + readonly height?: number; +} + +const WORKSPACE_URI = URI.file('/workspace/vscode'); +const VIEW_WIDTH = 380; +const VIEW_HEIGHT = 520; + +class FixtureChangesViewService extends Disposable implements IChangesViewService { + declare readonly _serviceBrand: undefined; + + readonly activeSessionResourceObs: IObservable; + readonly activeSessionTypeObs: IObservable; + readonly activeSessionIsVirtualWorkspaceObs: IObservable; + readonly activeSessionChangesObs: IObservable; + readonly activeSessionChangesetsObs: IObservable; + readonly activeSessionChangesetsLoadingObs: IObservable; + readonly activeSessionChangesetObs: IObservable; + readonly activeSessionChangesetLoadingObs: IObservable; + readonly activeSessionChangesetOperationsObs: IObservable; + readonly activeSessionHasGitRepositoryObs: IObservable; + readonly activeSessionReviewCommentCountByFileObs: IObservable>; + readonly activeSessionAgentFeedbackCountByFileObs: IObservable>; + readonly activeSessionStateObs: IObservable; + readonly activeSessionLoadingObs: IObservable; + readonly viewModeObs = observableValue(this, ChangesViewMode.List); + + constructor(session: IActiveSession, options: IChangesViewFixtureOptions) { + super(); + + const changeset = createChangeset(options.changes); + this.viewModeObs.set(options.viewMode, undefined); + this.activeSessionResourceObs = constObservable(session.resource); + this.activeSessionTypeObs = constObservable(session.sessionType); + this.activeSessionIsVirtualWorkspaceObs = constObservable(false); + this.activeSessionChangesObs = constObservable(options.changes); + this.activeSessionChangesetsObs = constObservable([changeset]); + this.activeSessionChangesetsLoadingObs = constObservable(false); + this.activeSessionChangesetObs = constObservable(changeset); + this.activeSessionChangesetLoadingObs = constObservable(false); + this.activeSessionChangesetOperationsObs = constObservable([]); + this.activeSessionHasGitRepositoryObs = constObservable(true); + this.activeSessionReviewCommentCountByFileObs = constObservable(new Map(options.reviewCommentCounts)); + this.activeSessionAgentFeedbackCountByFileObs = constObservable(new Map(options.agentFeedbackCounts)); + this.activeSessionStateObs = constObservable({ + isolationMode: IsolationMode.Worktree, + hasGitRepository: true, + branchName: 'feature/changes-view-fixtures', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature/changes-view-fixtures', + isMergeBaseBranchProtected: true, + incomingChanges: 0, + outgoingChanges: 2, + uncommittedChanges: 0, + hasBranchChanges: options.changes.length > 0, + hasGitHubRemote: true, + hasPullRequest: (options.checks?.length ?? 0) > 0, + hasOpenPullRequest: (options.checks?.length ?? 0) > 0, + hasGitOperationInProgress: false, + }); + this.activeSessionLoadingObs = constObservable(false); + } + + setChangesetId(_changesetId: string | undefined): void { } + + setViewMode(mode: ChangesViewMode): void { + this.viewModeObs.set(mode, undefined); + } +} + +class FixtureViewPaneContainer extends mock() { } + +const changesViewContainer: ViewContainer = { + id: CHANGES_VIEW_CONTAINER_ID, + title: localize2('fixtureChangesContainer', 'Changes'), + ctorDescriptor: new SyncDescriptor(FixtureViewPaneContainer), +}; + +const changesViewDescriptor: IViewDescriptor = { + id: CHANGES_VIEW_ID, + name: localize2('fixtureChangesView', 'Changes'), + ctorDescriptor: new SyncDescriptor(ChangesViewPane), + containerIcon: Codicon.gitCompare, +}; + +class FixtureViewContainerModel extends mock() { + override readonly viewContainer = changesViewContainer; + override readonly title = 'Changes'; + override readonly icon: ThemeIcon | URI | undefined = Codicon.gitCompare; + override readonly keybindingId = undefined; + override readonly onDidChangeContainerInfo = Event.None; + override readonly allViewDescriptors = [changesViewDescriptor]; + override readonly onDidChangeAllViewDescriptors = Event.None; + override readonly activeViewDescriptors = [changesViewDescriptor]; + override readonly onDidChangeActiveViewDescriptors = Event.None; + override readonly visibleViewDescriptors = [changesViewDescriptor]; + override readonly onDidAddVisibleViewDescriptors = Event.None; + override readonly onDidRemoveVisibleViewDescriptors = Event.None; + override readonly onDidMoveVisibleViewDescriptors = Event.None; + + override isVisible(): boolean { return true; } + override setVisible(): void { } + override isCollapsed(): boolean { return false; } + override setCollapsed(): void { } + override getSize(): number | undefined { return undefined; } + override setSizes(): void { } + override move(): void { } +} + +class FixtureViewDescriptorService extends mock() { + override readonly viewContainers = [changesViewContainer]; + override readonly onDidChangeViewContainers = Event.None; + override readonly onDidChangeContainerLocation = Event.None; + override readonly onDidChangeContainer = Event.None; + override readonly onDidChangeLocation = Event.None; + + private readonly _model = new FixtureViewContainerModel(); + + override getDefaultViewContainer(): ViewContainer | undefined { return changesViewContainer; } + override getViewContainerById(): ViewContainer | null { return changesViewContainer; } + override isViewContainerRemovedPermanently(): boolean { return false; } + override getDefaultViewContainerLocation(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override getViewContainerLocation(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override getViewContainersByLocation(): ViewContainer[] { return [changesViewContainer]; } + override getViewContainerModel(): IViewContainerModel { return this._model; } + override moveViewContainerToLocation(): void { } + override getViewContainerBadgeEnablementState(): boolean { return true; } + override setViewContainerBadgeEnablementState(): void { } + override getViewDescriptorById(): IViewDescriptor | null { return changesViewDescriptor; } + override getViewContainerByViewId(): ViewContainer | null { return changesViewContainer; } + override getDefaultContainerById(): ViewContainer | null { return changesViewContainer; } + override getViewLocationById(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override canMoveViews(): boolean { return false; } + override moveViewsToContainer(): void { } + override moveViewToLocation(): void { } + override reset(): void { } +} + +function createChangeset(changes: readonly ISessionFileChange[]): ISessionChangeset { + return new class extends mock() { + override readonly id = BRANCH_CHANGES_CHANGESET_ID; + override readonly label = 'Branch Changes'; + override readonly isEnabled = constObservable(true); + override readonly isDefault = constObservable(true); + override readonly isLoadingChanges = constObservable(false); + override readonly changes = constObservable(changes); + override readonly operations = constObservable([]); + override readonly originalCheckpointRef = constObservable(undefined); + override readonly modifiedCheckpointRef = constObservable(undefined); + override async invokeOperation(): Promise { } + }(); +} + +function createWorkspace(): ISessionWorkspace { + const gitRepository: ISessionGitRepository = { + uri: WORKSPACE_URI, + workTreeUri: URI.file('/workspace/.worktrees/changes-view-fixtures'), + branchName: 'feature/changes-view-fixtures', + baseBranchName: 'main', + baseBranchProtected: true, + hasGitHubRemote: true, + upstreamBranchName: 'origin/feature/changes-view-fixtures', + outgoingChanges: 2, + uncommittedChanges: 0, + gitHubInfo: constObservable({ + owner: 'microsoft', + repo: 'vscode', + pullRequest: { + number: 293163, + uri: URI.parse('https://github.com/microsoft/vscode/pull/293163'), + icon: Codicon.gitPullRequest, + }, + }), + }; + + return { + uri: WORKSPACE_URI, + label: 'vscode', + icon: Codicon.folder, + folders: [{ + root: WORKSPACE_URI, + workingDirectory: WORKSPACE_URI, + name: 'vscode', + description: undefined, + gitRepository, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createSession(options: IChangesViewFixtureOptions): IActiveSession { + const capabilities: ISessionCapabilities = { + supportsMultipleChats: false, + supportsRename: true, + }; + const changesets = [createChangeset(options.changes)]; + const chat = new class extends mock() { }(); + + return new class extends mock() { + override readonly sessionId = 'fixture:changes-view'; + override readonly resource = URI.parse('fixture-session://changes-view'); + override readonly providerId = 'fixture'; + override readonly sessionType = 'fixture'; + override readonly icon = Codicon.account; + override readonly createdAt = new Date('2026-05-14T12:00:00Z'); + override readonly workspace = constObservable(createWorkspace()); + override readonly title = constObservable('Changes view fixture'); + override readonly updatedAt = constObservable(new Date('2026-05-14T12:30:00Z')); + override readonly status = constObservable(SessionStatus.Completed); + override readonly changes = constObservable(options.changes); + override readonly changesets = constObservable(changesets); + override readonly externalChanges = constObservable(options.otherFiles ?? []); + override readonly modelId = constObservable(undefined); + override readonly mode = constObservable(undefined); + override readonly loading = constObservable(false); + override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); + override readonly description = constObservable(undefined); + override readonly lastTurnEnd = constObservable(undefined); + override readonly chats = constObservable([chat]); + override readonly mainChat = constObservable(chat); + override readonly capabilities = constObservable(capabilities); + override readonly activeChat = constObservable(chat); + override readonly isCreated = constObservable(true); + override readonly sticky = constObservable(false); + override readonly openChats = constObservable([chat]); + override readonly closedChats = constObservable([]); + override readonly lastClosedChat = undefined; + override readonly visibleChatTabs = constObservable([chat]); + override readonly shouldShowChatTabs = constObservable(false); + }(); +} + +function createFileChange(path: string, kind: 'added' | 'modified' | 'deleted', insertions: number, deletions: number): ISessionFileChange { + const uri = URI.file(`/workspace/vscode/${path}`); + return { + uri, + originalUri: kind === 'added' ? undefined : URI.file(`/workspace/vscode/.baseline/${path}`), + modifiedUri: kind === 'deleted' ? undefined : uri, + insertions, + deletions, + }; +} + +function createOtherFile(path: string, operation: SessionFileOperation): ISessionFile { + return { + uri: URI.file(path), + operation, + originalUri: operation === SessionFileOperation.Modified ? URI.file(`${path}.before`) : undefined, + }; +} + +function createCheck(id: number, name: string, status: GitHubCheckStatus, conclusion?: GitHubCheckConclusion): IGitHubCICheck { + return { + id, + name, + status, + conclusion, + startedAt: '2026-05-14T12:00:00Z', + completedAt: status === GitHubCheckStatus.Completed ? '2026-05-14T12:05:00Z' : undefined, + detailsUrl: `https://github.com/microsoft/vscode/actions/runs/${id}`, + }; +} + +function createCIModel(checks: readonly IGitHubCICheck[] | undefined): GitHubPullRequestCIModel | undefined { + if (!checks?.length) { + return undefined; + } + const visibleChecks: readonly IGitHubCICheck[] = checks; + + return new class extends mock() { + override readonly owner = 'microsoft'; + override readonly repo = 'vscode'; + override readonly prNumber = 293163; + override readonly headSha = 'abcdef1234567890'; + override readonly checks = constObservable(visibleChecks); + override readonly overallStatus = constObservable(GitHubCIOverallStatus.Failure); + override readonly fixRequested = constObservable(false); + override markFixRequested(): void { } + override async refresh(): Promise { } + override async rerunFailedCheck(): Promise { } + override async getCheckRunAnnotations(): Promise { return ''; } + override startPolling() { return { dispose() { } }; } + }(); +} + +function createGitHubService(checks: readonly IGitHubCICheck[] | undefined): IGitHubService { + return new class extends mock() { + override readonly activeSessionPullRequestObs = constObservable(undefined); + override readonly activeSessionPullRequestCIObs = constObservable(createCIModel(checks)); + override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); + override createRepositoryModelReference(): ReturnType { throw new Error('Not implemented in fixture.'); } + override createPullRequestModelReference(): ReturnType { throw new Error('Not implemented in fixture.'); } + override createPullRequestReviewThreadsModelReference(): ReturnType { throw new Error('Not implemented in fixture.'); } + override createPullRequestCIModelReference(): ReturnType { throw new Error('Not implemented in fixture.'); } + override async getChangedFiles() { return []; } + override async findPullRequestNumberByHeadBranch() { return undefined; } + }(); +} + +function getChangeUri(change: ISessionFileChange): URI { + return isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; +} + +function renderChangesView(ctx: ComponentFixtureContext, options: IChangesViewFixtureOptions): void { + const { container, disposableStore, theme } = ctx; + const height = options.height ?? VIEW_HEIGHT; + const session = createSession(options); + const changesViewService = disposableStore.add(new FixtureChangesViewService(session, options)); + + container.style.width = `${VIEW_WIDTH}px`; + container.style.height = `${height}px`; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const host = dom.append(container, dom.$('.part.auxiliarybar')); + host.style.width = '100%'; + host.style.height = '100%'; + + const paneView = dom.append(host, dom.$('.monaco-pane-view')); + paneView.style.width = '100%'; + paneView.style.height = '100%'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: reg => { + registerWorkbenchServices(reg); + reg.define(IMenuService, FixtureMenuService); + reg.define(IListService, ListService); + reg.define(ISessionChangesService, SessionChangesService); + reg.defineInstance(IChangesViewService, changesViewService); + reg.defineInstance(IGitHubService, createGitHubService(options.checks)); + reg.defineInstance(IViewDescriptorService, new FixtureViewDescriptorService()); + reg.defineInstance(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(session); + override readonly visibleSessions = constObservable([session]); + override readonly onDidToggleSessionStickiness = Event.None; + }()); + reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: 'fixture', folders: [], configuration: undefined }; } }()); + reg.defineInstance(INotebookDocumentService, new class extends mock() { override getNotebook() { return undefined; } }()); + reg.defineInstance(IFileService, new class extends mock() { + override async readFile(resource: URI): Promise { + return new class extends mock() { + override readonly resource = resource; + override readonly value = VSBuffer.fromString('before'); + }(); + } + }()); + reg.defineInstance(IEditorService, new class extends mock() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + override async openEditor(): Promise { return undefined; } + }()); + reg.defineInstance(IExtensionService, new class extends mock() { override readonly onDidChangeExtensions = Event.None; }()); + reg.defineInstance(ILifecycleService, new class extends mock() { + override readonly startupKind = StartupKind.NewWindow; + override phase = LifecyclePhase.Restored; + override readonly onBeforeShutdown = Event.None; + override readonly onShutdownVeto = Event.None; + override readonly onBeforeShutdownError = Event.None; + override readonly onWillShutdown = Event.None; + override readonly willShutdown = false; + override readonly onDidShutdown = Event.None; + override async when(): Promise { } + override async shutdown(): Promise { } + }()); + }, + }); + + const view = disposableStore.add(instantiationService.createInstance(ChangesViewPane, { + id: CHANGES_VIEW_ID, + title: 'Changes', + minimumBodySize: 0, + maximumBodySize: Number.POSITIVE_INFINITY, + } satisfies IViewPaneOptions)); + + view.render(); + paneView.appendChild(view.element); + view.setVisible(true); + view.orthogonalSize = VIEW_WIDTH; + view.layout(height); +} + +const SAMPLE_CHANGES = [ + createFileChange('src/vs/sessions/contrib/changes/browser/changesView.ts', 'modified', 42, 18), + createFileChange('src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts', 'modified', 24, 9), + createFileChange('src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css', 'modified', 6, 2), + createFileChange('src/vs/sessions/contrib/changes/test/browser/changesView.fixture.ts', 'added', 132, 0), + createFileChange('src/vs/sessions/contrib/changes/browser/oldChangesLayout.ts', 'deleted', 0, 47), +]; + +const SAMPLE_OTHER_FILES = [ + createOtherFile('/home/user/.config/code/settings.json', SessionFileOperation.Modified), + createOtherFile('/home/user/.config/copilot/agents/inbox.agent.md', SessionFileOperation.Created), + createOtherFile('/home/user/.cache/copilot/session.log', SessionFileOperation.Deleted), + createOtherFile('/tmp/session-notes.md', SessionFileOperation.Created), + createOtherFile('/home/user/.gitconfig', SessionFileOperation.Modified), + createOtherFile('/home/user/.ssh/config', SessionFileOperation.Modified), + createOtherFile('/home/user/.local/share/copilot/state.json', SessionFileOperation.Created), + createOtherFile('/home/user/.vscode-insiders/argv.json', SessionFileOperation.Modified), +]; + +const SAMPLE_CHECKS = [ + createCheck(1001, 'Linux / Unit Tests', GitHubCheckStatus.Completed, GitHubCheckConclusion.Success), + createCheck(1002, 'Windows / Unit Tests', GitHubCheckStatus.Completed, GitHubCheckConclusion.Failure), + createCheck(1003, 'macOS / Smoke Tests', GitHubCheckStatus.InProgress), + createCheck(1004, 'Hygiene', GitHubCheckStatus.Queued), + createCheck(1005, 'Compile', GitHubCheckStatus.Completed, GitHubCheckConclusion.Success), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/changes/' }, { + AllSections_List: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: SAMPLE_CHANGES, + otherFiles: SAMPLE_OTHER_FILES, + checks: SAMPLE_CHECKS, + reviewCommentCounts: new Map([[getChangeUri(SAMPLE_CHANGES[0]).fsPath, 2]]), + agentFeedbackCounts: new Map([[getChangeUri(SAMPLE_CHANGES[1]).fsPath, 1]]), + }), + }), + + TreeMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.Tree, + changes: SAMPLE_CHANGES, + otherFiles: SAMPLE_OTHER_FILES.slice(0, 3), + checks: SAMPLE_CHECKS.slice(0, 3), + }), + }), + + FilesAndChecksOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: SAMPLE_CHANGES, + checks: SAMPLE_CHECKS, + height: 440, + }), + }), + + NoFileChangesWithOtherFiles: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: [], + otherFiles: SAMPLE_OTHER_FILES, + checks: SAMPLE_CHECKS.slice(0, 2), + height: 440, + }), + }), + + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: [], + otherFiles: [], + checks: [], + height: 280, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts new file mode 100644 index 00000000000..8a81b997ed6 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { IChat, ISession, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../../../../sessions/contrib/blockedSessions/browser/blockedSessions.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/browser/sessionActionFeedback.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockActiveSession(title: string, workspaceLabel: string): IActiveSession { + const workspace = new class extends mock() { + override readonly label = workspaceLabel; + }(); + return new class extends mock() { + override readonly icon = Codicon.copilot; + override readonly title: IObservable = constObservable(title); + override readonly workspace: IObservable = constObservable(workspace); + override readonly isQuickChat: IObservable = constObservable(false); + }(); +} + +/** A blocked session to synthesize for a fixture. */ +interface IBlockedSpec { + /** Why the session is blocked. */ + readonly reason: BlockedSessionReason; + /** For `NeedsInput`, the kind of pending approval (terminal vs question). */ + readonly approvalKind?: AgentSessionApprovalKind; +} + +/** + * Build mock blocked sessions plus an approval model that reports the requested + * approval kind for each session's chat, so the widget classifies them exactly. + */ +function buildBlocked(specs: readonly IBlockedSpec[]): { blocked: IBlockedSession[]; approvalModel: AgentSessionApprovalModel } { + const approvals = new Map(); + const blocked = specs.map((spec, i): IBlockedSession => { + const chatResource = URI.parse(`session-chat:/blocked/${i}`); + if (spec.approvalKind) { + approvals.set(chatResource.toString(), { + kind: spec.approvalKind, + label: 'npm run build', + languageId: undefined, + since: new Date(), + confirm: () => { }, + }); + } + const chat = new class extends mock() { + override readonly resource = chatResource; + }(); + const session = new class extends mock() { + override readonly sessionId = `blocked-${i}`; + override readonly chats: IObservable = constObservable([chat]); + }(); + return { session, reason: spec.reason }; + }); + const approvalModel = new class extends mock() { + override getApproval(resource: URI): IObservable { + return constObservable(approvals.get(resource.toString())); + } + }(); + return { blocked, approvalModel }; +} + +interface ITitleBarState { + /** The active session shown in the default pill (falls back to "New Session"). */ + activeSession?: IActiveSession; + /** Number of blocked sessions (drives the orange "N sessions require input"). */ + blockedCount?: number; + /** Explicit typed blocked sessions (drives the specific requires-input message). */ + blocked?: readonly IBlockedSpec[]; + /** Whether the primary side bar is visible (requires-input only shows when hidden). */ + sidebarVisible?: boolean; + /** Number of recently approved sessions (drives the green "Approved N sessions"). */ + approvedCount?: number; +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): void { + const { container, disposableStore } = ctx; + + // Blocked sessions: either an explicit typed list, or a plain count of + // unclassified needs-input sessions (which yield the generic message). + const specs: readonly IBlockedSpec[] = state.blocked + ?? Array.from({ length: state.blockedCount ?? 0 }, (): IBlockedSpec => ({ reason: BlockedSessionReason.NeedsInput })); + const { blocked, approvalModel } = buildBlocked(specs); + const sidebarVisible = state.sidebarVisible ?? true; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.defineInstance(ISessionsService, new class extends mock() { + override readonly activeSession: IObservable = constObservable(state.activeSession); + override readonly visibleSessions: IObservable = constObservable([]); + }()); + reg.defineInstance(ISessionsManagementService, new class extends mock() { + override readonly onDidChangeSessions = Event.None; + }()); + reg.defineInstance(ISessionsProvidersService, new class extends mock() { + override readonly onDidChangeProviders = Event.None; + }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { + override readonly onDidChangePartVisibility = Event.None; + override isVisible(part: Parts): boolean { + return part === Parts.SIDEBAR_PART ? sidebarVisible : true; + } + }()); + }, + }); + + // The widget's pill styles are scoped under `.command-center`, so recreate + // that ancestor. The command center box sizes itself relative to the + // viewport, so give the host a representative width. + container.classList.add('agent-sessions-workbench'); + container.style.width = '460px'; + const commandCenter = append(container, $('.command-center')); + const widgetHost = append(commandCenter, $('div')); + + const action = new class extends mock() { + override readonly id = 'workbench.agentSessions.titlebar'; + override readonly label = 'Agent Sessions'; + override readonly tooltip = ''; + override readonly enabled = true; + override async run() { } + }(); + + const sessionActionFeedback = new class extends mock() { + override readonly approvedCount: IObservable = constObservable(state.approvedCount ?? 0); + override notifyApproved(): void { } + }(); + + const blockedSessionsModel = new class extends mock() { + override readonly blockedSessions: IObservable = constObservable(blocked.map(entry => entry.session)); + override readonly blockedSessionsWithReasons: IObservable = constObservable(blocked); + }(); + + const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined, sessionActionFeedback, approvalModel, blockedSessionsModel)); + widget.render(widgetHost); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + // Default: shows the active session pill (icon + title + workspace). + SessionsTitleBar_ActiveSession: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + }), + }), + + // Requires-input: generic orange state (a mix, or unclassified needs-input). + SessionsTitleBar_RequiresInput: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blockedCount: 3, + sidebarVisible: false, + }), + }), + + // Requires-input (terminal): all blocked sessions are waiting on a terminal command. + SessionsTitleBar_RequiresInputTerminal: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + ], + sidebarVisible: false, + }), + }), + + // Requires-input (question): all blocked sessions are asking a question. + SessionsTitleBar_RequiresInputQuestion: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Question }, + ], + sidebarVisible: false, + }), + }), + + // Requires-input (failing CI): all blocked sessions have failing CI checks. + SessionsTitleBar_RequiresInputFailingCI: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.FailingCI }, + { reason: BlockedSessionReason.FailingCI }, + ], + sidebarVisible: false, + }), + }), + + // Requires-input (mixed): a mix of reasons falls back to the generic message. + SessionsTitleBar_RequiresInputMixed: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + { reason: BlockedSessionReason.FailingCI }, + ], + sidebarVisible: false, + }), + }), + + // Approved (one): transient green confirmation after approving a session action. + SessionsTitleBar_ApprovedOne: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + approvedCount: 1, + }), + }), + + // Approved (many): green confirmation after approving several sessions in a row. + // Takes precedence over the orange requires-input state while visible. + SessionsTitleBar_ApprovedMany: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blockedCount: 3, + sidebarVisible: false, + approvedCount: 3, + }), + }), +}); diff --git a/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts b/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts new file mode 100644 index 00000000000..b1ce5ec76a9 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * A comment anchored to a range of a resource, sourced from the workbench's + * agent/session comment store (the same store the built-in code editor renders + * its session comments from). + */ + export interface AgentEditorComment { + /** Stable identity of the comment within its provider. */ + readonly id: string; + /** The range the comment is anchored to. */ + readonly range: Range; + /** The comment text. */ + readonly body: string; + /** Display name of the author, if any. */ + readonly author?: string; + } + + /** + * A live, disposable view of the agent/session comments for a resource that is + * not necessarily shown in a {@link TextEditor} (for example a document rendered + * by a custom editor). + * + * The comments are backed by the same workbench store the code editor uses, so + * comments added here are visible in the code editor and vice versa. + */ + export interface AgentEditorCommentsProvider extends Disposable { + /** + * The current comments for the resource. Empty when the resource has no + * comments or is not in scope for any session. + */ + readonly comments: readonly AgentEditorComment[]; + + /** + * An event that fires whenever {@link comments} changes. + */ + readonly onDidChange: Event; + + /** + * Add a comment for the resource at the given range. No-op when the resource + * is not in scope for a session. + * + * @param range The range to anchor the comment to. + * @param body The comment text. + */ + // eslint-disable-next-line local/vscode-dts-provider-naming + addComment(range: Range, body: string): void; + } + + export namespace window { + /** + * Observe (and contribute to) the agent/session comments for a resource. + * + * The resource only needs to be backed by an open {@link TextDocument}; it does + * not need to be shown in a {@link TextEditor}. This is useful for custom + * editors, which render their document in a webview rather than a text editor. + * + * The returned provider must be disposed once it is no longer needed. + * + * @param uri The resource to observe. + * @returns A live, disposable view of the resource's comments. + */ + export function createAgentEditorComments(uri: Uri): AgentEditorCommentsProvider; + } + +} diff --git a/test/componentFixtures/component-explorer.json b/test/componentFixtures/component-explorer.json index d3d574d0def..61cfa846a37 100644 --- a/test/componentFixtures/component-explorer.json +++ b/test/componentFixtures/component-explorer.json @@ -20,7 +20,7 @@ "cmd": "npm run serve-out-rspack", "cwd": "../../", "wait": { - "stderr": "Loopback: http://localhost:(?\\d+)/," + "stdout": "http://localhost:(?\\d+)/" }, "url": "http://localhost:${var:port}" }