From 73e5aeb1b432ad11112ea4dbb0f979014c3218b5 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:37:57 +0800 Subject: [PATCH 1/3] Add right panel close controls --- .../src/backend/DesktopBackendPool.test.ts | 1 + .../desktop/src/electron/ElectronMenu.test.ts | 52 ++++- apps/desktop/src/electron/ElectronMenu.ts | 14 ++ .../src/window/DesktopApplicationMenu.test.ts | 143 ++++++++++-- .../src/window/DesktopApplicationMenu.ts | 35 ++- apps/desktop/src/window/DesktopWindow.test.ts | 24 ++ apps/desktop/src/window/DesktopWindow.ts | 44 ++-- apps/web/src/components/AppSidebarLayout.tsx | 8 + apps/web/src/components/ChatView.tsx | 150 +++++++++++- .../src/components/RightPanelTabs.test.tsx | 104 +++++++++ apps/web/src/components/RightPanelTabs.tsx | 12 +- .../components/chat/PanelLayoutControls.tsx | 31 ++- apps/web/src/keybindings.test.ts | 33 +++ apps/web/src/keybindings.ts | 2 + apps/web/src/lib/nativeCloseFocus.test.ts | 145 ++++++++++++ apps/web/src/lib/nativeCloseFocus.ts | 73 ++++++ .../src/lib/rightPanelCloseRequest.test.ts | 216 ++++++++++++++++++ apps/web/src/lib/rightPanelCloseRequest.ts | 96 ++++++++ apps/web/src/lib/rightPanelFocus.test.ts | 113 +++++++++ apps/web/src/lib/rightPanelFocus.ts | 30 +++ apps/web/src/lib/rightPanelTabPointer.ts | 18 ++ packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 5 + 24 files changed, 1306 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/RightPanelTabs.test.tsx create mode 100644 apps/web/src/lib/nativeCloseFocus.test.ts create mode 100644 apps/web/src/lib/nativeCloseFocus.ts create mode 100644 apps/web/src/lib/rightPanelCloseRequest.test.ts create mode 100644 apps/web/src/lib/rightPanelCloseRequest.ts create mode 100644 apps/web/src/lib/rightPanelFocus.test.ts create mode 100644 apps/web/src/lib/rightPanelFocus.ts create mode 100644 apps/web/src/lib/rightPanelTabPointer.ts diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..095087c1741 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -78,6 +78,7 @@ function makePoolLayer( handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), + dispatchMenuActionToMainWindow: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1d..bb181082bcf 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -7,17 +7,22 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; -const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( - () => ({ - buildFromTemplateMock: vi.fn(), - createFromNamedImageMock: vi.fn(), - setApplicationMenuMock: vi.fn(), - }), -); +const { + buildFromTemplateMock, + createFromNamedImageMock, + sendActionToFirstResponderMock, + setApplicationMenuMock, +} = vi.hoisted(() => ({ + buildFromTemplateMock: vi.fn(), + createFromNamedImageMock: vi.fn(), + sendActionToFirstResponderMock: vi.fn(), + setApplicationMenuMock: vi.fn(), +})); vi.mock("electron", () => ({ Menu: { buildFromTemplate: buildFromTemplateMock, + sendActionToFirstResponder: sendActionToFirstResponderMock, setApplicationMenu: setApplicationMenuMock, }, nativeImage: { @@ -41,6 +46,7 @@ describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); createFromNamedImageMock.mockReset(); + sendActionToFirstResponderMock.mockReset(); setApplicationMenuMock.mockReset(); }); @@ -160,6 +166,38 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("sends native actions to the first responder", () => + Effect.gen(function* () { + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.sendActionToFirstResponder("performClose:"); + + assert.deepEqual(sendActionToFirstResponderMock.mock.calls, [["performClose:"]]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves first-responder failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("native action failed"); + sendActionToFirstResponderMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit(electronMenu.sendActionToFirstResponder("performClose:")); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "send-action-to-first-responder"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.equal(error.itemCount, 0); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("preserves popup-template failures with window context", () => Effect.gen(function* () { const cause = new Error("popup failed"); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c241..9aafcf75367 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -26,6 +26,7 @@ export interface ElectronMenuTemplateInput { const ElectronMenuOperation = Schema.Literals([ "set-application-menu", + "send-action-to-first-responder", "popup-template", "show-context-menu", ]); @@ -52,6 +53,7 @@ export class ElectronMenu extends Context.Service< readonly setApplicationMenu: ( template: readonly Electron.MenuItemConstructorOptions[], ) => Effect.Effect; + readonly sendActionToFirstResponder: (action: string) => Effect.Effect; readonly showContextMenu: ( input: ElectronMenuContextInput, ) => Effect.Effect>; @@ -185,6 +187,18 @@ export const make = Effect.gen(function* () { cause, }), }).pipe(Effect.orDie), + sendActionToFirstResponder: (action) => + Effect.try({ + try: () => Electron.Menu.sendActionToFirstResponder(action), + catch: (cause) => + new ElectronMenuOperationError({ + operation: "send-action-to-first-responder", + platform, + windowId: null, + itemCount: 0, + cause, + }), + }).pipe(Effect.orDie), popupTemplate: (input) => input.template.length === 0 ? Effect.void diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..8ccba085fc7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -66,7 +66,10 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { install: Effect.die("unexpected install"), } satisfies DesktopUpdates.DesktopUpdates["Service"]); -const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => +const makeDesktopWindowLayer = ( + selectedAction: Deferred.Deferred, + mainWindowActionsHandled: boolean, +) => Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -78,19 +81,55 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + dispatchMenuActionToMainWindow: (_window, action) => + mainWindowActionsHandled + ? Deferred.succeed(selectedAction, action).pipe(Effect.as(true)) + : Effect.succeed(false), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( applicationMenuTemplate: Deferred.Deferred, + nativeActions: string[] = [], ) => Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: (template) => Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), + sendActionToFirstResponder: (action) => + Effect.sync(() => { + nativeActions.push(action); + }), popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); +const configureMenu = ( + platform: "darwin" | "linux", + selectedAction: Deferred.Deferred, + applicationMenuTemplate: Deferred.Deferred, + nativeActions: string[] = [], + mainWindowActionsHandled = true, +) => + Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate, nativeActions)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction, mainWindowActionsHandled)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer({ ...environmentInput, platform }).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { @@ -98,25 +137,7 @@ describe("DesktopApplicationMenu", () => { const applicationMenuTemplate = yield* Deferred.make(); - yield* Effect.gen(function* () { - const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - yield* menu.configure; - }).pipe( - Effect.provide( - DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), - Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), - Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(electronDialogLayer), - Layer.provideMerge(electronAppLayer), - Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), - ), - ), - ), - ), - ); + yield* configureMenu("linux", selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); const fileMenu = template.find((item) => item.label === "File"); @@ -135,4 +156,86 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + it.effect("routes the macOS close shortcut through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu("darwin", selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const closeItem = fileMenu.submenu.find((item) => item.label === "Close Window"); + assert.isDefined(closeItem); + assert.equal(closeItem.accelerator, "Cmd+W"); + const closeClick = closeItem.click; + if (typeof closeClick !== "function") { + throw new Error("Expected Close Window menu item to have a click handler."); + } + + closeClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "close-window-or-right-panel"); + }), + ); + + it.effect("preserves native close behavior when system UI owns focus", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + const nativeActions: string[] = []; + + yield* configureMenu("darwin", selectedAction, applicationMenuTemplate, nativeActions); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const closeItem = fileMenu.submenu.find((item) => item.label === "Close Window"); + assert.isDefined(closeItem); + const closeClick = closeItem.click; + if (typeof closeClick !== "function") { + throw new Error("Expected Close Window menu item to have a click handler."); + } + + closeClick({} as Electron.MenuItem, undefined, {} as KeyboardEvent); + assert.deepEqual(nativeActions, ["performClose:"]); + }), + ); + + it.effect("preserves native close behavior for a non-main browser window", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + const nativeActions: string[] = []; + + yield* configureMenu("darwin", selectedAction, applicationMenuTemplate, nativeActions, false); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const closeItem = fileMenu.submenu.find((item) => item.label === "Close Window"); + assert.isDefined(closeItem); + const closeClick = closeItem.click; + if (typeof closeClick !== "function") { + throw new Error("Expected Close Window menu item to have a click handler."); + } + + closeClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + yield* Effect.yieldNow; + assert.deepEqual(nativeActions, ["performClose:"]); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0..bd5c1590beb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -127,6 +127,31 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const closeWindowClick: NonNullable = ( + _menuItem, + browserWindow, + ) => { + if (!browserWindow) { + runMenuEffect( + "close-focused-native-window", + electronMenu.sendActionToFirstResponder("performClose:"), + ); + return; + } + runMenuEffect( + "close-window-or-right-panel", + Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const handled = yield* desktopWindow.dispatchMenuActionToMainWindow( + browserWindow, + "close-window-or-right-panel", + ); + if (!handled) { + yield* electronMenu.sendActionToFirstResponder("performClose:"); + } + }), + ); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -170,7 +195,15 @@ export const make = Effect.gen(function* () { }, { type: "separator" as const }, ]), - { role: environment.platform === "darwin" ? "close" : "quit" }, + ...(environment.platform === "darwin" + ? [ + { + label: "Close Window", + accelerator: "Cmd+W", + click: closeWindowClick, + }, + ] + : [{ role: "quit" as const }]), ], }, { role: "editMenu" }, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..c14735e0ec3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -148,6 +148,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, + sendActionToFirstResponder: () => Effect.void, popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenu["Service"]); @@ -1096,4 +1097,27 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(scenario.layer)); }), ); + + it.effect("dispatches targeted menu actions only to the registered main window", () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([splash.window]); + yield* Ref.set(scenario.mainWindow, Option.some(main.window)); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + assert.isTrue( + yield* desktopWindow.dispatchMenuActionToMainWindow(main.window, "close-window"), + ); + assert.isFalse( + yield* desktopWindow.dispatchMenuActionToMainWindow(splash.window, "close-window"), + ); + assert.deepEqual(main.send.mock.calls, [[MENU_ACTION_CHANNEL, "close-window"]]); + assert.equal(splash.send.mock.calls.length, 0); + assert.deepEqual(yield* Ref.get(scenario.revealedWindows), []); + }).pipe(Effect.provide(scenario.layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..3d1f0cd88db 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -80,6 +80,10 @@ export class DesktopWindow extends Context.Service< readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly dispatchMenuActionToMainWindow: ( + window: Electron.BaseWindow, + action: string, + ) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -286,6 +290,24 @@ export const make = Effect.gen(function* () { const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + const sendMenuAction = Effect.fn("desktop.window.sendMenuAction")( + (targetWindow: Electron.BrowserWindow, action: string, reveal: boolean) => + Effect.sync(() => { + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + if (reveal) void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }), + ); + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< Electron.BrowserWindow, DesktopWindowError @@ -772,20 +794,16 @@ export const make = Effect.gen(function* () { return; } const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - void runPromise(electronWindow.reveal(targetWindow)); - }; - - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } - - send(); + yield* sendMenuAction(targetWindow, action, true); }), + dispatchMenuActionToMainWindow: Effect.fn("desktop.window.dispatchMenuActionToMainWindow")( + function* (window, action) { + const mainWindow = yield* electronWindow.main; + if (Option.isNone(mainWindow) || mainWindow.value !== window) return false; + yield* sendMenuAction(mainWindow.value, action, false); + return true; + }, + ), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6c692dc3de8..62286f55a7c 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { requestCloseActiveRightPanelSurface } from "../lib/rightPanelCloseRequest"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; @@ -144,6 +145,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { if (!isSettingsRoute) { void navigate({ to: "/settings" }); } + return; + } + + if (action === "close-window-or-right-panel") { + if (!requestCloseActiveRightPanelSurface()) { + window.close(); + } } }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..8d1b01bd6c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -48,6 +48,7 @@ import { Suspense, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -155,6 +156,21 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { + clearNativeCloseFocusOwner, + finishNativeClosePointer, + getNativeCloseFocusOwner, + recordNativeCloseFocus, + recordNativeCloseFocusOut, + recordNativeClosePointer, +} from "../lib/nativeCloseFocus"; +import { isRightPanelFocused } from "../lib/rightPanelFocus"; +import { + CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, + resolveNativeCloseTarget, + resolveRightPanelCloseTarget, + shouldDeferCloseCommandToDesktopMenu, +} from "../lib/rightPanelCloseRequest"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, @@ -208,7 +224,11 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; +import { + PanelLayoutControls, + RightPanelCloseControl, + RightPanelMaximizeControl, +} from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; @@ -1459,6 +1479,13 @@ function ChatViewContent(props: ChatViewProps) { canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + useEffect(() => { + if (rightPanelOpen) return; + setMaximizedRightPanelThreadKey((threadKey) => + threadKey === routeThreadKey ? null : threadKey, + ); + }, [rightPanelOpen, routeThreadKey]); + useEffect(() => { if (!activeThreadRef) return; useRightPanelStore @@ -3146,6 +3173,97 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], ); + const closeActiveRightPanelTarget = useCallback( + (terminalFocusOwner = getTerminalFocusOwner()) => { + const target = resolveRightPanelCloseTarget({ + isOpen: rightPanelOpen, + hasActiveSurface: activeRightPanelSurface !== null, + hasFocusedTerminal: + activeRightPanelSurface?.kind === "terminal" && terminalFocusOwner === "right-panel", + }); + if (target === "terminal" && activeRightPanelSurface?.kind === "terminal") { + closePanelTerminal(activeRightPanelSurface.activeTerminalId); + } else if (target === "surface" && activeRightPanelSurface) { + closeRightPanelSurface(activeRightPanelSurface); + } else if (target === "panel" && activeThreadRef) { + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + } + return target !== null; + }, + [ + activeRightPanelSurface, + activeThreadRef, + closePanelTerminal, + closeRightPanelSurface, + rightPanelOpen, + ], + ); + const handleCloseRequest = useEffectEvent((event: Event) => { + const target = resolveNativeCloseTarget({ + focusOwner: getNativeCloseFocusOwner(), + drawerTerminalOpen: terminalUiState.terminalOpen, + rightPanelOpen, + hasActiveRightPanelSurface: activeRightPanelSurface !== null, + activeRightPanelSurfaceIsTerminal: activeRightPanelSurface?.kind === "terminal", + }); + if (target === "drawer-terminal") { + closeTerminal(terminalUiState.activeTerminalId); + } else if (target === "right-panel-terminal" && activeRightPanelSurface?.kind === "terminal") { + closePanelTerminal(activeRightPanelSurface.activeTerminalId); + } else if (target === "right-panel-surface" && activeRightPanelSurface) { + closeRightPanelSurface(activeRightPanelSurface); + } else if (target === "right-panel" && activeThreadRef) { + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + } else { + return; + } + event.preventDefault(); + }); + useEffect(() => { + const listener = (event: Event) => handleCloseRequest(event); + + window.addEventListener(CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, listener); + return () => { + window.removeEventListener(CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, listener); + }; + }, []); + useEffect(() => { + const recordFocus = (event: FocusEvent | PointerEvent) => { + recordNativeCloseFocus(event.target); + }; + const recordFocusOut = (event: FocusEvent) => { + recordNativeCloseFocusOut(event.relatedTarget, document.hasFocus()); + }; + const recordPointer = (event: PointerEvent) => { + recordNativeClosePointer(event.target); + }; + + recordNativeCloseFocus(document.activeElement); + window.addEventListener("focusin", recordFocus, true); + window.addEventListener("focusout", recordFocusOut, true); + window.addEventListener("pointerdown", recordPointer, true); + window.addEventListener("pointerup", finishNativeClosePointer, true); + window.addEventListener("pointercancel", finishNativeClosePointer, true); + window.addEventListener("blur", finishNativeClosePointer); + return () => { + window.removeEventListener("focusin", recordFocus, true); + window.removeEventListener("focusout", recordFocusOut, true); + window.removeEventListener("pointerdown", recordPointer, true); + window.removeEventListener("pointerup", finishNativeClosePointer, true); + window.removeEventListener("pointercancel", finishNativeClosePointer, true); + window.removeEventListener("blur", finishNativeClosePointer); + clearNativeCloseFocusOwner(); + }; + }, [routeThreadKey]); + useEffect(() => { + if (!terminalUiState.terminalOpen) clearNativeCloseFocusOwner("drawer-terminal"); + if (!rightPanelOpen) { + clearNativeCloseFocusOwner("right-panel-terminal"); + clearNativeCloseFocusOwner("right-panel"); + } + }, [rightPanelOpen, terminalUiState.terminalOpen]); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -3832,6 +3950,13 @@ function ChatViewContent(props: ChatViewProps) { terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + rightPanelFocus: isRightPanelFocused({ + rightPanelOpen, + activePreviewTabId: + activeRightPanelSurface?.kind === "preview" + ? (activeRightPanelSurface.resourceId ?? null) + : null, + }), }; if ( @@ -3851,6 +3976,18 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if ( + shouldDeferCloseCommandToDesktopMenu({ + command, + desktop: isElectron, + platform: navigator.platform, + event, + terminalFocused: terminalFocusOwner !== null, + }) + ) { + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -3865,6 +4002,13 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "rightPanel.closeActiveSurface") { + if (!closeActiveRightPanelTarget()) return; + event.preventDefault(); + event.stopPropagation(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -3952,6 +4096,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId, closeTerminal, closePanelTerminal, + closeActiveRightPanelTarget, createNewTerminal, setTerminalOpen, runProjectScript, @@ -5110,6 +5255,9 @@ function ChatViewContent(props: ChatViewProps) { /> ) : null} {panelToggleControls} + {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + + ) : null} ); const rightPanelContent = activeThreadRef ? ( diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx new file mode 100644 index 00000000000..5f338c12f89 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,104 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { handleSurfaceTabAuxClick } from "~/lib/rightPanelTabPointer"; + +import { RightPanelTabs } from "./RightPanelTabs"; +import { + PanelLayoutControls, + RightPanelCloseControl, + RightPanelMaximizeControl, +} from "./chat/PanelLayoutControls"; + +vi.mock("~/env", () => ({ isElectron: false })); + +function renderTabs(mode: "inline" | "sheet"): string { + return renderToStaticMarkup( + +
Active surface
+
, + ); +} + +describe("RightPanelTabs", () => { + it("closes the clicked surface on middle mouse button", () => { + const surface = { id: "files", kind: "files" } as const; + const event = { + button: 1, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const onCloseSurface = vi.fn(); + + handleSurfaceTabAuxClick(event, surface, onCloseSurface); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); + expect(onCloseSurface).toHaveBeenCalledOnce(); + expect(onCloseSurface).toHaveBeenCalledWith(surface); + }); + + it.each([0, 2])("ignores auxiliary click button %s", (button) => { + const event = { + button, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const onCloseSurface = vi.fn(); + + handleSurfaceTabAuxClick(event, { id: "files", kind: "files" }, onCloseSurface); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(onCloseSurface).not.toHaveBeenCalled(); + }); + + it("marks the panel content region", () => { + const markup = renderTabs("inline"); + + expect(markup).toContain("data-right-panel-content"); + }); + + it("places the whole-panel close control after the other title-bar controls", () => { + const markup = renderToStaticMarkup( +
+ + + +
, + ); + + expect(markup.indexOf('aria-label="Close right panel"')).toBeGreaterThan( + markup.indexOf('aria-label="Toggle right panel"'), + ); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f5bc9880d74..57556de01ed 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -19,6 +19,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; import { faviconUrlForOrigin } from "~/lib/favicon"; +import { handleSurfaceTabAuxClick } from "~/lib/rightPanelTabPointer"; import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; @@ -337,10 +338,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }, []); const handleTabAuxClick = useCallback( (event: ReactMouseEvent, surface: RightPanelSurface) => { - if (event.button !== 1) return; - event.preventDefault(); - event.stopPropagation(); - props.onCloseSurface(surface); + handleSurfaceTabAuxClick(event, surface, props.onCloseSurface); }, [props], ); @@ -358,8 +356,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.layoutControls}
-
+
{props.activeSurfaceId === null ? ( ); }); + +export const RightPanelCloseControl = memo(function RightPanelCloseControl({ + onClose, +}: { + onClose: () => void; +}) { + return ( + + + + + } + /> + Close right panel + + ); +}); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..e444a10249d 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -108,6 +108,11 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.close", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: modShortcut("w"), + command: "rightPanel.closeActiveSurface", + whenAst: whenAnd(whenIdentifier("rightPanelFocus"), whenNot(whenIdentifier("terminalFocus"))), + }, { shortcut: modShortcut("d"), command: "diff.toggle", @@ -289,6 +294,34 @@ describe("split/new/close terminal shortcuts", () => { }); }); +describe("right panel close shortcut", () => { + it("matches Cmd+W only while the right panel owns focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { rightPanelFocus: true }, + }), + "rightPanel.closeActiveSurface", + ); + assert.isNull( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { rightPanelFocus: false }, + }), + ); + }); + + it("keeps terminal close active for a focused right-panel terminal", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { rightPanelFocus: true, terminalFocus: true }, + }), + "terminal.close", + ); + }); +}); + describe("shortcutLabelForCommand", () => { it("returns the effective binding label", () => { const bindings = compile([ diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a7780..a0a4b2b7a5e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -32,6 +32,7 @@ export interface ShortcutMatchContext { terminalOpen: boolean; previewFocus: boolean; previewOpen: boolean; + rightPanelFocus: boolean; [key: string]: boolean; } @@ -120,6 +121,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc terminalOpen: false, previewFocus: false, previewOpen: false, + rightPanelFocus: false, ...options?.context, }; } diff --git a/apps/web/src/lib/nativeCloseFocus.test.ts b/apps/web/src/lib/nativeCloseFocus.test.ts new file mode 100644 index 00000000000..1f45ed89753 --- /dev/null +++ b/apps/web/src/lib/nativeCloseFocus.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + clearNativeCloseFocusOwner, + finishNativeClosePointer, + getNativeCloseFocusOwner, + recordNativeCloseFocus, + recordNativeCloseFocusOut, + recordNativeClosePointer, +} from "./nativeCloseFocus"; + +class MockElement extends EventTarget { + isConnected = true; + owner: "drawer" | "right-panel" | null = null; + inRightPanel = false; + inPreviewViewport = false; + tagName = "div"; + + closest(selector: string): MockElement | null { + if (!this.isConnected) return null; + if (selector === "[data-terminal-owner]" && this.owner !== null) return this; + if (selector.includes("[data-right-panel-tabbar]") && this.inRightPanel) return this; + if (selector === "[data-preview-viewport]" && this.inPreviewViewport) return this; + return null; + } + + get dataset(): DOMStringMap { + return { terminalOwner: this.owner ?? undefined } as DOMStringMap; + } +} + +const originalDocument = globalThis.document; +const originalElement = globalThis.Element; + +afterEach(() => { + clearNativeCloseFocusOwner(); + if (originalDocument === undefined) { + delete (globalThis as { document?: Document }).document; + } else { + globalThis.document = originalDocument; + } + if (originalElement === undefined) { + delete (globalThis as { Element?: typeof Element }).Element; + } else { + globalThis.Element = originalElement; + } +}); + +function installDom(activeElement: MockElement, body = new MockElement()): void { + globalThis.Element = MockElement as unknown as typeof Element; + globalThis.document = { activeElement, body } as unknown as Document; +} + +describe("native close focus", () => { + it("distinguishes drawer terminals, right-panel terminals, and other panel focus", () => { + const drawer = new MockElement(); + drawer.owner = "drawer"; + installDom(drawer); + expect(getNativeCloseFocusOwner()).toBe("drawer-terminal"); + + const panelTerminal = new MockElement(); + panelTerminal.owner = "right-panel"; + installDom(panelTerminal); + expect(getNativeCloseFocusOwner()).toBe("right-panel-terminal"); + + const panelControl = new MockElement(); + panelControl.inRightPanel = true; + installDom(panelControl); + expect(getNativeCloseFocusOwner()).toBe("right-panel"); + }); + + it("retains ownership only while focus is obscured by native UI", () => { + const terminal = new MockElement(); + terminal.owner = "drawer"; + const body = new MockElement(); + installDom(body, body); + recordNativeCloseFocus(terminal); + recordNativeCloseFocusOut(null, false); + + expect(getNativeCloseFocusOwner()).toBe("drawer-terminal"); + + recordNativeCloseFocusOut(null, true); + expect(getNativeCloseFocusOwner()).toBeNull(); + }); + + it("preserves pointer ownership until the pointer gesture finishes", async () => { + const panelChrome = new MockElement(); + panelChrome.inRightPanel = true; + const body = new MockElement(); + installDom(body, body); + + recordNativeClosePointer(panelChrome); + await Promise.resolve(); + recordNativeCloseFocusOut(null, true); + finishNativeClosePointer(); + + expect(getNativeCloseFocusOwner()).toBe("right-panel"); + }); + + it("clears retained ownership on focus or pointer movement outside the panel", () => { + const panel = new MockElement(); + panel.inRightPanel = true; + const outside = new MockElement(); + installDom(outside); + recordNativeCloseFocus(panel); + recordNativeCloseFocus(outside); + + expect(getNativeCloseFocusOwner()).toBeNull(); + }); + + it("updates ownership directly from a focusout related target", () => { + const drawer = new MockElement(); + drawer.owner = "drawer"; + const panel = new MockElement(); + panel.inRightPanel = true; + installDom(panel); + recordNativeCloseFocus(drawer); + recordNativeCloseFocusOut(panel, true); + + expect(getNativeCloseFocusOwner()).toBe("right-panel"); + }); + + it("treats hosted preview webviews and viewport chrome as right-panel focus", () => { + const webview = new MockElement(); + webview.tagName = "webview"; + installDom(webview); + expect(getNativeCloseFocusOwner()).toBe("right-panel"); + + const viewport = new MockElement(); + viewport.inPreviewViewport = true; + installDom(viewport); + expect(getNativeCloseFocusOwner()).toBe("right-panel"); + }); + + it("ignores detached retained targets", () => { + const terminal = new MockElement(); + terminal.owner = "drawer"; + terminal.isConnected = false; + const body = new MockElement(); + installDom(body, body); + recordNativeCloseFocus(terminal); + + expect(getNativeCloseFocusOwner()).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/nativeCloseFocus.ts b/apps/web/src/lib/nativeCloseFocus.ts new file mode 100644 index 00000000000..77c360bb658 --- /dev/null +++ b/apps/web/src/lib/nativeCloseFocus.ts @@ -0,0 +1,73 @@ +export type NativeCloseFocusOwner = "drawer-terminal" | "right-panel-terminal" | "right-panel"; + +const RIGHT_PANEL_SELECTOR = + "[data-right-panel-tabbar], [data-right-panel-content], [data-right-panel-control]"; + +let retainedOwner: NativeCloseFocusOwner | null = null; +let pointerTransitionActive = false; + +function ownerForElement(element: Element): NativeCloseFocusOwner | null { + if (!element.isConnected) return null; + + const terminalOwner = + element.closest("[data-terminal-owner]")?.dataset.terminalOwner; + if (terminalOwner === "drawer") return "drawer-terminal"; + if (terminalOwner === "right-panel") return "right-panel-terminal"; + if (element.closest(RIGHT_PANEL_SELECTOR)) return "right-panel"; + if (element.tagName.toLowerCase() === "webview" || element.closest("[data-preview-viewport]")) { + return "right-panel"; + } + return null; +} + +/** Records an in-document focus or pointer transition before native UI can obscure DOM focus. */ +export function recordNativeCloseFocus(target: EventTarget | null): void { + retainedOwner = target instanceof Element ? ownerForElement(target) : null; +} + +/** Records pointer intent before a non-focusable target can blur the previous owner to body. */ +export function recordNativeClosePointer(target: EventTarget | null): void { + pointerTransitionActive = true; + recordNativeCloseFocus(target); +} + +export function finishNativeClosePointer(): void { + pointerTransitionActive = false; +} + +/** + * Clears real in-document focus exits while preserving ownership when focus + * temporarily moves into native application UI such as the macOS menu bar. + */ +export function recordNativeCloseFocusOut( + relatedTarget: EventTarget | null, + documentHasFocus: boolean, +): void { + if (relatedTarget instanceof Element) { + retainedOwner = ownerForElement(relatedTarget); + return; + } + if (documentHasFocus && !pointerTransitionActive) retainedOwner = null; +} + +export function getNativeCloseFocusOwner(): NativeCloseFocusOwner | null { + const activeElement = document.activeElement; + if (activeElement instanceof Element && activeElement.isConnected) { + const liveOwner = ownerForElement(activeElement); + if (liveOwner !== null) { + retainedOwner = liveOwner; + return liveOwner; + } + if (activeElement !== document.body) { + retainedOwner = null; + return null; + } + } + return retainedOwner; +} + +export function clearNativeCloseFocusOwner(owner?: NativeCloseFocusOwner): void { + if (owner !== undefined && retainedOwner !== owner) return; + retainedOwner = null; + pointerTransitionActive = false; +} diff --git a/apps/web/src/lib/rightPanelCloseRequest.test.ts b/apps/web/src/lib/rightPanelCloseRequest.test.ts new file mode 100644 index 00000000000..2001a43f8eb --- /dev/null +++ b/apps/web/src/lib/rightPanelCloseRequest.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, + requestCloseActiveRightPanelSurface, + resolveNativeCloseTarget, + resolveRightPanelCloseTarget, + shouldDeferCloseCommandToDesktopMenu, + shouldDeferCloseShortcutToDesktopMenu, +} from "./rightPanelCloseRequest"; + +const keyboardEvent = (overrides: Partial = {}) => + ({ + code: "KeyW", + key: "w", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, + }) as KeyboardEvent; + +describe("requestCloseActiveRightPanelSurface", () => { + it("reports when the active right-panel surface handles the request", () => { + const target = new EventTarget(); + target.addEventListener(CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, (event) => { + event.preventDefault(); + }); + + expect(requestCloseActiveRightPanelSurface(target)).toBe(true); + }); + + it("leaves an unhandled request available for window closing", () => { + expect(requestCloseActiveRightPanelSurface(new EventTarget())).toBe(false); + }); + + it("defers the native Cmd+W accelerator only in the macOS desktop app", () => { + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: true, + platform: "MacIntel", + event: keyboardEvent(), + }), + ).toBe(true); + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: false, + platform: "MacIntel", + event: keyboardEvent(), + }), + ).toBe(false); + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: true, + platform: "Win32", + event: keyboardEvent(), + }), + ).toBe(false); + }); + + it("keeps rebound macOS shortcuts in the renderer", () => { + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: true, + platform: "MacIntel", + event: keyboardEvent({ code: "KeyK", key: "k" }), + }), + ).toBe(false); + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: true, + platform: "MacIntel", + event: keyboardEvent({ shiftKey: true }), + }), + ).toBe(false); + }); + + it("defers Cmd+W when the physical key and layout character differ", () => { + expect( + shouldDeferCloseShortcutToDesktopMenu({ + desktop: true, + platform: "MacIntel", + event: keyboardEvent({ key: "z" }), + }), + ).toBe(true); + }); + + it("defers native Cmd+W for focused terminal close commands", () => { + expect( + shouldDeferCloseCommandToDesktopMenu({ + command: "terminal.close", + desktop: true, + event: keyboardEvent(), + platform: "MacIntel", + terminalFocused: true, + }), + ).toBe(true); + expect( + shouldDeferCloseCommandToDesktopMenu({ + command: "terminal.close", + desktop: true, + event: keyboardEvent(), + platform: "MacIntel", + terminalFocused: false, + }), + ).toBe(false); + }); +}); + +describe("resolveRightPanelCloseTarget", () => { + it("closes the active surface when the open panel has one", () => { + expect( + resolveRightPanelCloseTarget({ + isOpen: true, + hasActiveSurface: true, + hasFocusedTerminal: false, + }), + ).toBe("surface"); + }); + + it("closes the focused terminal pane before its containing surface", () => { + expect( + resolveRightPanelCloseTarget({ + isOpen: true, + hasActiveSurface: true, + hasFocusedTerminal: true, + }), + ).toBe("terminal"); + }); + + it("closes the panel itself when it is open without a surface", () => { + expect( + resolveRightPanelCloseTarget({ + isOpen: true, + hasActiveSurface: false, + hasFocusedTerminal: false, + }), + ).toBe("panel"); + }); + + it("does not intercept window close when the panel is hidden", () => { + expect( + resolveRightPanelCloseTarget({ + isOpen: false, + hasActiveSurface: true, + hasFocusedTerminal: false, + }), + ).toBeNull(); + }); +}); + +describe("resolveNativeCloseTarget", () => { + const defaults = { + focusOwner: null, + drawerTerminalOpen: true, + rightPanelOpen: true, + hasActiveRightPanelSurface: true, + activeRightPanelSurfaceIsTerminal: false, + } as const; + + it("does not intercept window close when focus is outside an open panel", () => { + expect(resolveNativeCloseTarget(defaults)).toBeNull(); + }); + + it("closes a focused drawer terminal before an open right panel", () => { + expect(resolveNativeCloseTarget({ ...defaults, focusOwner: "drawer-terminal" })).toBe( + "drawer-terminal", + ); + }); + + it("ignores stale drawer ownership after the drawer closes", () => { + expect( + resolveNativeCloseTarget({ + ...defaults, + focusOwner: "drawer-terminal", + drawerTerminalOpen: false, + }), + ).toBeNull(); + }); + + it("closes only the focused pane in a right-panel terminal group", () => { + expect( + resolveNativeCloseTarget({ + ...defaults, + focusOwner: "right-panel-terminal", + activeRightPanelSurfaceIsTerminal: true, + }), + ).toBe("right-panel-terminal"); + }); + + it("closes the active surface for other right-panel focus", () => { + expect(resolveNativeCloseTarget({ ...defaults, focusOwner: "right-panel" })).toBe( + "right-panel-surface", + ); + }); + + it("closes an empty focused panel without closing the window", () => { + expect( + resolveNativeCloseTarget({ + ...defaults, + focusOwner: "right-panel", + hasActiveRightPanelSurface: false, + }), + ).toBe("right-panel"); + }); + + it("does not intercept after the right panel closes", () => { + expect( + resolveNativeCloseTarget({ + ...defaults, + focusOwner: "right-panel", + rightPanelOpen: false, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/rightPanelCloseRequest.ts b/apps/web/src/lib/rightPanelCloseRequest.ts new file mode 100644 index 00000000000..f248604676d --- /dev/null +++ b/apps/web/src/lib/rightPanelCloseRequest.ts @@ -0,0 +1,96 @@ +import { isMacPlatform } from "./utils"; +import type { NativeCloseFocusOwner } from "./nativeCloseFocus"; + +export const CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT = "t3:close-active-right-panel-surface"; + +interface NativeCloseShortcutInput { + readonly desktop: boolean; + readonly platform: string; + readonly event: { + readonly code?: string; + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + }; +} + +export function shouldDeferCloseShortcutToDesktopMenu({ + desktop, + platform, + event, +}: NativeCloseShortcutInput): boolean { + return ( + desktop && + isMacPlatform(platform) && + (event.key.toLowerCase() === "w" || event.code === "KeyW") && + event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} + +export function shouldDeferCloseCommandToDesktopMenu({ + command, + terminalFocused, + ...shortcut +}: NativeCloseShortcutInput & { + readonly command: string; + readonly terminalFocused: boolean; +}): boolean { + const isNativeCloseCommand = + command === "rightPanel.closeActiveSurface" || + (command === "terminal.close" && terminalFocused); + return isNativeCloseCommand && shouldDeferCloseShortcutToDesktopMenu(shortcut); +} + +export function resolveRightPanelCloseTarget({ + isOpen, + hasActiveSurface, + hasFocusedTerminal, +}: { + readonly isOpen: boolean; + readonly hasActiveSurface: boolean; + readonly hasFocusedTerminal: boolean; +}): "terminal" | "surface" | "panel" | null { + if (!isOpen) return null; + if (hasFocusedTerminal) return "terminal"; + return hasActiveSurface ? "surface" : "panel"; +} + +export function resolveNativeCloseTarget({ + focusOwner, + drawerTerminalOpen, + rightPanelOpen, + hasActiveRightPanelSurface, + activeRightPanelSurfaceIsTerminal, +}: { + readonly focusOwner: NativeCloseFocusOwner | null; + readonly drawerTerminalOpen: boolean; + readonly rightPanelOpen: boolean; + readonly hasActiveRightPanelSurface: boolean; + readonly activeRightPanelSurfaceIsTerminal: boolean; +}): "drawer-terminal" | "right-panel-terminal" | "right-panel-surface" | "right-panel" | null { + if (focusOwner === "drawer-terminal") { + return drawerTerminalOpen ? "drawer-terminal" : null; + } + if (focusOwner !== "right-panel" && focusOwner !== "right-panel-terminal") return null; + if (!rightPanelOpen) return null; + if (!hasActiveRightPanelSurface) return "right-panel"; + if (focusOwner === "right-panel-terminal" && activeRightPanelSurfaceIsTerminal) { + return "right-panel-terminal"; + } + return "right-panel-surface"; +} + +export function requestCloseActiveRightPanelSurface( + target: Pick = window, +): boolean { + const event = new Event(CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, { + cancelable: true, + }); + target.dispatchEvent(event); + return event.defaultPrevented; +} diff --git a/apps/web/src/lib/rightPanelFocus.test.ts b/apps/web/src/lib/rightPanelFocus.test.ts new file mode 100644 index 00000000000..96733dcfcf9 --- /dev/null +++ b/apps/web/src/lib/rightPanelFocus.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { isRightPanelFocused } from "./rightPanelFocus"; + +class MockHTMLElement { + isConnected = false; + rightPanelRegion = false; + inPreviewViewport = false; + previewTabId: string | null = null; + tagName = "div"; + + closest(selector: string): MockHTMLElement | null { + if (!this.isConnected) return null; + if (selector === "[data-preview-viewport]" && this.inPreviewViewport) return this; + if ( + selector === + "[data-right-panel-tabbar], [data-right-panel-content], [data-right-panel-control]" && + this.rightPanelRegion + ) { + return this; + } + return null; + } + + getAttribute(name: string): string | null { + if (name === "data-preview-tab" || name === "data-preview-viewport") { + return this.previewTabId; + } + return null; + } +} + +const originalDocument = globalThis.document; +const originalHTMLElement = globalThis.HTMLElement; + +afterEach(() => { + if (originalDocument === undefined) { + delete (globalThis as { document?: Document }).document; + } else { + globalThis.document = originalDocument; + } + + if (originalHTMLElement === undefined) { + delete (globalThis as { HTMLElement?: typeof HTMLElement }).HTMLElement; + } else { + globalThis.HTMLElement = originalHTMLElement; + } +}); + +describe("isRightPanelFocused", () => { + it("recognizes focus in the right-panel tab bar or active surface", () => { + const activeElement = new MockHTMLElement(); + activeElement.isConnected = true; + activeElement.rightPanelRegion = true; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement } as unknown as Document; + + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: null })).toBe(true); + }); + + it("recognizes hosted preview focus while the right panel is open", () => { + const webview = new MockHTMLElement(); + webview.isConnected = true; + webview.tagName = "webview"; + webview.previewTabId = "preview-a"; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement: webview } as unknown as Document; + + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: "preview-a" })).toBe( + true, + ); + + const viewport = new MockHTMLElement(); + viewport.isConnected = true; + viewport.inPreviewViewport = true; + viewport.previewTabId = "preview-a"; + globalThis.document = { activeElement: viewport } as unknown as Document; + + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: "preview-a" })).toBe( + true, + ); + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: "preview-b" })).toBe( + false, + ); + }); + + it("ignores right-panel controls when the panel is closed", () => { + const activeElement = new MockHTMLElement(); + activeElement.isConnected = true; + activeElement.rightPanelRegion = true; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement } as unknown as Document; + + expect(isRightPanelFocused({ rightPanelOpen: false, activePreviewTabId: null })).toBe(false); + }); + + it("ignores detached elements and focus outside the right panel", () => { + const activeElement = new MockHTMLElement(); + activeElement.rightPanelRegion = true; + + globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; + globalThis.document = { activeElement } as unknown as Document; + + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: null })).toBe(false); + + activeElement.isConnected = true; + activeElement.rightPanelRegion = false; + expect(isRightPanelFocused({ rightPanelOpen: true, activePreviewTabId: null })).toBe(false); + }); +}); diff --git a/apps/web/src/lib/rightPanelFocus.ts b/apps/web/src/lib/rightPanelFocus.ts new file mode 100644 index 00000000000..a884860bca4 --- /dev/null +++ b/apps/web/src/lib/rightPanelFocus.ts @@ -0,0 +1,30 @@ +/** + * Returns true when keyboard focus belongs to the tabbed right-panel chrome + * or its active surface. This deliberately excludes other preview-panel + * shells so right-panel shortcuts do not affect standalone preview layouts. + */ +export function isRightPanelFocused({ + rightPanelOpen, + activePreviewTabId, +}: { + readonly rightPanelOpen: boolean; + readonly activePreviewTabId: string | null; +}): boolean { + if (!rightPanelOpen) return false; + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (!activeElement.isConnected) return false; + const previewViewport = activeElement.closest("[data-preview-viewport]"); + const focusedPreviewTabId = + activeElement.tagName.toLowerCase() === "webview" + ? activeElement.getAttribute("data-preview-tab") + : (previewViewport?.getAttribute("data-preview-viewport") ?? null); + if (focusedPreviewTabId !== null) { + return focusedPreviewTabId === activePreviewTabId; + } + return ( + activeElement.closest( + "[data-right-panel-tabbar], [data-right-panel-content], [data-right-panel-control]", + ) !== null + ); +} diff --git a/apps/web/src/lib/rightPanelTabPointer.ts b/apps/web/src/lib/rightPanelTabPointer.ts new file mode 100644 index 00000000000..fba8a3161ca --- /dev/null +++ b/apps/web/src/lib/rightPanelTabPointer.ts @@ -0,0 +1,18 @@ +import type { RightPanelSurface } from "~/rightPanelStore"; + +interface SurfaceTabAuxClickEvent { + readonly button: number; + preventDefault(): void; + stopPropagation(): void; +} + +export function handleSurfaceTabAuxClick( + event: SurfaceTabAuxClickEvent, + surface: RightPanelSurface, + onCloseSurface: (surface: RightPanelSurface) => void, +): void { + if (event.button !== 1) return; + event.preventDefault(); + event.stopPropagation(); + onCloseSurface(surface); +} diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..1ed8016aa91 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle"); + const parsedRightPanelClose = yield* decode(KeybindingRule, { + key: "mod+w", + command: "rightPanel.closeActiveSurface", + }); + assert.strictEqual(parsedRightPanelClose.command, "rightPanel.closeActiveSurface"); + const parsedClose = yield* decode(KeybindingRule, { key: "mod+w", command: "terminal.close", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..643dde5f968 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -55,6 +55,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "rightPanel.toggle", + "rightPanel.closeActiveSurface", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..289d6910d72 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -26,6 +26,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { + key: "mod+w", + command: "rightPanel.closeActiveSurface", + when: "rightPanelFocus && !terminalFocus", + }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+shift+j", command: "preview.toggle" }, { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, From 137c50175e200188ec22d6f44fa0577ea581c140 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 20 Jul 2026 16:54:49 +0200 Subject: [PATCH 2/3] Refresh right panel close controls for PR #4148 Keep native close focus scoped to the active hosted preview and preserve pointer ownership across webview blur transitions. Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 27 +++++----- apps/web/src/lib/nativeCloseFocus.test.ts | 63 +++++++++++++++-------- apps/web/src/lib/nativeCloseFocus.ts | 41 +++++++++++---- 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8d1b01bd6c9..84f15b0f70e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3173,6 +3173,10 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], ); + const activeRightPanelPreviewTabId = + activeRightPanelSurface?.kind === "preview" + ? (activeRightPanelSurface.resourceId ?? null) + : null; const closeActiveRightPanelTarget = useCallback( (terminalFocusOwner = getTerminalFocusOwner()) => { const target = resolveRightPanelCloseTarget({ @@ -3201,7 +3205,7 @@ function ChatViewContent(props: ChatViewProps) { ); const handleCloseRequest = useEffectEvent((event: Event) => { const target = resolveNativeCloseTarget({ - focusOwner: getNativeCloseFocusOwner(), + focusOwner: getNativeCloseFocusOwner(activeRightPanelPreviewTabId), drawerTerminalOpen: terminalUiState.terminalOpen, rightPanelOpen, hasActiveRightPanelSurface: activeRightPanelSurface !== null, @@ -3231,32 +3235,34 @@ function ChatViewContent(props: ChatViewProps) { }, []); useEffect(() => { const recordFocus = (event: FocusEvent | PointerEvent) => { - recordNativeCloseFocus(event.target); + recordNativeCloseFocus(event.target, activeRightPanelPreviewTabId); }; const recordFocusOut = (event: FocusEvent) => { - recordNativeCloseFocusOut(event.relatedTarget, document.hasFocus()); + recordNativeCloseFocusOut( + event.relatedTarget, + document.hasFocus(), + activeRightPanelPreviewTabId, + ); }; const recordPointer = (event: PointerEvent) => { - recordNativeClosePointer(event.target); + recordNativeClosePointer(event.target, activeRightPanelPreviewTabId); }; - recordNativeCloseFocus(document.activeElement); + recordNativeCloseFocus(document.activeElement, activeRightPanelPreviewTabId); window.addEventListener("focusin", recordFocus, true); window.addEventListener("focusout", recordFocusOut, true); window.addEventListener("pointerdown", recordPointer, true); window.addEventListener("pointerup", finishNativeClosePointer, true); window.addEventListener("pointercancel", finishNativeClosePointer, true); - window.addEventListener("blur", finishNativeClosePointer); return () => { window.removeEventListener("focusin", recordFocus, true); window.removeEventListener("focusout", recordFocusOut, true); window.removeEventListener("pointerdown", recordPointer, true); window.removeEventListener("pointerup", finishNativeClosePointer, true); window.removeEventListener("pointercancel", finishNativeClosePointer, true); - window.removeEventListener("blur", finishNativeClosePointer); clearNativeCloseFocusOwner(); }; - }, [routeThreadKey]); + }, [activeRightPanelPreviewTabId, routeThreadKey]); useEffect(() => { if (!terminalUiState.terminalOpen) clearNativeCloseFocusOwner("drawer-terminal"); if (!rightPanelOpen) { @@ -3952,10 +3958,7 @@ function ChatViewContent(props: ChatViewProps) { modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, rightPanelFocus: isRightPanelFocused({ rightPanelOpen, - activePreviewTabId: - activeRightPanelSurface?.kind === "preview" - ? (activeRightPanelSurface.resourceId ?? null) - : null, + activePreviewTabId: activeRightPanelPreviewTabId, }), }; diff --git a/apps/web/src/lib/nativeCloseFocus.test.ts b/apps/web/src/lib/nativeCloseFocus.test.ts index 1f45ed89753..24a4774e140 100644 --- a/apps/web/src/lib/nativeCloseFocus.test.ts +++ b/apps/web/src/lib/nativeCloseFocus.test.ts @@ -14,6 +14,7 @@ class MockElement extends EventTarget { owner: "drawer" | "right-panel" | null = null; inRightPanel = false; inPreviewViewport = false; + previewTabId: string | null = null; tagName = "div"; closest(selector: string): MockElement | null { @@ -27,6 +28,13 @@ class MockElement extends EventTarget { get dataset(): DOMStringMap { return { terminalOwner: this.owner ?? undefined } as DOMStringMap; } + + getAttribute(name: string): string | null { + if (name === "data-preview-tab" || name === "data-preview-viewport") { + return this.previewTabId; + } + return null; + } } const originalDocument = globalThis.document; @@ -56,17 +64,17 @@ describe("native close focus", () => { const drawer = new MockElement(); drawer.owner = "drawer"; installDom(drawer); - expect(getNativeCloseFocusOwner()).toBe("drawer-terminal"); + expect(getNativeCloseFocusOwner(null)).toBe("drawer-terminal"); const panelTerminal = new MockElement(); panelTerminal.owner = "right-panel"; installDom(panelTerminal); - expect(getNativeCloseFocusOwner()).toBe("right-panel-terminal"); + expect(getNativeCloseFocusOwner(null)).toBe("right-panel-terminal"); const panelControl = new MockElement(); panelControl.inRightPanel = true; installDom(panelControl); - expect(getNativeCloseFocusOwner()).toBe("right-panel"); + expect(getNativeCloseFocusOwner(null)).toBe("right-panel"); }); it("retains ownership only while focus is obscured by native UI", () => { @@ -74,13 +82,13 @@ describe("native close focus", () => { terminal.owner = "drawer"; const body = new MockElement(); installDom(body, body); - recordNativeCloseFocus(terminal); - recordNativeCloseFocusOut(null, false); + recordNativeCloseFocus(terminal, null); + recordNativeCloseFocusOut(null, false, null); - expect(getNativeCloseFocusOwner()).toBe("drawer-terminal"); + expect(getNativeCloseFocusOwner(null)).toBe("drawer-terminal"); - recordNativeCloseFocusOut(null, true); - expect(getNativeCloseFocusOwner()).toBeNull(); + recordNativeCloseFocusOut(null, true, null); + expect(getNativeCloseFocusOwner(null)).toBeNull(); }); it("preserves pointer ownership until the pointer gesture finishes", async () => { @@ -89,12 +97,12 @@ describe("native close focus", () => { const body = new MockElement(); installDom(body, body); - recordNativeClosePointer(panelChrome); + recordNativeClosePointer(panelChrome, null); await Promise.resolve(); - recordNativeCloseFocusOut(null, true); + recordNativeCloseFocusOut(null, true, null); finishNativeClosePointer(); - expect(getNativeCloseFocusOwner()).toBe("right-panel"); + expect(getNativeCloseFocusOwner(null)).toBe("right-panel"); }); it("clears retained ownership on focus or pointer movement outside the panel", () => { @@ -102,10 +110,10 @@ describe("native close focus", () => { panel.inRightPanel = true; const outside = new MockElement(); installDom(outside); - recordNativeCloseFocus(panel); - recordNativeCloseFocus(outside); + recordNativeCloseFocus(panel, null); + recordNativeCloseFocus(outside, null); - expect(getNativeCloseFocusOwner()).toBeNull(); + expect(getNativeCloseFocusOwner(null)).toBeNull(); }); it("updates ownership directly from a focusout related target", () => { @@ -114,22 +122,35 @@ describe("native close focus", () => { const panel = new MockElement(); panel.inRightPanel = true; installDom(panel); - recordNativeCloseFocus(drawer); - recordNativeCloseFocusOut(panel, true); + recordNativeCloseFocus(drawer, null); + recordNativeCloseFocusOut(panel, true, null); - expect(getNativeCloseFocusOwner()).toBe("right-panel"); + expect(getNativeCloseFocusOwner(null)).toBe("right-panel"); }); it("treats hosted preview webviews and viewport chrome as right-panel focus", () => { const webview = new MockElement(); webview.tagName = "webview"; + webview.previewTabId = "active-preview"; installDom(webview); - expect(getNativeCloseFocusOwner()).toBe("right-panel"); + expect(getNativeCloseFocusOwner("active-preview")).toBe("right-panel"); const viewport = new MockElement(); viewport.inPreviewViewport = true; + viewport.previewTabId = "active-preview"; installDom(viewport); - expect(getNativeCloseFocusOwner()).toBe("right-panel"); + expect(getNativeCloseFocusOwner("active-preview")).toBe("right-panel"); + }); + + it("ignores previews that are not the active right-panel preview", () => { + const webview = new MockElement(); + webview.tagName = "webview"; + webview.previewTabId = "standalone-preview"; + installDom(webview); + + recordNativeClosePointer(webview, "right-panel-preview"); + + expect(getNativeCloseFocusOwner("right-panel-preview")).toBeNull(); }); it("ignores detached retained targets", () => { @@ -138,8 +159,8 @@ describe("native close focus", () => { terminal.isConnected = false; const body = new MockElement(); installDom(body, body); - recordNativeCloseFocus(terminal); + recordNativeCloseFocus(terminal, null); - expect(getNativeCloseFocusOwner()).toBeNull(); + expect(getNativeCloseFocusOwner(null)).toBeNull(); }); }); diff --git a/apps/web/src/lib/nativeCloseFocus.ts b/apps/web/src/lib/nativeCloseFocus.ts index 77c360bb658..482e4cb5d8c 100644 --- a/apps/web/src/lib/nativeCloseFocus.ts +++ b/apps/web/src/lib/nativeCloseFocus.ts @@ -6,29 +6,45 @@ const RIGHT_PANEL_SELECTOR = let retainedOwner: NativeCloseFocusOwner | null = null; let pointerTransitionActive = false; -function ownerForElement(element: Element): NativeCloseFocusOwner | null { +function ownerForElement( + element: Element, + activePreviewTabId: string | null, +): NativeCloseFocusOwner | null { if (!element.isConnected) return null; const terminalOwner = element.closest("[data-terminal-owner]")?.dataset.terminalOwner; if (terminalOwner === "drawer") return "drawer-terminal"; if (terminalOwner === "right-panel") return "right-panel-terminal"; - if (element.closest(RIGHT_PANEL_SELECTOR)) return "right-panel"; - if (element.tagName.toLowerCase() === "webview" || element.closest("[data-preview-viewport]")) { - return "right-panel"; + + const previewViewport = element.closest("[data-preview-viewport]"); + const focusedPreviewTabId = + element.tagName.toLowerCase() === "webview" + ? element.getAttribute("data-preview-tab") + : (previewViewport?.getAttribute("data-preview-viewport") ?? null); + if (focusedPreviewTabId !== null) { + return focusedPreviewTabId === activePreviewTabId ? "right-panel" : null; } + + if (element.closest(RIGHT_PANEL_SELECTOR)) return "right-panel"; return null; } /** Records an in-document focus or pointer transition before native UI can obscure DOM focus. */ -export function recordNativeCloseFocus(target: EventTarget | null): void { - retainedOwner = target instanceof Element ? ownerForElement(target) : null; +export function recordNativeCloseFocus( + target: EventTarget | null, + activePreviewTabId: string | null, +): void { + retainedOwner = target instanceof Element ? ownerForElement(target, activePreviewTabId) : null; } /** Records pointer intent before a non-focusable target can blur the previous owner to body. */ -export function recordNativeClosePointer(target: EventTarget | null): void { +export function recordNativeClosePointer( + target: EventTarget | null, + activePreviewTabId: string | null, +): void { pointerTransitionActive = true; - recordNativeCloseFocus(target); + recordNativeCloseFocus(target, activePreviewTabId); } export function finishNativeClosePointer(): void { @@ -42,18 +58,21 @@ export function finishNativeClosePointer(): void { export function recordNativeCloseFocusOut( relatedTarget: EventTarget | null, documentHasFocus: boolean, + activePreviewTabId: string | null, ): void { if (relatedTarget instanceof Element) { - retainedOwner = ownerForElement(relatedTarget); + retainedOwner = ownerForElement(relatedTarget, activePreviewTabId); return; } if (documentHasFocus && !pointerTransitionActive) retainedOwner = null; } -export function getNativeCloseFocusOwner(): NativeCloseFocusOwner | null { +export function getNativeCloseFocusOwner( + activePreviewTabId: string | null, +): NativeCloseFocusOwner | null { const activeElement = document.activeElement; if (activeElement instanceof Element && activeElement.isConnected) { - const liveOwner = ownerForElement(activeElement); + const liveOwner = ownerForElement(activeElement, activePreviewTabId); if (liveOwner !== null) { retainedOwner = liveOwner; return liveOwner; From e1ba7ac8791f393b5e18dbc2d1fdad542b2e4c7e Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:46:37 +0800 Subject: [PATCH 3/3] Fix right panel close routing edge cases --- apps/desktop/src/preview/Manager.test.ts | 174 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 129 ++++++++++--- apps/desktop/src/window/DesktopWindow.test.ts | 28 ++- apps/desktop/src/window/DesktopWindow.ts | 18 +- apps/web/src/components/ChatView.tsx | 20 +- .../src/lib/rightPanelCloseRequest.test.ts | 14 ++ apps/web/src/lib/rightPanelCloseRequest.ts | 11 ++ apps/web/src/lib/terminalFocus.test.ts | 22 ++- apps/web/src/lib/terminalFocus.ts | 12 ++ 9 files changed, 384 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6e2df118c34..4761de31e4f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -96,6 +96,180 @@ const layer = PreviewManager.layer.pipe( ); const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError); +describe("resolvePreviewShortcutForwarding", () => { + it("routes macOS Cmd+W through the native close request instead of reinjecting it", () => { + expect( + PreviewManager.resolvePreviewShortcutForwarding("darwin", { + type: "keyDown", + key: "w", + meta: true, + control: false, + shift: false, + alt: false, + code: "KeyW", + }), + ).toBe("native-close"); + }); + + it("recognizes physical Cmd+W when the logical key differs", () => { + expect( + PreviewManager.resolvePreviewShortcutForwarding("darwin", { + type: "keyDown", + key: "z", + code: "KeyW", + meta: true, + control: false, + shift: false, + alt: false, + }), + ).toBe("native-close"); + }); + + it("forwards platform mod shortcuts to the renderer on every desktop platform", () => { + expect( + PreviewManager.resolvePreviewShortcutForwarding("darwin", { + type: "keyDown", + key: "k", + meta: true, + control: false, + shift: false, + alt: false, + }), + ).toBe("renderer"); + expect( + PreviewManager.resolvePreviewShortcutForwarding("linux", { + type: "keyDown", + key: "w", + meta: false, + control: true, + shift: false, + alt: false, + }), + ).toBe("renderer"); + expect( + PreviewManager.resolvePreviewShortcutForwarding("win32", { + type: "keyDown", + key: "w", + meta: false, + control: true, + shift: false, + alt: false, + }), + ).toBe("renderer"); + }); + + it("ignores wrong-modifier, modified, and key-up events", () => { + expect( + PreviewManager.resolvePreviewShortcutForwarding("linux", { + type: "keyDown", + key: "w", + meta: true, + control: false, + shift: false, + alt: false, + }), + ).toBeNull(); + expect( + PreviewManager.resolvePreviewShortcutForwarding("darwin", { + type: "keyDown", + key: "w", + meta: true, + control: false, + shift: false, + alt: true, + }), + ).toBeNull(); + expect( + PreviewManager.resolvePreviewShortcutForwarding("darwin", { + type: "keyUp", + key: "w", + meta: true, + control: false, + shift: false, + alt: false, + }), + ).toBeNull(); + }); +}); + +describe("forwardPreviewShortcut", () => { + const macCloseInput = { + type: "keyDown", + key: "w", + code: "KeyW", + meta: true, + control: false, + shift: false, + alt: false, + }; + + it("prevents and dispatches macOS preview close without synthetic reinjection", () => { + const preventDefault = vi.fn(); + const dispatchCloseAction = vi.fn(); + const closeWindow = vi.fn(); + const sendInputEvent = vi.fn(); + + expect( + PreviewManager.forwardPreviewShortcut( + { platform: "darwin", input: macCloseInput, rendererReady: true }, + { preventDefault, dispatchCloseAction, closeWindow, sendInputEvent }, + ), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(dispatchCloseAction).toHaveBeenCalledOnce(); + expect(closeWindow).not.toHaveBeenCalled(); + expect(sendInputEvent).not.toHaveBeenCalled(); + }); + + it("falls back to native window close when the renderer is not ready", () => { + const preventDefault = vi.fn(); + const dispatchCloseAction = vi.fn(); + const closeWindow = vi.fn(); + const sendInputEvent = vi.fn(); + + expect( + PreviewManager.forwardPreviewShortcut( + { platform: "darwin", input: macCloseInput, rendererReady: false }, + { preventDefault, dispatchCloseAction, closeWindow, sendInputEvent }, + ), + ).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(dispatchCloseAction).not.toHaveBeenCalled(); + expect(closeWindow).toHaveBeenCalledOnce(); + expect(sendInputEvent).not.toHaveBeenCalled(); + }); + + it("reinjects a physical Ctrl+W as the close key on non-QWERTY layouts", () => { + const sendInputEvent = vi.fn(); + + expect( + PreviewManager.forwardPreviewShortcut( + { + platform: "linux", + input: { + ...macCloseInput, + key: "z", + meta: false, + control: true, + }, + rendererReady: true, + }, + { + preventDefault: vi.fn(), + dispatchCloseAction: vi.fn(), + closeWindow: vi.fn(), + sendInputEvent, + }, + ), + ).toBe(true); + expect(sendInputEvent).toHaveBeenCalledWith({ + type: "keyDown", + keyCode: "w", + modifiers: ["control"], + }); + }); +}); + const withManager = ( use: ( manager: PreviewManager.PreviewManager["Service"], diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d0360ef72c..91278d841a6 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -324,20 +325,96 @@ interface ExpectedAgentInput { const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; - meta: boolean; shift: boolean; - control: boolean; }> = Object.freeze([ // mod+shift+J → preview.toggle - { key: "j", meta: true, shift: true, control: false }, + { key: "j", shift: true }, // mod+K → command palette - { key: "k", meta: true, shift: false, control: false }, - // mod+, → settings (macOS convention) - { key: ",", meta: true, shift: false, control: false }, + { key: "k", shift: false }, + // mod+, → settings + { key: ",", shift: false }, // mod+W → close tab/panel - { key: "w", meta: true, shift: false, control: false }, + { key: "w", shift: false }, ]); +interface PreviewShortcutInput { + readonly type: string; + readonly key: string; + readonly code?: string; + readonly meta: boolean; + readonly control: boolean; + readonly shift: boolean; + readonly alt: boolean; +} + +export function resolvePreviewShortcutForwarding( + platform: NodeJS.Platform, + input: PreviewShortcutInput, +): "native-close" | "renderer" | null { + if (input.type !== "keyDown" || input.alt) return null; + const platformModifierMatches = + platform === "darwin" ? input.meta && !input.control : input.control && !input.meta; + if (!platformModifierMatches) return null; + const isCloseShortcut = input.key.toLowerCase() === "w" || input.code === "KeyW"; + const shortcutMatches = APP_FORWARDED_SHORTCUTS.some( + (shortcut) => + (shortcut.key.toLowerCase() === input.key.toLowerCase() || + (shortcut.key === "w" && isCloseShortcut)) && + shortcut.shift === input.shift, + ); + if (!shortcutMatches) return null; + return platform === "darwin" && isCloseShortcut ? "native-close" : "renderer"; +} + +interface PreviewShortcutActions { + readonly preventDefault: () => void; + readonly dispatchCloseAction: () => void; + readonly closeWindow: () => void; + readonly sendInputEvent: (input: { + readonly type: "keyDown"; + readonly keyCode: string; + readonly modifiers: ReadonlyArray<"meta" | "shift" | "control" | "alt">; + }) => void; +} + +export function forwardPreviewShortcut( + { + platform, + input, + rendererReady, + }: { + readonly platform: NodeJS.Platform; + readonly input: PreviewShortcutInput; + readonly rendererReady: boolean; + }, + actions: PreviewShortcutActions, +): boolean { + const forwarding = resolvePreviewShortcutForwarding(platform, input); + if (forwarding === null) return false; + + actions.preventDefault(); + if (forwarding === "native-close") { + if (rendererReady) { + actions.dispatchCloseAction(); + } else { + actions.closeWindow(); + } + return true; + } + + actions.sendInputEvent({ + type: "keyDown", + keyCode: input.code === "KeyW" ? "w" : input.key, + modifiers: [ + ...(input.meta ? (["meta"] as const) : []), + ...(input.shift ? (["shift"] as const) : []), + ...(input.control ? (["control"] as const) : []), + ...(input.alt ? (["alt"] as const) : []), + ], + }); + return true; +} + const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { @@ -1061,16 +1138,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); }); - const isAppShortcut = (input: Electron.Input): boolean => - input.type === "keyDown" && - APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, - ); - const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); const title = wc.getTitle(); @@ -1211,20 +1278,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function input: Electron.Input, ) { const mainWindow = yield* Ref.get(mainWindowRef); - if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + if (Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { return; } - event.preventDefault(); - mainWindow.value.webContents.sendInputEvent({ - type: "keyDown", - keyCode: input.key, - modifiers: [ - ...(input.meta ? (["meta"] as const) : []), - ...(input.shift ? (["shift"] as const) : []), - ...(input.control ? (["control"] as const) : []), - ...(input.alt ? (["alt"] as const) : []), - ], - }); + forwardPreviewShortcut( + { + platform: hostPlatform, + input, + rendererReady: !mainWindow.value.webContents.isLoadingMainFrame(), + }, + { + preventDefault: () => event.preventDefault(), + dispatchCloseAction: () => + mainWindow.value.webContents.send(MENU_ACTION_CHANNEL, "close-window-or-right-panel"), + closeWindow: () => mainWindow.value.close(), + sendInputEvent: (forwardedInput) => + mainWindow.value.webContents.sendInputEvent(forwardedInput), + }, + ); }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { runFork(forwardShortcut(event, input)); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c14735e0ec3..6c5918f41b0 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -58,13 +58,13 @@ const environmentInput = { runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -function makeFakeBrowserWindow() { +function makeFakeBrowserWindow({ loadingMainFrame = false } = {}) { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), - isLoadingMainFrame: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => loadingMainFrame), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); }), @@ -114,6 +114,7 @@ function makeFakeBrowserWindow() { loadURL: window.loadURL, maximize: window.maximize, openDevTools: webContents.openDevTools, + once: webContents.once, reload: webContents.reload, send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, @@ -1120,4 +1121,27 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(scenario.layer)); }), ); + + it.effect("does not claim a targeted close action while the renderer is still loading", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow({ loadingMainFrame: true }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make(Option.some(main.window)); + const layer = makeTestLayer({ + window: main.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + assert.isFalse( + yield* desktopWindow.dispatchMenuActionToMainWindow(main.window, "close-window"), + ); + assert.equal(main.send.mock.calls.length, 0); + assert.equal(main.once.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3d1f0cd88db..0d72e9bab71 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -291,20 +291,27 @@ export const make = Effect.gen(function* () { const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const sendMenuAction = Effect.fn("desktop.window.sendMenuAction")( - (targetWindow: Electron.BrowserWindow, action: string, reveal: boolean) => + ( + targetWindow: Electron.BrowserWindow, + action: string, + reveal: boolean, + queueWhileLoading = true, + ) => Effect.sync(() => { const send = () => { - if (targetWindow.isDestroyed()) return; + if (targetWindow.isDestroyed()) return false; targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); if (reveal) void runPromise(electronWindow.reveal(targetWindow)); + return true; }; if (targetWindow.webContents.isLoadingMainFrame()) { + if (!queueWhileLoading) return false; targetWindow.webContents.once("did-finish-load", send); - return; + return true; } - send(); + return send(); }), ); @@ -800,8 +807,7 @@ export const make = Effect.gen(function* () { function* (window, action) { const mainWindow = yield* electronWindow.main; if (Option.isNone(mainWindow) || mainWindow.value !== window) return false; - yield* sendMenuAction(mainWindow.value, action, false); - return true; + return yield* sendMenuAction(mainWindow.value, action, false, false); }, ), syncAppearance: Effect.gen(function* () { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f15b0f70e..b9f610c24b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -155,7 +155,7 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { getTerminalFocusOwner, resolveTerminalKeyboardFocusOwner } from "../lib/terminalFocus"; import { clearNativeCloseFocusOwner, finishNativeClosePointer, @@ -167,6 +167,7 @@ import { import { isRightPanelFocused } from "../lib/rightPanelFocus"; import { CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, + hasRightPanelKeyboardFocus, resolveNativeCloseTarget, resolveRightPanelCloseTarget, shouldDeferCloseCommandToDesktopMenu, @@ -3948,7 +3949,11 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadId || isCommandPaletteOpen()) { return; } - const terminalFocusOwner = getTerminalFocusOwner(); + const retainedFocusOwner = getNativeCloseFocusOwner(activeRightPanelPreviewTabId); + const terminalFocusOwner = resolveTerminalKeyboardFocusOwner( + getTerminalFocusOwner(), + retainedFocusOwner, + ); if (event.defaultPrevented && terminalFocusOwner === null) { return; } @@ -3956,10 +3961,13 @@ function ChatViewContent(props: ChatViewProps) { terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - rightPanelFocus: isRightPanelFocused({ - rightPanelOpen, - activePreviewTabId: activeRightPanelPreviewTabId, - }), + rightPanelFocus: hasRightPanelKeyboardFocus( + isRightPanelFocused({ + rightPanelOpen, + activePreviewTabId: activeRightPanelPreviewTabId, + }), + retainedFocusOwner, + ), }; if ( diff --git a/apps/web/src/lib/rightPanelCloseRequest.test.ts b/apps/web/src/lib/rightPanelCloseRequest.test.ts index 2001a43f8eb..1ebbcd48a84 100644 --- a/apps/web/src/lib/rightPanelCloseRequest.test.ts +++ b/apps/web/src/lib/rightPanelCloseRequest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { CLOSE_ACTIVE_RIGHT_PANEL_SURFACE_EVENT, + hasRightPanelKeyboardFocus, requestCloseActiveRightPanelSurface, resolveNativeCloseTarget, resolveRightPanelCloseTarget, @@ -149,6 +150,19 @@ describe("resolveRightPanelCloseTarget", () => { }); }); +describe("hasRightPanelKeyboardFocus", () => { + it("uses retained panel ownership when live DOM focus falls back to the page body", () => { + expect(hasRightPanelKeyboardFocus(false, "right-panel")).toBe(true); + expect(hasRightPanelKeyboardFocus(false, "right-panel-terminal")).toBe(true); + }); + + it("does not treat drawer or missing ownership as right-panel focus", () => { + expect(hasRightPanelKeyboardFocus(false, "drawer-terminal")).toBe(false); + expect(hasRightPanelKeyboardFocus(false, null)).toBe(false); + expect(hasRightPanelKeyboardFocus(true, null)).toBe(true); + }); +}); + describe("resolveNativeCloseTarget", () => { const defaults = { focusOwner: null, diff --git a/apps/web/src/lib/rightPanelCloseRequest.ts b/apps/web/src/lib/rightPanelCloseRequest.ts index f248604676d..f5aac592df2 100644 --- a/apps/web/src/lib/rightPanelCloseRequest.ts +++ b/apps/web/src/lib/rightPanelCloseRequest.ts @@ -60,6 +60,17 @@ export function resolveRightPanelCloseTarget({ return hasActiveSurface ? "surface" : "panel"; } +export function hasRightPanelKeyboardFocus( + hasLiveRightPanelFocus: boolean, + retainedFocusOwner: NativeCloseFocusOwner | null, +): boolean { + return ( + hasLiveRightPanelFocus || + retainedFocusOwner === "right-panel" || + retainedFocusOwner === "right-panel-terminal" + ); +} + export function resolveNativeCloseTarget({ focusOwner, drawerTerminalOpen, diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts index 83f26637d4e..21b177e595e 100644 --- a/apps/web/src/lib/terminalFocus.test.ts +++ b/apps/web/src/lib/terminalFocus.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; -import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus"; +import { + getTerminalFocusOwner, + isTerminalFocused, + resolveTerminalKeyboardFocusOwner, +} from "./terminalFocus"; class MockHTMLElement { isConnected = false; @@ -79,3 +83,19 @@ describe("isTerminalFocused", () => { expect(isTerminalFocused()).toBe(true); }); }); + +describe("resolveTerminalKeyboardFocusOwner", () => { + it("prefers live terminal ownership", () => { + expect(resolveTerminalKeyboardFocusOwner("drawer", "right-panel-terminal")).toBe("drawer"); + }); + + it("restores terminal ownership retained across native focus transitions", () => { + expect(resolveTerminalKeyboardFocusOwner(null, "drawer-terminal")).toBe("drawer"); + expect(resolveTerminalKeyboardFocusOwner(null, "right-panel-terminal")).toBe("right-panel"); + }); + + it("does not treat non-terminal retained ownership as terminal focus", () => { + expect(resolveTerminalKeyboardFocusOwner(null, "right-panel")).toBeNull(); + expect(resolveTerminalKeyboardFocusOwner(null, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts index 158c7fb2c98..789c03b7ae8 100644 --- a/apps/web/src/lib/terminalFocus.ts +++ b/apps/web/src/lib/terminalFocus.ts @@ -1,3 +1,5 @@ +import type { NativeCloseFocusOwner } from "./nativeCloseFocus"; + export type TerminalFocusOwner = "drawer" | "right-panel"; export function getTerminalFocusOwner(): TerminalFocusOwner | null { @@ -12,3 +14,13 @@ export function getTerminalFocusOwner(): TerminalFocusOwner | null { export function isTerminalFocused(): boolean { return getTerminalFocusOwner() !== null; } + +export function resolveTerminalKeyboardFocusOwner( + liveOwner: TerminalFocusOwner | null, + retainedOwner: NativeCloseFocusOwner | null, +): TerminalFocusOwner | null { + if (liveOwner !== null) return liveOwner; + if (retainedOwner === "drawer-terminal") return "drawer"; + if (retainedOwner === "right-panel-terminal") return "right-panel"; + return null; +}