From 8ac1707cfe167f45cec7a0b533b02a961cc4fb77 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 18:52:04 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20feat:=20keyboard=20shortcuts?= =?UTF-8?q?=20for=20the=20background=20processes=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/config/keybinds.mdx | 12 ++ .../BackgroundProcessesBanner.stories.tsx | 35 ++++ .../BackgroundProcessesBanner.tsx | 164 +++++++++++++++++- .../ChatPane/ChatInputDecoration.tsx | 13 +- .../Settings/Sections/KeybindsSection.tsx | 15 ++ src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.test.ts | 31 ++++ src/browser/utils/commands/sources.ts | 21 +++ src/browser/utils/ui/keybinds.ts | 15 ++ src/common/constants/events.ts | 12 ++ .../builtInSkillContent.generated.ts | 12 ++ vscode/src/webview/App.test.tsx | 94 ++++++++++ 12 files changed, 419 insertions(+), 6 deletions(-) diff --git a/docs/config/keybinds.mdx b/docs/config/keybinds.mdx index 7c8c5f13d4a..f9b89327199 100644 --- a/docs/config/keybinds.mdx +++ b/docs/config/keybinds.mdx @@ -40,6 +40,18 @@ When documentation shows `Ctrl`, it means: | Increase thinking level | `Ctrl+Shift+]` | | Decrease thinking level | `Ctrl+Shift+[` | +## Background Processes + +These work in the desktop app and in the VS Code extension. After `Ctrl+Shift+J`, the other shortcuts apply while a row of the background processes strip has focus. + +| Action | Shortcut | +| -------------------------------------- | -------------- | +| Focus the strip (again to collapse it) | `Ctrl+Shift+J` | +| Next / previous process | `↓` / `↑` | +| View output | `Enter` | +| Terminate process | `Backspace` | +| Collapse the strip and return focus | `Esc` | + ## Workspaces | Action | Shortcut | diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx index e90d33ef415..880641389eb 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx @@ -1,3 +1,4 @@ +import { waitFor, within } from "@storybook/test"; import type { AppStory } from "@/browser/stories/meta.js"; import { appMeta, AppWithMocks } from "@/browser/stories/meta.js"; import { setupSimpleChatStory } from "@/browser/stories/helpers/chatSetup"; @@ -219,3 +220,37 @@ export const MonitorLostWakePendingAfterRestart: AppStory = { }, }, }; + +/** The focus shortcut expands the strip and focuses its first row (#5197). */ +export const KeyboardFocusedRow: AppStory = { + render: BackgroundProcesses.render, + play: async ({ canvasElement }) => { + await within(canvasElement).findByRole("button", { name: /3 background bashes/ }); + const composer = canvasElement.querySelector("textarea"); + if (!composer) throw new Error("composer textarea not found"); + composer.focus(); + composer.dispatchEvent( + new KeyboardEvent("keydown", { key: "J", ctrlKey: true, shiftKey: true, bubbles: true }) + ); + await waitFor(() => { + if (!(document.activeElement instanceof HTMLElement)) throw new Error("nothing focused"); + if (!document.activeElement.hasAttribute("data-process-row")) { + throw new Error("the first background process row is not focused yet"); + } + }); + }, + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + pixel: { + matrix: { viewports: ["phone", "desktop"] }, + }, + docs: { + description: { + story: + "Ctrl/Cmd+Shift+J expands the strip and focuses the first row. Arrow keys move between rows, Enter opens the output, Backspace terminates, and Esc collapses the strip and returns focus.", + }, + }, + }, +}; diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx index 9bb1c7b5bb1..4f063ef7464 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { Terminal, X, Loader2, FileText } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "../Tooltip/Tooltip"; import { cn } from "@/common/lib/utils"; @@ -11,6 +11,27 @@ import { } from "@/browser/stores/BackgroundBashStore"; import { useBackgroundBashActions } from "@/browser/contexts/BackgroundBashContext"; import { useChatHostContext } from "@/browser/contexts/ChatHostContext"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; +import { KEYBINDS, formatKeybind, isDialogOpen, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; + +// Shortcut hints are keyboard-only affordances: hidden on mobile widths. +const SHORTCUT_HINT_CLASS = + "ml-1.5 font-mono text-[10px] opacity-70 [@media(max-width:768px)]:hidden"; + +/** Focuses the expanded strip's row at `index`, clamped to the rows that exist. */ +function focusProcessRow(list: HTMLElement | null, index: number): void { + const rows = list?.querySelectorAll("[data-process-row]"); + if (!rows || rows.length === 0) return; + rows[Math.max(0, Math.min(index, rows.length - 1))].focus(); +} + +/** Returns focus to where it was before the strip took it (usually the composer). */ +function restoreFocus(ref: React.MutableRefObject): void { + const target = ref.current; + ref.current = null; + if (target?.isConnected) target.focus(); +} /** * Truncate script to reasonable display length. @@ -41,6 +62,16 @@ export const BackgroundProcessesBanner: React.FC const { terminate } = useBackgroundBashActions(); // Hosts without the output dialog hide the View output action. const canViewOutput = useChatHostContext().uiSupport.backgroundBashOutput === "supported"; + // Keyboard access (#5197): a roving tabindex over the rows, opened by FOCUS_BACKGROUND_PROCESSES. + const listRef = useRef(null); + const returnFocusRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + // Bumped to focus the first row once the expanded list has rendered. + const [focusRequest, setFocusRequest] = useState(0); + // A keyboard terminate removes the focused row when the process exits; focus a neighbor then. + const refocusAfterRemovalRef = useRef<{ processId: string; index: number } | null>(null); + // The output dialog has no trigger to return focus to, so a keyboard open records its row. + const refocusAfterOutputRef = useRef(null); // Keep running processes visible, plus exited processes whose monitor matched but whose // wake has not been delivered yet — otherwise a one-shot watcher that matched and exited @@ -59,6 +90,114 @@ export const BackgroundProcessesBanner: React.FC return () => clearInterval(interval); }, [isExpanded, hasRunning]); + // Listens only while there is something to focus, so the chord is not consumed otherwise. + useEffect(() => { + if (count === 0) return; + const open = () => { + const active = document.activeElement; + if ( + active instanceof HTMLElement && + active !== document.body && + !listRef.current?.contains(active) + ) { + returnFocusRef.current = active; + } + setActiveIndex(0); + setIsExpanded(true); + setFocusRequest((request) => request + 1); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return; + event.preventDefault(); + if (event.repeat) return; + if (listRef.current?.contains(document.activeElement)) { + setIsExpanded(false); + restoreFocus(returnFocusRef); + } else { + open(); + } + }; + const onFocusRequest = (event: Event) => { + const { detail } = event as CustomEvent< + CustomEventPayloads[typeof CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES] + >; + if (detail.workspaceId !== props.workspaceId) return; + detail.handled = true; + open(); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, onFocusRequest); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, onFocusRequest); + }; + }, [count, props.workspaceId]); + + useEffect(() => { + if (focusRequest > 0) focusProcessRow(listRef.current, 0); + }, [focusRequest]); + + const rowIdsKey = visibleProcesses.map((proc) => proc.id).join("\u0000"); + useEffect(() => { + const removed = refocusAfterRemovalRef.current; + if (removed == null || rowIdsKey.split("\u0000").includes(removed.processId)) return; + refocusAfterRemovalRef.current = null; + // Only when the removal dropped focus, not when the user moved it elsewhere meanwhile. + if (document.activeElement != null && document.activeElement !== document.body) return; + if (listRef.current) { + focusProcessRow(listRef.current, removed.index); + } else { + restoreFocus(returnFocusRef); + } + }, [rowIdsKey]); + + useEffect(() => { + if (viewingProcessId != null) return; + const row = refocusAfterOutputRef.current; + refocusAfterOutputRef.current = null; + if (row?.isConnected) row.focus(); + }, [viewingProcessId]); + + const handleListKeyDown = (event: React.KeyboardEvent) => { + const row = + event.target instanceof HTMLElement + ? event.target.closest("[data-process-row]") + : null; + const index = Number(row?.dataset.processIndex ?? -1); + const proc = visibleProcesses[index]; + if (!row || !proc) return; + if (matchesKeybind(event, KEYBINDS.CANCEL)) { + setIsExpanded(false); + restoreFocus(returnFocusRef); + } else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_NEXT)) { + focusProcessRow(listRef.current, index + 1); + } else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_PREV)) { + focusProcessRow(listRef.current, index - 1); + } else if ( + // Enter on a row button keeps its own meaning. + event.target === row && + matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_VIEW_OUTPUT) && + proc.synthesized !== true && + canViewOutput && + !terminatingIds.has(proc.id) + ) { + refocusAfterOutputRef.current = row; + setViewingProcessId(proc.id); + } else if ( + matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_TERMINATE) && + proc.status === "running" && + !terminatingIds.has(proc.id) + ) { + refocusAfterRemovalRef.current = { processId: proc.id, index }; + terminate(proc.id); + } else { + return; + } + event.preventDefault(); + // Keeps window-level handlers (e.g. Escape interrupting the stream) from also acting. + stopKeyboardPropagation(event); + }; + const handleViewOutput = useCallback((processId: string, event: React.MouseEvent) => { event.stopPropagation(); setViewingProcessId(processId); @@ -88,6 +227,8 @@ export const BackgroundProcessesBanner: React.FC expanded={isExpanded} onToggle={handleToggle} contentClassName="max-h-48 space-y-1.5 overflow-y-auto py-2" + contentRef={listRef} + onContentKeyDown={handleListKeyDown} summary={ <> @@ -99,13 +240,18 @@ export const BackgroundProcessesBanner: React.FC } renderExpanded={() => - visibleProcesses.map((proc) => { + visibleProcesses.map((proc, index) => { const isTerminating = terminatingIds.has(proc.id); return (
setActiveIndex(index)} className={cn( "hover:bg-hover flex items-center justify-between gap-3 rounded px-2 py-1.5", + "focus-visible:ring-accent outline-none focus-visible:ring-1", "transition-colors", isTerminating && "pointer-events-none opacity-50" )} @@ -170,7 +316,12 @@ export const BackgroundProcessesBanner: React.FC - View output + + View output + + {formatKeybind(KEYBINDS.BACKGROUND_PROCESS_VIEW_OUTPUT)} + + )} {/* Nothing to terminate once the process has exited */} @@ -193,7 +344,12 @@ export const BackgroundProcessesBanner: React.FC )} - Terminate process + + Terminate process + + {formatKeybind(KEYBINDS.BACKGROUND_PROCESS_TERMINATE)} + + )}
diff --git a/src/browser/components/ChatPane/ChatInputDecoration.tsx b/src/browser/components/ChatPane/ChatInputDecoration.tsx index ae872e49fbb..90601af8e1f 100644 --- a/src/browser/components/ChatPane/ChatInputDecoration.tsx +++ b/src/browser/components/ChatPane/ChatInputDecoration.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { KeyboardEventHandler, ReactNode, Ref } from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout"; @@ -20,6 +20,9 @@ interface ChatInputDecorationProps { * whose `onToggle` navigates elsewhere instead of expanding inline. */ trailingIcon?: ReactNode; + /** Ref and key handler for the expanded content, for decorations with keyboard navigation. */ + contentRef?: Ref; + onContentKeyDown?: KeyboardEventHandler; } // Keep collapsible decorations aligned with the chat input gutter so swapping @@ -58,7 +61,13 @@ export function ChatInputDecoration(props: ChatInputDecorationProps) { {props.expanded && props.renderExpanded && ( -
{props.renderExpanded()}
+
+ {props.renderExpanded()} +
)} ); diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 31d79320a6f..4d8d350d9d1 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -11,6 +11,11 @@ const KEYBIND_LABELS: Record = { SEND_QUEUED_MESSAGE_NOW: "Send queued message now", SEND_HELD_INPUT: "Send oldest unsent message", DISCARD_HELD_INPUT: "Discard oldest unsent message", + FOCUS_BACKGROUND_PROCESSES: "Focus background processes", + BACKGROUND_PROCESS_NEXT: "Next background process", + BACKGROUND_PROCESS_PREV: "Previous background process", + BACKGROUND_PROCESS_VIEW_OUTPUT: "View background process output", + BACKGROUND_PROCESS_TERMINATE: "Terminate background process", SEND_MESSAGE_AFTER_TURN: "Send after turn", NEW_LINE: "Insert newline", CANCEL: "Cancel / Close modal", @@ -195,6 +200,16 @@ const KEYBIND_GROUPS: Array<{ "REVEAL_LAST_PROMPT", ], }, + { + label: "Background processes", + keys: [ + "FOCUS_BACKGROUND_PROCESSES", + "BACKGROUND_PROCESS_NEXT", + "BACKGROUND_PROCESS_PREV", + "BACKGROUND_PROCESS_VIEW_OUTPUT", + "BACKGROUND_PROCESS_TERMINATE", + ], + }, { label: "Editing", keys: ["SAVE_EDIT", "CANCEL_EDIT"], diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index c43b708d01e..47b01fce069 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -72,6 +72,7 @@ export const CommandIds = { chatJumpBottom: () => "chat:jumpBottom" as const, chatVoiceInput: () => "chat:voiceInput" as const, chatRunLatestPlanAction: () => "chat:runLatestPlanAction" as const, + chatFocusBackgroundProcesses: () => "chat:focusBackgroundProcesses" as const, chatClearTimingStats: () => "chat:clearTimingStats" as const, // Mode commands diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 7c7cefc32e3..7c0620e06b3 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -1925,6 +1925,37 @@ describe("Implement Latest Plan palette action (#4963)", () => { }); }); +describe("Focus Background Processes palette action (#5197)", () => { + test("asks the selected workspace's strip to take focus, and says so when there is none", async () => { + await withTestWindow(async () => { + const action = getActions().find( + (candidate) => candidate.id === CommandIds.chatFocusBackgroundProcesses() + ); + expect(action).toBeDefined(); + const events = collectCommandEvents(); + try { + // No strip handled the request (no running processes). + await action!.run(); + expect(events.receivedToasts.map((toast) => toast.type)).toEqual(["error"]); + + const requests: string[] = []; + const strip = (event: Event) => { + const { detail } = event as CustomEvent<{ workspaceId: string; handled: boolean }>; + requests.push(detail.workspaceId); + detail.handled = true; + }; + window.addEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, strip); + await action!.run(); + window.removeEventListener(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, strip); + expect(requests).toEqual(["w1"]); + expect(events.receivedToasts).toHaveLength(1); + } finally { + events.dispose(); + } + }); + }); +}); + test("artifact palette commands follow the experiment and pin the typed path", async () => { expect(getActions().some((action) => action.id === CommandIds.navOpenFileAsArtifact())).toBe( false diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 6daf73eeaf9..ae2b805d3e2 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -271,6 +271,8 @@ let sessionTapesSaveRunning = false; */ const SESSION_TAPES_PATH_TOAST_MS = 15_000; +const NO_BACKGROUND_PROCESSES_MESSAGE = "No background processes are running in this workspace."; + const NO_RUNNABLE_PLAN_MESSAGE = "No plan to implement: the latest plan's Implement / Continue in Auto is missing or disabled."; @@ -1417,6 +1419,25 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } }, }); + list.push({ + id: CommandIds.chatFocusBackgroundProcesses(), + title: "Focus Background Processes", + subtitle: "Arrows select, Enter shows output, Backspace terminates", + section: section.chat, + keywords: ["background", "bash", "process", "terminate", "output"], + shortcutHint: formatKeybind(KEYBINDS.FOCUS_BACKGROUND_PROCESSES), + run: () => { + // The strip takes focus and marks the request handled; listeners run synchronously. + const request = createCustomEvent(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, { + workspaceId: id, + handled: false, + }); + window.dispatchEvent(request); + if (!request.detail.handled) { + showCommandFeedbackToast({ type: "error", message: NO_BACKGROUND_PROCESSES_MESSAGE }); + } + }, + }); list.push({ id: CommandIds.chatClearTimingStats(), title: "Clear Timing Stats", diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index ff284a5d83b..2a6008ca127 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -460,6 +460,21 @@ export const KEYBINDS = { // macOS: Cmd+Shift+F, Win/Linux: Ctrl+Shift+F TOGGLE_FAST_MODE: { key: "F", ctrl: true, shift: true }, + /** + * Expand the background processes strip and focus its first row; again (or Esc) collapses it and + * restores focus. Works from the composer. The VS Code webview forwards every keydown to the + * workbench, so this avoids VS Code's global chords (Ctrl+Shift+B runs the build task). + */ + // macOS: Cmd+Shift+J, Win/Linux: Ctrl+Shift+J + FOCUS_BACKGROUND_PROCESSES: { key: "J", ctrl: true, shift: true }, + + // Scoped to a focused row of the background processes strip. + BACKGROUND_PROCESS_NEXT: { key: "ArrowDown" }, + BACKGROUND_PROCESS_PREV: { key: "ArrowUp" }, + BACKGROUND_PROCESS_VIEW_OUTPUT: { key: "Enter" }, + // Backspace, not Delete: macOS keyboards' delete key reports Backspace. + BACKGROUND_PROCESS_TERMINATE: { key: "Backspace" }, + /** Focus chat input from anywhere */ // Works even when focus is already in an input field // macOS: Cmd+I, Win/Linux: Ctrl+I diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index d7004bc5dfa..c1780b0b6cf 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -42,6 +42,13 @@ export const CUSTOM_EVENTS = { */ RUN_LATEST_PLAN_ACTION: "mux:runLatestPlanAction", + /** + * Event asking the workspace's background processes strip to expand and take focus (command + * palette). The strip sets `handled` when it has processes to show. + * Detail: { workspaceId: string; handled: boolean } + */ + FOCUS_BACKGROUND_PROCESSES: "mux:focusBackgroundProcesses", + /** * Event to open the model selector * No detail @@ -198,6 +205,11 @@ export interface CustomEventPayloads { /** Set synchronously by the plan card that started the action. */ handled: boolean; }; + [CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES]: { + workspaceId: string; + /** Set synchronously by the strip when it took focus. */ + handled: boolean; + }; [CUSTOM_EVENTS.OPEN_AGENT_PICKER]: never; // No payload [CUSTOM_EVENTS.CLOSE_AGENT_PICKER]: never; // No payload [CUSTOM_EVENTS.AGENTS_REFRESH_REQUESTED]: never; // No payload diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index bbb70d49dbd..d8c9988ff1d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4264,6 +4264,18 @@ export const BUILTIN_SKILL_FILES: Record> = { "| Increase thinking level | `Ctrl+Shift+]` |", "| Decrease thinking level | `Ctrl+Shift+[` |", "", + "## Background Processes", + "", + "These work in the desktop app and in the VS Code extension. After `Ctrl+Shift+J`, the other shortcuts apply while a row of the background processes strip has focus.", + "", + "| Action | Shortcut |", + "| -------------------------------------- | -------------- |", + "| Focus the strip (again to collapse it) | `Ctrl+Shift+J` |", + "| Next / previous process | `↓` / `↑` |", + "| View output | `Enter` |", + "| Terminate process | `Backspace` |", + "| Collapse the strip and return focus | `Esc` |", + "", "## Workspaces", "", "| Action | Shortcut |", diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index 8f5217a482a..aaff92c300a 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -2523,6 +2523,100 @@ describe("vscode webview background processes strip (#5092)", () => { expect(bridge.orpcCalls("workspace.backgroundBashes.subscribe")).toHaveLength(1); }); + // The strip's keyboard shortcuts live in the shared banner, so desktop gets the same ones (#5197). + const press = async (target: Element, init: KeyboardEventInit): Promise => { + let notPrevented = true; + await act(async () => { + notPrevented = fireEvent.keyDown(target, init); + await Promise.resolve(); + }); + return notPrevented; + }; + const FOCUS_STRIP = { key: "J", ctrlKey: true, shiftKey: true }; + const stripRows = (view: ReturnType) => + Array.from(view.container.querySelectorAll("[data-process-row]")); + const focusComposerWithProcesses = async (workspace: UiWorkspace) => { + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge, [], workspace); + await emitBackgroundBashes(bridge, workspace.id, [ + runningProcess, + { ...runningProcess, id: "bash-2", pid: 4343, script: "sleep 700" }, + ]); + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("the composer is not rendered"); + textarea.focus(); + return { bridge, view, textarea }; + }; + + test("the shortcut focuses the strip; arrows move, Backspace terminates, Escape returns focus (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys", workspaceName: "bash-keys" }; + const { bridge, view, textarea } = await focusComposerWithProcesses(workspace); + expect(stripRows(view)).toHaveLength(0); + + expect(await press(textarea, FOCUS_STRIP)).toBe(false); + const rows = stripRows(view); + expect(rows).toHaveLength(2); + expect(document.activeElement).toBe(rows[0]); + + await press(rows[0], { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows[1]); + await press(rows[1], { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows[1]); + await press(rows[1], { key: "ArrowUp" }); + expect(document.activeElement).toBe(rows[0]); + await press(rows[0], { key: "ArrowDown" }); + await press(rows[1], { key: "Backspace" }); + await settle(); + expect( + bridge.orpcCalls("workspace.backgroundBashes.terminate").map((call) => call.input) + ).toEqual([{ workspaceId: workspace.id, processId: "bash-2" }]); + + // Once the terminated process leaves the list, focus moves to the remaining row. + await emitBackgroundBashes(bridge, workspace.id, [runningProcess]); + const remaining = stripRows(view); + expect(remaining).toHaveLength(1); + expect(document.activeElement).toBe(remaining[0]); + + await press(remaining[0], { key: "Escape" }); + expect(stripRows(view)).toHaveLength(0); + expect(document.activeElement).toBe(textarea); + }); + + test("Enter on a focused row opens its output (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-output", workspaceName: "bash-keys-output" }; + const { bridge, view, textarea } = await focusComposerWithProcesses(workspace); + await press(textarea, FOCUS_STRIP); + await press(stripRows(view)[0], { key: "Enter" }); + await settle(); + expect(getOutputCalls(bridge).map((call) => call.input)).toEqual([ + { workspaceId: workspace.id, processId: "bash-1", tailBytes: 64_000 }, + ]); + + // The dialog has no trigger to return focus to; closing it refocuses the row. + const close = Array.from( + document.body.querySelectorAll('[role="dialog"] button') + ).find((button) => button.textContent === "Close"); + if (!close) throw new Error("the output dialog has no close button"); + await click(close); + await settle(); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(stripRows(view)[0]); + }); + + test("the shortcut is not consumed when there are no processes (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-idle", workspaceName: "bash-keys-idle" }; + const bridge = new TestBridge(); + const view = render(); + await selectWorkspace(bridge, [], workspace); + const textarea = view.container.querySelector("textarea"); + if (!textarea) throw new Error("the composer is not rendered"); + textarea.focus(); + // Not prevented, so the key keeps its default meaning for the host. + expect(await press(textarea, FOCUS_STRIP)).toBe(true); + expect(stripRows(view)).toHaveLength(0); + }); + const getOutputCalls = (bridge: TestBridge) => bridge.orpcCalls("workspace.backgroundBashes.getOutput"); const openStripOutput = async ( From 2769b8195038bb9d7013fddf803f1a743eba2f18 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 19:18:42 +0000 Subject: [PATCH 2/7] Keep the keyboard story to one Pixel snapshot --- .../BackgroundProcessesBanner.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx index 880641389eb..3bc98d69fe7 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx @@ -239,12 +239,13 @@ export const KeyboardFocusedRow: AppStory = { } }); }, + // One snapshot (the Pixel budget is tight): phone, where the shortcut hints are hidden. globals: { viewport: { value: "mobile1", isRotated: false }, }, parameters: { pixel: { - matrix: { viewports: ["phone", "desktop"] }, + matrix: { viewports: ["phone"] }, }, docs: { description: { From d56b9d80e0d2f71826f229cb6691d0544dade1fd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 19:28:00 +0000 Subject: [PATCH 3/7] Track the focused row for any removal, fall back to the strip toggle, ignore the chord in the palette --- .../BackgroundProcessesBanner.tsx | 52 +++++++++++++++---- vscode/src/webview/App.test.tsx | 52 +++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx index 4f063ef7464..d36f2dd2213 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx @@ -26,11 +26,26 @@ function focusProcessRow(list: HTMLElement | null, index: number): void { rows[Math.max(0, Math.min(index, rows.length - 1))].focus(); } -/** Returns focus to where it was before the strip took it (usually the composer). */ -function restoreFocus(ref: React.MutableRefObject): void { +/** + * Returns focus to where it was before the strip took it (usually the composer). Without one (the + * palette closes before its command runs, leaving focus on the body), focus `fallback` instead. + */ +function restoreFocus( + ref: React.MutableRefObject, + fallback?: Element | null +): void { const target = ref.current; ref.current = null; - if (target?.isConnected) target.focus(); + if (target?.isConnected) { + target.focus(); + } else if (fallback instanceof HTMLElement) { + fallback.focus(); + } +} + +/** The strip's expand/collapse button, which ChatInputDecoration renders just before the list. */ +function stripToggle(list: HTMLElement | null): Element | null { + return list?.previousElementSibling ?? null; } /** @@ -68,8 +83,9 @@ export const BackgroundProcessesBanner: React.FC const [activeIndex, setActiveIndex] = useState(0); // Bumped to focus the first row once the expanded list has rendered. const [focusRequest, setFocusRequest] = useState(0); - // A keyboard terminate removes the focused row when the process exits; focus a neighbor then. - const refocusAfterRemovalRef = useRef<{ processId: string; index: number } | null>(null); + // The row that has focus. When its process leaves the list (terminated or exited), focus + // would drop to the body, so a neighbor takes it instead. + const focusedRowRef = useRef<{ processId: string; index: number } | null>(null); // The output dialog has no trigger to return focus to, so a keyboard open records its row. const refocusAfterOutputRef = useRef(null); @@ -108,11 +124,15 @@ export const BackgroundProcessesBanner: React.FC }; const onKeyDown = (event: KeyboardEvent) => { if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return; + // The command palette (cmdk) is an overlay that isDialogOpen() does not see; taking focus + // behind it would send the next keystrokes to hidden rows. + if (event.target instanceof Element && event.target.closest("[cmdk-root]")) return; event.preventDefault(); if (event.repeat) return; if (listRef.current?.contains(document.activeElement)) { + const toggle = stripToggle(listRef.current); setIsExpanded(false); - restoreFocus(returnFocusRef); + restoreFocus(returnFocusRef, toggle); } else { open(); } @@ -139,9 +159,9 @@ export const BackgroundProcessesBanner: React.FC const rowIdsKey = visibleProcesses.map((proc) => proc.id).join("\u0000"); useEffect(() => { - const removed = refocusAfterRemovalRef.current; + const removed = focusedRowRef.current; if (removed == null || rowIdsKey.split("\u0000").includes(removed.processId)) return; - refocusAfterRemovalRef.current = null; + focusedRowRef.current = null; // Only when the removal dropped focus, not when the user moved it elsewhere meanwhile. if (document.activeElement != null && document.activeElement !== document.body) return; if (listRef.current) { @@ -167,8 +187,9 @@ export const BackgroundProcessesBanner: React.FC const proc = visibleProcesses[index]; if (!row || !proc) return; if (matchesKeybind(event, KEYBINDS.CANCEL)) { + const toggle = stripToggle(listRef.current); setIsExpanded(false); - restoreFocus(returnFocusRef); + restoreFocus(returnFocusRef, toggle); } else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_NEXT)) { focusProcessRow(listRef.current, index + 1); } else if (matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_PREV)) { @@ -188,7 +209,6 @@ export const BackgroundProcessesBanner: React.FC proc.status === "running" && !terminatingIds.has(proc.id) ) { - refocusAfterRemovalRef.current = { processId: proc.id, index }; terminate(proc.id); } else { return; @@ -248,7 +268,17 @@ export const BackgroundProcessesBanner: React.FC data-process-row="" data-process-index={index} tabIndex={index === Math.min(activeIndex, count - 1) ? 0 : -1} - onFocus={() => setActiveIndex(index)} + onFocus={() => { + setActiveIndex(index); + focusedRowRef.current = { processId: proc.id, index }; + }} + onBlur={(event) => { + // A row that is still mounted lost focus to something else. A removed row + // keeps the record so the effect above can move focus to a neighbor. + if (event.relatedTarget != null || event.currentTarget.isConnected) { + focusedRowRef.current = null; + } + }} className={cn( "hover:bg-hover flex items-center justify-between gap-3 rounded px-2 py-1.5", "focus-visible:ring-accent outline-none focus-visible:ring-1", diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index aaff92c300a..44744367f58 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -19,6 +19,7 @@ import { getProvidersConfigStore } from "xum/browser/stores/ProvidersConfigStore import { CHAT_VIEW_DATA_READY_TIMEOUT_MS } from "xum/browser/components/ChatPane/useChatViewDataReady"; import { createMuxMessage, type MuxMetadata } from "xum/common/types/message"; import { formatAgentMessageEnvelope } from "xum/common/utils/agentMessageEnvelope"; +import { CUSTOM_EVENTS, createCustomEvent } from "xum/common/constants/events"; import { App } from "./App"; import type { UiWorkspace, WebviewToExtensionMessage } from "./protocol"; import type { VscodeBridge } from "./vscodeBridge"; @@ -2604,6 +2605,57 @@ describe("vscode webview background processes strip (#5092)", () => { expect(document.activeElement).toBe(stripRows(view)[0]); }); + test("focus moves to a neighbor when the focused process exits on its own (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-exit", workspaceName: "bash-keys-exit" }; + const { bridge, view, textarea } = await focusComposerWithProcesses(workspace); + await press(textarea, FOCUS_STRIP); + await press(stripRows(view)[0], { key: "ArrowDown" }); + expect(document.activeElement).toBe(stripRows(view)[1]); + + await emitBackgroundBashes(bridge, workspace.id, [runningProcess]); + const remaining = stripRows(view); + expect(remaining).toHaveLength(1); + expect(document.activeElement).toBe(remaining[0]); + }); + + test("opened without a focus origin (command palette), Escape leaves focus on the strip toggle (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-palette", workspaceName: "bash-keys-palette" }; + const { view, textarea } = await focusComposerWithProcesses(workspace); + textarea.blur(); + const request = createCustomEvent(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, { + workspaceId: workspace.id, + handled: false, + }); + await act(async () => { + window.dispatchEvent(request); + await Promise.resolve(); + }); + expect(request.detail.handled).toBe(true); + expect(document.activeElement).toBe(stripRows(view)[0]); + + await press(stripRows(view)[0], { key: "Escape" }); + expect(stripRows(view)).toHaveLength(0); + expect(document.activeElement).toBe(view.getByRole("button", { name: /2 background bashes/ })); + }); + + test("the shortcut is ignored inside the command palette (#5197)", async () => { + const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-cmdk", workspaceName: "bash-keys-cmdk" }; + const { view } = await focusComposerWithProcesses(workspace); + const palette = document.createElement("div"); + palette.setAttribute("cmdk-root", ""); + const input = document.createElement("input"); + palette.appendChild(input); + document.body.appendChild(palette); + try { + input.focus(); + expect(await press(input, FOCUS_STRIP)).toBe(true); + expect(stripRows(view)).toHaveLength(0); + expect(document.activeElement).toBe(input); + } finally { + palette.remove(); + } + }); + test("the shortcut is not consumed when there are no processes (#5197)", async () => { const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-idle", workspaceName: "bash-keys-idle" }; const bridge = new TestBridge(); From 5b2e605bdf1204a647b16a7a1952fe6ed91f2d58 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 19:43:46 +0000 Subject: [PATCH 4/7] Refocus a neighbor when the output row disappears; ignore requests while the pane is inert --- docs/config/keybinds.mdx | 10 +---- .../BackgroundProcessesBanner.stories.tsx | 6 --- .../BackgroundProcessesBanner.tsx | 40 ++++++++++--------- .../Settings/Sections/KeybindsSection.tsx | 5 --- src/browser/utils/ui/keybinds.ts | 6 +-- src/common/constants/events.ts | 5 +-- .../builtInSkillContent.generated.ts | 10 +---- vscode/src/webview/App.test.tsx | 30 ++++++++++---- 8 files changed, 50 insertions(+), 62 deletions(-) diff --git a/docs/config/keybinds.mdx b/docs/config/keybinds.mdx index f9b89327199..cb1c6976ece 100644 --- a/docs/config/keybinds.mdx +++ b/docs/config/keybinds.mdx @@ -42,15 +42,7 @@ When documentation shows `Ctrl`, it means: ## Background Processes -These work in the desktop app and in the VS Code extension. After `Ctrl+Shift+J`, the other shortcuts apply while a row of the background processes strip has focus. - -| Action | Shortcut | -| -------------------------------------- | -------------- | -| Focus the strip (again to collapse it) | `Ctrl+Shift+J` | -| Next / previous process | `↓` / `↑` | -| View output | `Enter` | -| Terminate process | `Backspace` | -| Collapse the strip and return focus | `Esc` | +In the desktop app and the VS Code extension, `Ctrl+Shift+J` focuses the background processes strip (again, or `Esc`, collapses it). On a focused row, `↓`/`↑` move, `Enter` opens the output, and `Backspace` terminates the process. ## Workspaces diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx index 3bc98d69fe7..6589607b13c 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx @@ -247,11 +247,5 @@ export const KeyboardFocusedRow: AppStory = { pixel: { matrix: { viewports: ["phone"] }, }, - docs: { - description: { - story: - "Ctrl/Cmd+Shift+J expands the strip and focuses the first row. Arrow keys move between rows, Enter opens the output, Backspace terminates, and Esc collapses the strip and returns focus.", - }, - }, }, }; diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx index d36f2dd2213..c49974e3055 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx @@ -15,7 +15,6 @@ import { stopKeyboardPropagation } from "@/browser/utils/events"; import { KEYBINDS, formatKeybind, isDialogOpen, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; -// Shortcut hints are keyboard-only affordances: hidden on mobile widths. const SHORTCUT_HINT_CLASS = "ml-1.5 font-mono text-[10px] opacity-70 [@media(max-width:768px)]:hidden"; @@ -26,10 +25,7 @@ function focusProcessRow(list: HTMLElement | null, index: number): void { rows[Math.max(0, Math.min(index, rows.length - 1))].focus(); } -/** - * Returns focus to where it was before the strip took it (usually the composer). Without one (the - * palette closes before its command runs, leaving focus on the body), focus `fallback` instead. - */ +/** Refocuses the origin (usually the composer), else `fallback` (palette runs leave none). */ function restoreFocus( ref: React.MutableRefObject, fallback?: Element | null @@ -83,11 +79,12 @@ export const BackgroundProcessesBanner: React.FC const [activeIndex, setActiveIndex] = useState(0); // Bumped to focus the first row once the expanded list has rendered. const [focusRequest, setFocusRequest] = useState(0); - // The row that has focus. When its process leaves the list (terminated or exited), focus - // would drop to the body, so a neighbor takes it instead. + // The focused row; if its process leaves the list, a neighbor takes focus instead of the body. const focusedRowRef = useRef<{ processId: string; index: number } | null>(null); // The output dialog has no trigger to return focus to, so a keyboard open records its row. - const refocusAfterOutputRef = useRef(null); + const refocusAfterOutputRef = useRef<{ row: HTMLElement; index: number } | null>(null); + // Under immersive review desktop keeps the chat pane mounted but inert: ignore requests then. + const rootRef = useRef(null); // Keep running processes visible, plus exited processes whose monitor matched but whose // wake has not been delivered yet — otherwise a one-shot watcher that matched and exited @@ -124,9 +121,9 @@ export const BackgroundProcessesBanner: React.FC }; const onKeyDown = (event: KeyboardEvent) => { if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return; - // The command palette (cmdk) is an overlay that isDialogOpen() does not see; taking focus - // behind it would send the next keystrokes to hidden rows. + // isDialogOpen() misses the command palette (cmdk); focus must not move behind it. if (event.target instanceof Element && event.target.closest("[cmdk-root]")) return; + if (rootRef.current?.closest("[inert]")) return; event.preventDefault(); if (event.repeat) return; if (listRef.current?.contains(document.activeElement)) { @@ -141,7 +138,7 @@ export const BackgroundProcessesBanner: React.FC const { detail } = event as CustomEvent< CustomEventPayloads[typeof CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES] >; - if (detail.workspaceId !== props.workspaceId) return; + if (detail.workspaceId !== props.workspaceId || rootRef.current?.closest("[inert]")) return; detail.handled = true; open(); }; @@ -173,9 +170,17 @@ export const BackgroundProcessesBanner: React.FC useEffect(() => { if (viewingProcessId != null) return; - const row = refocusAfterOutputRef.current; + const target = refocusAfterOutputRef.current; refocusAfterOutputRef.current = null; - if (row?.isConnected) row.focus(); + if (target == null) return; + // The row's process may have left the list while the dialog was open. + if (target.row.isConnected) { + target.row.focus(); + } else if (listRef.current) { + focusProcessRow(listRef.current, target.index); + } else { + restoreFocus(returnFocusRef); + } }, [viewingProcessId]); const handleListKeyDown = (event: React.KeyboardEvent) => { @@ -202,7 +207,7 @@ export const BackgroundProcessesBanner: React.FC canViewOutput && !terminatingIds.has(proc.id) ) { - refocusAfterOutputRef.current = row; + refocusAfterOutputRef.current = { row, index }; setViewingProcessId(proc.id); } else if ( matchesKeybind(event, KEYBINDS.BACKGROUND_PROCESS_TERMINATE) && @@ -241,7 +246,7 @@ export const BackgroundProcessesBanner: React.FC } return ( - <> +
{count > 0 && ( focusedRowRef.current = { processId: proc.id, index }; }} onBlur={(event) => { - // A row that is still mounted lost focus to something else. A removed row - // keeps the record so the effect above can move focus to a neighbor. + // Only a removed row keeps the record, for the removal effect above. if (event.relatedTarget != null || event.currentTarget.isConnected) { focusedRowRef.current = null; } @@ -404,6 +408,6 @@ export const BackgroundProcessesBanner: React.FC script={viewingProcess?.script} /> )} - +
); }; diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 4d8d350d9d1..9047f880eaf 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -198,11 +198,6 @@ const KEYBIND_GROUPS: Array<{ "TOGGLE_VOICE_INPUT", "SHOW_LAST_PROMPT", "REVEAL_LAST_PROMPT", - ], - }, - { - label: "Background processes", - keys: [ "FOCUS_BACKGROUND_PROCESSES", "BACKGROUND_PROCESS_NEXT", "BACKGROUND_PROCESS_PREV", diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 2a6008ca127..445f8fd0116 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -461,11 +461,9 @@ export const KEYBINDS = { TOGGLE_FAST_MODE: { key: "F", ctrl: true, shift: true }, /** - * Expand the background processes strip and focus its first row; again (or Esc) collapses it and - * restores focus. Works from the composer. The VS Code webview forwards every keydown to the - * workbench, so this avoids VS Code's global chords (Ctrl+Shift+B runs the build task). + * Expand the background processes strip and focus its first row; again (or Esc) collapses it. + * The VS Code webview forwards every keydown to the workbench: avoid VS Code's global chords. */ - // macOS: Cmd+Shift+J, Win/Linux: Ctrl+Shift+J FOCUS_BACKGROUND_PROCESSES: { key: "J", ctrl: true, shift: true }, // Scoped to a focused row of the background processes strip. diff --git a/src/common/constants/events.ts b/src/common/constants/events.ts index c1780b0b6cf..776ad745c25 100644 --- a/src/common/constants/events.ts +++ b/src/common/constants/events.ts @@ -43,9 +43,8 @@ export const CUSTOM_EVENTS = { RUN_LATEST_PLAN_ACTION: "mux:runLatestPlanAction", /** - * Event asking the workspace's background processes strip to expand and take focus (command - * palette). The strip sets `handled` when it has processes to show. - * Detail: { workspaceId: string; handled: boolean } + * Asks the workspace's background processes strip to take focus (command palette); it sets + * `handled` when it did. Detail: { workspaceId: string; handled: boolean } */ FOCUS_BACKGROUND_PROCESSES: "mux:focusBackgroundProcesses", diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d8c9988ff1d..6a43f45a81f 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4266,15 +4266,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Background Processes", "", - "These work in the desktop app and in the VS Code extension. After `Ctrl+Shift+J`, the other shortcuts apply while a row of the background processes strip has focus.", - "", - "| Action | Shortcut |", - "| -------------------------------------- | -------------- |", - "| Focus the strip (again to collapse it) | `Ctrl+Shift+J` |", - "| Next / previous process | `↓` / `↑` |", - "| View output | `Enter` |", - "| Terminate process | `Backspace` |", - "| Collapse the strip and return focus | `Esc` |", + "In the desktop app and the VS Code extension, `Ctrl+Shift+J` focuses the background processes strip (again, or `Esc`, collapses it). On a focused row, `↓`/`↑` move, `Enter` opens the output, and `Backspace` terminates the process.", "", "## Workspaces", "", diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index 44744367f58..630afe0b58a 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -2595,13 +2595,23 @@ describe("vscode webview background processes strip (#5092)", () => { ]); // The dialog has no trigger to return focus to; closing it refocuses the row. - const close = Array.from( - document.body.querySelectorAll('[role="dialog"] button') - ).find((button) => button.textContent === "Close"); - if (!close) throw new Error("the output dialog has no close button"); - await click(close); - await settle(); - expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + const closeDialog = async () => { + const close = Array.from( + document.body.querySelectorAll('[role="dialog"] button') + ).find((button) => button.textContent === "Close"); + if (!close) throw new Error("the output dialog has no close button"); + await click(close); + await settle(); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + }; + await closeDialog(); + expect(document.activeElement).toBe(stripRows(view)[0]); + + // When the row's process leaves the list while the dialog is open, a neighbor takes focus. + await press(stripRows(view)[0], { key: "ArrowDown" }); + await press(stripRows(view)[1], { key: "Enter" }); + await emitBackgroundBashes(bridge, workspace.id, [runningProcess]); + await closeDialog(); expect(document.activeElement).toBe(stripRows(view)[0]); }); @@ -2638,7 +2648,7 @@ describe("vscode webview background processes strip (#5092)", () => { expect(document.activeElement).toBe(view.getByRole("button", { name: /2 background bashes/ })); }); - test("the shortcut is ignored inside the command palette (#5197)", async () => { + test("the shortcut is ignored inside the command palette or an inert pane (#5197)", async () => { const workspace: UiWorkspace = { ...WORKSPACE, id: "ws-bash-keys-cmdk", workspaceName: "bash-keys-cmdk" }; const { view } = await focusComposerWithProcesses(workspace); const palette = document.createElement("div"); @@ -2654,6 +2664,10 @@ describe("vscode webview background processes strip (#5092)", () => { } finally { palette.remove(); } + // Nor while the chat pane is inert (desktop's immersive review keeps it mounted). + view.container.setAttribute("inert", ""); + expect(await press(document.body, FOCUS_STRIP)).toBe(true); + expect(stripRows(view)).toHaveLength(0); }); test("the shortcut is not consumed when there are no processes (#5197)", async () => { From 6fc0100016e5c3eee7d0d151093d33d14af971fa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 19:54:30 +0000 Subject: [PATCH 5/7] Leave the chord to the terminal; give the story play CI-sized timeouts; list only the global chord in Settings --- .../BackgroundProcessesBanner.stories.tsx | 14 ++++++-------- .../BackgroundProcessesBanner.tsx | 9 ++++++++- .../features/Settings/Sections/KeybindsSection.tsx | 4 ---- vscode/src/webview/App.test.tsx | 5 +++++ 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx index 6589607b13c..e71a40c402f 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx @@ -1,4 +1,4 @@ -import { waitFor, within } from "@storybook/test"; +import { expect, waitFor, within } from "@storybook/test"; import type { AppStory } from "@/browser/stories/meta.js"; import { appMeta, AppWithMocks } from "@/browser/stories/meta.js"; import { setupSimpleChatStory } from "@/browser/stories/helpers/chatSetup"; @@ -225,19 +225,17 @@ export const MonitorLostWakePendingAfterRestart: AppStory = { export const KeyboardFocusedRow: AppStory = { render: BackgroundProcesses.render, play: async ({ canvasElement }) => { - await within(canvasElement).findByRole("button", { name: /3 background bashes/ }); + // The full app mounts first; CI needs more than the default 1 s. + const name = /3 background bashes/; + await within(canvasElement).findByRole("button", { name }, { timeout: 10_000 }); const composer = canvasElement.querySelector("textarea"); if (!composer) throw new Error("composer textarea not found"); composer.focus(); composer.dispatchEvent( new KeyboardEvent("keydown", { key: "J", ctrlKey: true, shiftKey: true, bubbles: true }) ); - await waitFor(() => { - if (!(document.activeElement instanceof HTMLElement)) throw new Error("nothing focused"); - if (!document.activeElement.hasAttribute("data-process-row")) { - throw new Error("the first background process row is not focused yet"); - } - }); + const isRowFocused = () => document.activeElement?.hasAttribute("data-process-row") === true; + await waitFor(() => expect(isRowFocused()).toBe(true), { timeout: 5_000 }); }, // One snapshot (the Pixel budget is tight): phone, where the shortcut hints are hidden. globals: { diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx index c49974e3055..215925941c4 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx @@ -12,7 +12,13 @@ import { import { useBackgroundBashActions } from "@/browser/contexts/BackgroundBashContext"; import { useChatHostContext } from "@/browser/contexts/ChatHostContext"; import { stopKeyboardPropagation } from "@/browser/utils/events"; -import { KEYBINDS, formatKeybind, isDialogOpen, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { + KEYBINDS, + formatKeybind, + isDialogOpen, + isTerminalFocused, + matchesKeybind, +} from "@/browser/utils/ui/keybinds"; import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events"; const SHORTCUT_HINT_CLASS = @@ -123,6 +129,7 @@ export const BackgroundProcessesBanner: React.FC if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return; // isDialogOpen() misses the command palette (cmdk); focus must not move behind it. if (event.target instanceof Element && event.target.closest("[cmdk-root]")) return; + if (isTerminalFocused(event.target)) return; // the terminal owns its keystrokes if (rootRef.current?.closest("[inert]")) return; event.preventDefault(); if (event.repeat) return; diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 9047f880eaf..9f92690c411 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -199,10 +199,6 @@ const KEYBIND_GROUPS: Array<{ "SHOW_LAST_PROMPT", "REVEAL_LAST_PROMPT", "FOCUS_BACKGROUND_PROCESSES", - "BACKGROUND_PROCESS_NEXT", - "BACKGROUND_PROCESS_PREV", - "BACKGROUND_PROCESS_VIEW_OUTPUT", - "BACKGROUND_PROCESS_TERMINATE", ], }, { diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index 630afe0b58a..ba39586de1e 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -2664,6 +2664,11 @@ describe("vscode webview background processes strip (#5092)", () => { } finally { palette.remove(); } + // Nor from the terminal, which owns its keystrokes. + const terminal = document.createElement("textarea"); + terminal.setAttribute("data-terminal-container", ""); + view.container.appendChild(terminal); + expect(await press(terminal, FOCUS_STRIP)).toBe(true); // Nor while the chat pane is inert (desktop's immersive review keeps it mounted). view.container.setAttribute("inert", ""); expect(await press(document.body, FOCUS_STRIP)).toBe(true); From 5e03a59daf9e20701a2f173a1484541fe142d6fb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 20:19:50 +0000 Subject: [PATCH 6/7] Trim comments to keep the PR within 500 lines --- .../BackgroundProcessesBanner.tsx | 1 - src/browser/utils/commands/sources.ts | 2 -- src/browser/utils/ui/keybinds.ts | 7 ++----- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx index 215925941c4..fc3b26978ec 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.tsx @@ -83,7 +83,6 @@ export const BackgroundProcessesBanner: React.FC const listRef = useRef(null); const returnFocusRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); - // Bumped to focus the first row once the expanded list has rendered. const [focusRequest, setFocusRequest] = useState(0); // The focused row; if its process leaves the list, a neighbor takes focus instead of the body. const focusedRowRef = useRef<{ processId: string; index: number } | null>(null); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index ae2b805d3e2..812b8921d7d 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1424,10 +1424,8 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi title: "Focus Background Processes", subtitle: "Arrows select, Enter shows output, Backspace terminates", section: section.chat, - keywords: ["background", "bash", "process", "terminate", "output"], shortcutHint: formatKeybind(KEYBINDS.FOCUS_BACKGROUND_PROCESSES), run: () => { - // The strip takes focus and marks the request handled; listeners run synchronously. const request = createCustomEvent(CUSTOM_EVENTS.FOCUS_BACKGROUND_PROCESSES, { workspaceId: id, handled: false, diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 445f8fd0116..71e78cdbe05 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -460,12 +460,9 @@ export const KEYBINDS = { // macOS: Cmd+Shift+F, Win/Linux: Ctrl+Shift+F TOGGLE_FAST_MODE: { key: "F", ctrl: true, shift: true }, - /** - * Expand the background processes strip and focus its first row; again (or Esc) collapses it. - * The VS Code webview forwards every keydown to the workbench: avoid VS Code's global chords. - */ + // Expand the background processes strip and focus its first row; again (or Esc) collapses it. + // The VS Code webview forwards every keydown to the workbench: avoid VS Code's global chords. FOCUS_BACKGROUND_PROCESSES: { key: "J", ctrl: true, shift: true }, - // Scoped to a focused row of the background processes strip. BACKGROUND_PROCESS_NEXT: { key: "ArrowDown" }, BACKGROUND_PROCESS_PREV: { key: "ArrowUp" }, From 58d46988eb7a2f3cfdd8afdfef5175c72a4a9cb8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 4 Oct 2026 20:38:44 +0000 Subject: [PATCH 7/7] Drop the keyboard story; App tests cover the flow --- .../BackgroundProcessesBanner.stories.tsx | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx index e71a40c402f..e90d33ef415 100644 --- a/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx +++ b/src/browser/components/BackgroundProcessesBanner/BackgroundProcessesBanner.stories.tsx @@ -1,4 +1,3 @@ -import { expect, waitFor, within } from "@storybook/test"; import type { AppStory } from "@/browser/stories/meta.js"; import { appMeta, AppWithMocks } from "@/browser/stories/meta.js"; import { setupSimpleChatStory } from "@/browser/stories/helpers/chatSetup"; @@ -220,30 +219,3 @@ export const MonitorLostWakePendingAfterRestart: AppStory = { }, }, }; - -/** The focus shortcut expands the strip and focuses its first row (#5197). */ -export const KeyboardFocusedRow: AppStory = { - render: BackgroundProcesses.render, - play: async ({ canvasElement }) => { - // The full app mounts first; CI needs more than the default 1 s. - const name = /3 background bashes/; - await within(canvasElement).findByRole("button", { name }, { timeout: 10_000 }); - const composer = canvasElement.querySelector("textarea"); - if (!composer) throw new Error("composer textarea not found"); - composer.focus(); - composer.dispatchEvent( - new KeyboardEvent("keydown", { key: "J", ctrlKey: true, shiftKey: true, bubbles: true }) - ); - const isRowFocused = () => document.activeElement?.hasAttribute("data-process-row") === true; - await waitFor(() => expect(isRowFocused()).toBe(true), { timeout: 5_000 }); - }, - // One snapshot (the Pixel budget is tight): phone, where the shortcut hints are hidden. - globals: { - viewport: { value: "mobile1", isRotated: false }, - }, - parameters: { - pixel: { - matrix: { viewports: ["phone"] }, - }, - }, -};