From a9e4bcbf74575a643f5543d7adfb4b03fd74aea2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 17:23:12 +0800 Subject: [PATCH 1/4] fix(shortcuts): preserve independently owned surface handlers --- .../src/infrastructure/hooks/useShortcut.ts | 12 ++++- .../services/ShortcutManager.test.ts | 44 +++++++++++++++++++ .../services/ShortcutManager.ts | 35 ++++++++++++--- src/web-ui/src/shared/types/shortcut.ts | 2 + 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/infrastructure/hooks/useShortcut.ts b/src/web-ui/src/infrastructure/hooks/useShortcut.ts index 087108e018..6abbad7ae6 100644 --- a/src/web-ui/src/infrastructure/hooks/useShortcut.ts +++ b/src/web-ui/src/infrastructure/hooks/useShortcut.ts @@ -8,6 +8,8 @@ export interface UseShortcutOptions { priority?: number; /** Set to false to temporarily disable this shortcut without unregistering. */ enabled?: boolean; + /** Restrict this mounted handler to the event's owning surface. */ + when?: (event: KeyboardEvent) => boolean; } /** @@ -29,6 +31,11 @@ export function useShortcut( options: UseShortcutOptions = {} ): void { const { description, priority, enabled = true } = options; + const hasOwnerPredicate = Boolean(options.when); + const owner = useRef(Symbol(id)); + const whenRef = useRef(options.when); + whenRef.current = options.when; + const stableWhen = useRef((event: KeyboardEvent) => whenRef.current?.(event) ?? true); // Keep the latest callback in a ref so changes don't trigger re-registration. const callbackRef = useRef(callback); @@ -46,6 +53,7 @@ export function useShortcut( meta: config.meta, scope: config.scope, allowInInput: config.allowInInput, + inheritInChildScopes: config.inheritInChildScopes, }); useEffect(() => { @@ -54,9 +62,11 @@ export function useShortcut( const unregister = shortcutManager.register(id, config, stableCallback.current, { description, priority, + owner: hasOwnerPredicate ? owner.current : undefined, + when: stableWhen.current, }); return unregister; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, configKey, enabled, description, priority]); + }, [id, configKey, enabled, description, priority, hasOwnerPredicate]); } diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts index fc6162704b..bd1a989b99 100644 --- a/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts +++ b/src/web-ui/src/infrastructure/services/ShortcutManager.test.ts @@ -101,6 +101,50 @@ describe('ShortcutManager platform primary modifier', () => { expect(side).not.toHaveBeenCalled(); }); + it('inherits only opted-in commands through terminal scope and preserves the terminal event otherwise', () => { + setPlatform('Win32'); + const close = vi.fn(), escape = vi.fn(), main = vi.fn(); + shortcutManager.register('tab.close', { key: 'w', ctrl: true, scope: 'app', allowInInput: true }, main); + shortcutManager.register('tab.close', { key: 'w', ctrl: true, scope: 'canvas', allowInInput: true, inheritInChildScopes: true }, close); + shortcutManager.register('canvas.escape', { key: 'Escape', scope: 'canvas', allowInInput: true }, escape); + const canvas = document.createElement('div'); + canvas.dataset.shortcutScope = 'canvas'; + const terminal = document.createElement('textarea'); + terminal.dataset.shortcutScope = 'terminal'; + canvas.append(terminal); + document.body.append(canvas); + terminal.dispatchEvent(new KeyboardEvent('keydown', { key: 'w', ctrlKey: true, bubbles: true, cancelable: true })); + const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + terminal.dispatchEvent(escapeEvent); + expect(close).toHaveBeenCalledOnce(); + expect(main).not.toHaveBeenCalled(); + expect(escape).not.toHaveBeenCalled(); + expect(escapeEvent.defaultPrevented).toBe(false); + }); + + it('keeps separately owned handlers through override changes and another owner unmounting', () => { + setPlatform('Win32'); + const right = document.createElement('div'), bottom = document.createElement('div'); + right.dataset.shortcutScope = bottom.dataset.shortcutScope = 'canvas'; + document.body.append(right, bottom); + const onRight = vi.fn(), onBottom = vi.fn(); + const config = { key: 'w', ctrl: true, scope: 'canvas' as const }; + shortcutManager.register('tab.close', config, onRight, { owner: Symbol(), when: event => right.contains(event.target as Node) }); + const removeBottom = shortcutManager.register('tab.close', config, onBottom, { owner: Symbol(), when: event => bottom.contains(event.target as Node) }); + shortcutManager.loadUserOverrides({ 'tab.close': { key: 'q', alt: true } }); + const press = (element: HTMLElement) => element.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', altKey: true, bubbles: true, cancelable: true })); + press(right); press(bottom); + expect(onRight).toHaveBeenCalledOnce(); + expect(onBottom).toHaveBeenCalledOnce(); + removeBottom(); + press(right); press(bottom); + expect(onRight).toHaveBeenCalledTimes(2); + expect(onBottom).toHaveBeenCalledOnce(); + expect(shortcutManager.unregister('tab.close')).toBe(true); + press(right); + expect(onRight).toHaveBeenCalledTimes(2); + }); + afterEach(() => { shortcutManager.clear(); vi.restoreAllMocks(); diff --git a/src/web-ui/src/infrastructure/services/ShortcutManager.ts b/src/web-ui/src/infrastructure/services/ShortcutManager.ts index fb906196f2..ea05054fd1 100644 --- a/src/web-ui/src/infrastructure/services/ShortcutManager.ts +++ b/src/web-ui/src/infrastructure/services/ShortcutManager.ts @@ -15,6 +15,16 @@ export interface ShortcutRegistration { callback: ShortcutCallback; description?: string; priority: number; + /** Restrict a registration to its owning surface before choosing a winner. */ + when?: (event: KeyboardEvent) => boolean; +} + +export interface ShortcutRegistrationOptions { + description?: string; + priority?: number; + /** Distinguish mounted instances of the same command without changing its settings ID. */ + owner?: symbol; + when?: (event: KeyboardEvent) => boolean; } /** @@ -159,9 +169,9 @@ export class ShortcutManager { private static instance: ShortcutManager; /** - * All registrations, keyed by shortcut id. + * Registrations keyed by scope/command, or by an explicitly mounted owner. */ - private registrations: Map = new Map(); + private registrations: Map = new Map(); /** * O(1) lookup index: mapKey → sorted registrations (descending priority). @@ -212,12 +222,12 @@ export class ShortcutManager { id: string, config: ShortcutConfig, callback: ShortcutCallback, - options?: { description?: string; priority?: number } + options?: ShortcutRegistrationOptions ): () => void { // Apply user override if present const effectiveConfig = this.applyOverride(id, config); - const registrationKey = `${config.scope ?? 'app'}:${id}`; + const registrationKey = options?.owner ?? `${config.scope ?? 'app'}:${id}`; const existing = this.registrations.get(registrationKey); if (existing) { this.removeFromLookupMap(existing); @@ -230,6 +240,7 @@ export class ShortcutManager { callback, description: options?.description, priority: options?.priority ?? 0, + when: options?.when, }; this.registrations.set(registrationKey, registration); @@ -319,6 +330,7 @@ export class ShortcutManager { const appList = this.lookupMap.get(appKey) ?? []; for (const reg of appList) { if (inInput && !reg.config.allowInInput) continue; + if (reg.when && !reg.when(event)) continue; results.push(reg); } @@ -331,12 +343,25 @@ export class ShortcutManager { ? 'workbench-content' : 'canvas'; const panelScopes: ShortcutScope[] = activeScope === 'editor' ? ['editor', editorOwner] : [activeScope]; + // Only opted-in commands cross a nested panel boundary. In particular, + // closing a terminal tab belongs to its canvas, while terminal Escape + // and other terminal commands retain their own scope. + const ancestorScopes = new Set(panelScopes); + let ancestor = event.target instanceof Element + ? event.target.closest('[data-shortcut-scope]') : null; + while (ancestor) { + const scope = this.detectScope(ancestor); + if (scope !== 'app') ancestorScopes.add(scope); + ancestor = ancestor.parentElement?.closest('[data-shortcut-scope]') ?? null; + } const panelRegs: ShortcutRegistration[] = []; - for (const scope of panelScopes) { + for (const scope of ancestorScopes) { for (const reg of this.lookupMap.get(makeEventKey(scope, event)) ?? []) { + if (!panelScopes.includes(scope) && !reg.config.inheritInChildScopes) continue; if (inInput && !reg.config.allowInInput) { continue; } + if (reg.when && !reg.when(event)) continue; panelRegs.push(reg); } } diff --git a/src/web-ui/src/shared/types/shortcut.ts b/src/web-ui/src/shared/types/shortcut.ts index d686ca9382..965475a1f6 100644 --- a/src/web-ui/src/shared/types/shortcut.ts +++ b/src/web-ui/src/shared/types/shortcut.ts @@ -33,4 +33,6 @@ export interface ShortcutConfig { * element is focused. Defaults to false. */ allowInInput?: boolean; + /** Also handle nested panel scopes, such as a terminal inside this canvas. */ + inheritInChildScopes?: boolean; } From a382891b4c3ad41369540563a6353c3aed7b796f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 17:23:13 +0800 Subject: [PATCH 2/4] fix(web-ui): close tabs in the focused workbench region --- src/web-ui/AGENTS.md | 10 + src/web-ui/README.md | 12 + src/web-ui/README.zh-CN.md | 10 + .../hooks/useKeyboardShortcuts.ts | 52 ++- .../src/app/hooks/useGlobalSceneShortcuts.ts | 14 + .../app/hooks/useTabCloseShortcut.test.tsx | 312 ++++++++++++++++++ .../src/app/hooks/useTabCloseShortcut.ts | 34 ++ .../src/app/workbench/ContentResourceView.tsx | 4 +- .../src/app/workbench/WorkbenchLayout.tsx | 2 +- src/web-ui/src/shared/constants/shortcuts.ts | 10 +- 10 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 src/web-ui/src/app/hooks/useTabCloseShortcut.test.tsx create mode 100644 src/web-ui/src/app/hooks/useTabCloseShortcut.ts diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 7e85877ab3..4c114c80db 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -243,6 +243,16 @@ pnpm --dir src/web-ui run test:run src/app/components/SceneBar/SceneBar.test.tsx These local DOM/store fixtures cover the session tab limit, other-device tabs and simulated SSH/Peer identities; they do not establish native rendering or live remote behavior. +For Ctrl/Cmd+W ownership across conversation, content, split groups and terminals: + +```bash +pnpm --dir src/web-ui run test:run src/app/hooks/useTabCloseShortcut.test.tsx src/infrastructure/services/ShortcutManager.test.ts src/app/components/panels/content-canvas/hooks/useTabLifecycle.btw.test.tsx +``` + +These DOM/store fixtures simulate Windows/Linux/macOS modifiers, concurrent +canvas owners, close guards and Peer switches. They do not establish native +keyboard delivery or live SSH, Remote Control, Peer Device or Detached Dispatch behavior. + For composer mode restoration, passive synchronization, or explicit mode selection: ```bash diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 1b5a1dc0f5..0463cc7616 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -9,6 +9,18 @@ This directory contains OpenBitFun’s **Web UI** (React + TypeScript). The same - **Desktop**: loaded via **Tauri** - **Server/Web**: built into static assets and served by the backend +## Closing tabs with the keyboard + +In Desktop, **Ctrl+W** on Windows/Linux or **Cmd+W** on macOS closes the current +tab in the region you are using. From the conversation area it closes the +conversation tab; from the file or terminal area it closes that area's current +tab. Split views use the focused group. An empty content pane does not close a +conversation in the other pane. + +The shortcut uses the same unsaved-change checks and terminal lifecycle as the +tab's close button. Existing custom bindings for **Close tab** continue to apply +to both regions. Dialogs and held-key repeats do not close additional tabs. + ## Creating a project The sidebar's add button opens two clickable cards without footer actions. diff --git a/src/web-ui/README.zh-CN.md b/src/web-ui/README.zh-CN.md index 6759ebc6ce..d01abbd2ea 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -9,6 +9,16 @@ - **Desktop**:通过 **Tauri** 加载运行 - **Server/Web**:构建为静态资源,由后端提供访问 +## 使用快捷键关闭标签 + +桌面端使用 **Ctrl+W**(Windows/Linux)或 **Cmd+W**(macOS)关闭当前操作区域的标签。 +在中间会话区域操作时关闭当前会话标签;在文件或终端区域操作时关闭该区域的当前标签。 +多个分栏按焦点所在分组响应,空的内容区域不会转而关闭另一侧的会话。 + +快捷键与标签关闭按钮共用未保存内容检查和终端生命周期处理。 +已有的“关闭标签”自定义快捷键对两个区域均生效。 +弹窗交互和长按按键产生的重复事件不会继续关闭其他标签。 + ## 创建项目 左侧导航的新增按钮展示两张可点击卡片,首层不设底部操作按钮。 diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts index 2bb97996ed..52eb002fdf 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts @@ -2,15 +2,17 @@ * useKeyboardShortcuts Hook * * Registers canvas-level keyboard shortcuts via ShortcutManager. - * All shortcuts use scope 'canvas' so they only fire when focus is inside - * the editor canvas area (data-shortcut-scope="canvas"). + * Canvas shortcuts follow DOM scope. Close also follows the workbench's focused + * pane when focus returns to the document body after a tab is removed. */ -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { CANVAS_OVERVIEW_SHORTCUT } from '@/shared/constants/shortcuts'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService'; -import { useCanvasStore } from '../stores'; +import { CanvasStoreModeContext, useCanvasStore } from '../stores'; +import { useTabCloseShortcut } from '@/app/hooks/useTabCloseShortcut'; +import { useSceneStore } from '@/app/stores/sceneStore'; import type { EditorGroupId } from '../types'; interface UseKeyboardShortcutsOptions { @@ -21,6 +23,7 @@ interface UseKeyboardShortcutsOptions { } export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) => { + const mode = useContext(CanvasStoreModeContext); const { enabled = true, missionControlEnabled = true, @@ -31,6 +34,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) const { primaryGroup, secondaryGroup, + tertiaryGroup, activeGroupId, layout, closeTab, @@ -44,8 +48,9 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) } = useCanvasStore(); const getActiveGroup = useCallback(() => { - return activeGroupId === 'primary' ? primaryGroup : secondaryGroup; - }, [activeGroupId, primaryGroup, secondaryGroup]); + return activeGroupId === 'primary' ? primaryGroup + : activeGroupId === 'secondary' ? secondaryGroup : tertiaryGroup; + }, [activeGroupId, primaryGroup, secondaryGroup, tertiaryGroup]); const getVisibleTabs = useCallback(() => { return getActiveGroup().tabs.filter((t) => !t.isHidden); @@ -106,19 +111,38 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) ); // Close current tab: mod+W - useShortcut( - 'tab.close', - { key: 'W', ctrl: true, scope: 'canvas', allowInInput: true }, - () => { - const activeGroup = getActiveGroup(); + useTabCloseShortcut( + 'app', + event => { + // Keyboard focus can enter a split group without its click handler running. + const groupElement = event.target instanceof Element + ? event.target.closest('[data-openbitfun-group], [data-canvas-toolbar-group]') : null; + const focusedGroup = groupElement?.getAttribute('data-openbitfun-group') + ?? groupElement?.getAttribute('data-canvas-toolbar-group'); + const groupId = focusedGroup === 'primary' || focusedGroup === 'secondary' || focusedGroup === 'tertiary' + ? focusedGroup : activeGroupId; + const activeGroup = groupId === 'primary' ? primaryGroup + : groupId === 'secondary' ? secondaryGroup : tertiaryGroup; if (!activeGroup.activeTabId) return; if (handleCloseWithDirtyCheck) { - handleCloseWithDirtyCheck(activeGroup.activeTabId, activeGroupId); + return handleCloseWithDirtyCheck(activeGroup.activeTabId, groupId); } else { - closeTab(activeGroup.activeTabId, activeGroupId); + closeTab(activeGroup.activeTabId, groupId); } }, - { enabled, priority: 10, description: 'keyboard.shortcuts.tab.close' } + { + enabled, + priority: 20, + when: event => { + const target = event.target instanceof Element ? event.target : null; + const owner = target?.closest('[data-canvas-mode]')?.getAttribute('data-canvas-mode'); + if (owner) return owner === mode; + // Explicit editor/chat/terminal scope always wins over remembered pane focus. + if (target?.closest('[data-shortcut-scope], [data-openbitfun-component="scene-bar"]')) return false; + const scene = useSceneStore.getState(); + return mode === 'agent' && scene.paneSelections[scene.focusedPane].kind === 'canvas'; + }, + } ); // Reopen closed tab: mod+Shift+T diff --git a/src/web-ui/src/app/hooks/useGlobalSceneShortcuts.ts b/src/web-ui/src/app/hooks/useGlobalSceneShortcuts.ts index 9ed9ec1d66..23e5acc84c 100644 --- a/src/web-ui/src/app/hooks/useGlobalSceneShortcuts.ts +++ b/src/web-ui/src/app/hooks/useGlobalSceneShortcuts.ts @@ -14,6 +14,7 @@ import { useCallback } from 'react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; +import { useTabCloseShortcut } from './useTabCloseShortcut'; function activateSceneByStripIndex(index: number): void { const { openTabs, activateScene } = useSceneStore.getState(); @@ -28,6 +29,19 @@ function openSceneById(id: SceneTabId): void { export function useGlobalSceneShortcuts(): void { const byIndex = useCallback((i: number) => () => activateSceneByStripIndex(i), []); + useTabCloseShortcut('app', event => { + // A canvas owns its tabs, including nested file/terminal views. An empty + // or temporarily inactive canvas must never close the conversation instead. + if (event.target instanceof Element && event.target.closest('[data-shortcut-scope="canvas"]')) return; + const { activeTabId, openTabs, closeScene } = useSceneStore.getState(); + // Tabbing onto a scene label does not activate it. Its own close shortcut + // must still address that label, rather than the previously focused pane. + const focusedTabId = event.target instanceof Element + ? event.target.closest('[data-scene-tab-id]')?.getAttribute('data-scene-tab-id') : null; + const targetId = focusedTabId ? openTabs.find(tab => tab.id === focusedTabId)?.id : activeTabId; + if (targetId) return closeScene(targetId); + }); + // ── Scene-bar position shortcuts (Alt+1–3) ──────────────────────────── useShortcut('scene.focus1', { key: '1', alt: true, scope: 'app', allowInInput: true }, byIndex(0), { priority: 12, diff --git a/src/web-ui/src/app/hooks/useTabCloseShortcut.test.tsx b/src/web-ui/src/app/hooks/useTabCloseShortcut.test.tsx new file mode 100644 index 0000000000..d5e12e4c50 --- /dev/null +++ b/src/web-ui/src/app/hooks/useTabCloseShortcut.test.tsx @@ -0,0 +1,312 @@ +// @vitest-environment jsdom +import React, { act, createRef, useContext } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Portal } from '@openbitfun/ui'; +import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { useConfirmDialogStore } from '@/infrastructure/confirm-dialog/confirmDialogService'; +import { + cancelPendingSettingsNavigation, discardAndContinueSettingsNavigation, getSettingsDraftSnapshot, + registerSettingsDraft, resetSettingsDraftRegistryForTests, +} from '@/infrastructure/config/settingsDraftRegistry'; +import { resetSceneStoreForTest, useSceneStore } from '../stores/sceneStore'; +import { getSessionSceneTabId } from '../components/SceneBar/types'; +import { useGlobalSceneShortcuts } from './useGlobalSceneShortcuts'; +import { useKeyboardShortcuts } from '../components/panels/content-canvas/hooks/useKeyboardShortcuts'; +import { useTabLifecycle } from '../components/panels/content-canvas/hooks/useTabLifecycle'; +import { + CanvasStoreModeContext, clearAgentCanvasForPeerSwitch, useAgentCanvasStore, useBottomTerminalCanvasStore, +} from '../components/panels/content-canvas/stores'; +import type { EditorGroupId } from '../components/panels/content-canvas/types'; + +const host = vi.hoisted(() => ({ destroyTerminal: vi.fn() })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/services/destroyTerminalSession', () => ({ destroyTerminalSession: host.destroyTerminal })); +vi.mock('@/shared/services/workbenchContentService', () => ({ openCanvasContent: vi.fn(), openContentInBestTarget: vi.fn() })); +vi.mock('@/shared/services/pendingTabQueue', () => ({ drainPendingTabs: () => [] })); +vi.mock('@/flow_chat/services/BtwSessionLifecycle', () => ({ + discardBtwSession: vi.fn(), saveBtwSessionAsFork: vi.fn(), canSaveBtwSessionAsFork: () => false, +})); +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ flowChatStore: { getState: () => ({ sessions: new Map() }) } })); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const session = { surfaceId: 'local', workspaceKey: 'project', sessionId: 'conversation' }; +const sessionTabId = getSessionSceneTabId(session); + +function GlobalShortcuts() { useGlobalSceneShortcuts(); return null; } +function CanvasShortcuts({ enabled = true }: { enabled?: boolean }) { + const mode = useContext(CanvasStoreModeContext); + const { handleCloseWithDirtyCheck } = useTabLifecycle({ mode }); + useKeyboardShortcuts({ enabled, handleCloseWithDirtyCheck }); + return null; +} +function Fixture({ canvasEnabled = true, bottom = false }: { canvasEnabled?: boolean; bottom?: boolean }) { + return <> + +