diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 7e85877ab3..fb8b39f73c 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -243,6 +243,17 @@ 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 close/reopen, numbered tab selection and layout shortcuts 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..a44744cf6c 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -9,6 +9,21 @@ 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. +Numbered tab selection, reopening a closed tab and layout shortcuts also stay +with their owning canvas. Recording a new binding in Settings captures the key +without executing the existing command. + ## 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..dc2a3c6c29 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -9,6 +9,18 @@ - **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..2f085a9a45 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, @@ -43,12 +47,28 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) toggleMissionControl, } = useCanvasStore(); - const getActiveGroup = useCallback(() => { - return activeGroupId === 'primary' ? primaryGroup : secondaryGroup; - }, [activeGroupId, primaryGroup, secondaryGroup]); - - const getVisibleTabs = useCallback(() => { - return getActiveGroup().tabs.filter((t) => !t.isHidden); + const ownsCanvasEvent = useCallback((event: KeyboardEvent) => event.target instanceof Element + && event.target.closest('[data-canvas-mode]')?.getAttribute('data-canvas-mode') === mode, [mode]); + const canvasShortcutOptions = { enabled, when: ownsCanvasEvent }; + + const getFocusedGroupId = useCallback((event: KeyboardEvent): EditorGroupId => { + // 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'); + return focusedGroup === 'primary' || focusedGroup === 'secondary' || focusedGroup === 'tertiary' + ? focusedGroup : activeGroupId; + }, [activeGroupId]); + + const getActiveGroup = useCallback((event: KeyboardEvent) => { + const groupId = getFocusedGroupId(event); + return groupId === 'primary' ? primaryGroup + : groupId === 'secondary' ? secondaryGroup : tertiaryGroup; + }, [getFocusedGroupId, primaryGroup, secondaryGroup, tertiaryGroup]); + + const getVisibleTabs = useCallback((event: KeyboardEvent) => { + return getActiveGroup(event).tabs.filter((t) => !t.isHidden); }, [getActiveGroup]); // Find in file (Monaco) — only when `data-shortcut-scope="editor"` is innermost @@ -58,7 +78,13 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) () => { activeEditTargetService.openMonacoFind(); }, - { enabled, priority: 20, description: 'keyboard.shortcuts.editor.findInFile' } + { + enabled, priority: 20, description: 'keyboard.shortcuts.editor.findInFile', + // Main document editors share the active edit target service. Canvas + // instances must nevertheless survive another canvas unmounting. + when: event => event.target instanceof Element + && (!event.target.closest('[data-canvas-mode]') || ownsCanvasEvent(event)), + } ); // Mission control @@ -68,6 +94,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) () => toggleMissionControl(), { enabled: enabled && missionControlEnabled, + when: ownsCanvasEvent, priority: 10, description: 'keyboard.shortcuts.canvas.missionControl', } @@ -78,7 +105,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) 'canvas.splitHorizontal', { key: '\\', ctrl: true, scope: 'canvas' }, () => setSplitMode(layout.splitMode === 'horizontal' ? 'none' : 'horizontal'), - { enabled, description: 'keyboard.shortcuts.canvas.splitHorizontal' } + { ...canvasShortcutOptions, description: 'keyboard.shortcuts.canvas.splitHorizontal' } ); // Vertical split: mod+Shift+\ @@ -86,7 +113,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) 'canvas.splitVertical', { key: '\\', ctrl: true, shift: true, scope: 'canvas' }, () => setSplitMode(layout.splitMode === 'vertical' ? 'none' : 'vertical'), - { enabled, description: 'keyboard.shortcuts.canvas.splitVertical' } + { ...canvasShortcutOptions, description: 'keyboard.shortcuts.canvas.splitVertical' } ); // Anchor zone: mod+` @@ -94,7 +121,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) 'canvas.anchorZone', { key: '`', ctrl: true, scope: 'canvas' }, () => setAnchorPosition(layout.anchorPosition === 'hidden' ? 'bottom' : 'hidden'), - { enabled, description: 'keyboard.shortcuts.canvas.anchorZone' } + { ...canvasShortcutOptions, description: 'keyboard.shortcuts.canvas.anchorZone' } ); // Maximize: mod+Shift+M @@ -102,23 +129,35 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) 'canvas.maximize', { key: 'M', ctrl: true, shift: true, scope: 'canvas' }, () => toggleMaximize(), - { enabled, description: 'keyboard.shortcuts.canvas.maximize' } + { ...canvasShortcutOptions, description: 'keyboard.shortcuts.canvas.maximize' } ); // Close current tab: mod+W - useShortcut( - 'tab.close', - { key: 'W', ctrl: true, scope: 'canvas', allowInInput: true }, - () => { - const activeGroup = getActiveGroup(); + useTabCloseShortcut( + 'app', + event => { + const groupId = getFocusedGroupId(event); + const activeGroup = getActiveGroup(event); 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 @@ -130,29 +169,29 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) reopenClosedTab(); onReveal?.(); }, - { enabled, priority: 10, description: 'keyboard.shortcuts.tab.reopenClosed' } + { ...canvasShortcutOptions, priority: 10, description: 'keyboard.shortcuts.tab.reopenClosed' } ); // Switch to tab by number: mod+1~9 const switchToTabByIndex = useCallback( - (index: number) => { - const tabs = getVisibleTabs(); + (event: KeyboardEvent, index: number) => { + const tabs = getVisibleTabs(event); const target = index === -1 ? tabs[tabs.length - 1] : tabs[index]; - if (target) switchToTab(target.id, activeGroupId); + if (target) switchToTab(target.id, getFocusedGroupId(event)); }, - [getVisibleTabs, switchToTab, activeGroupId] + [getVisibleTabs, switchToTab, getFocusedGroupId] ); // allowInInput so Ctrl+1..9 still work while focus is in a Monaco editor - useShortcut('tab.switch1', { key: '1', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(0), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch2', { key: '2', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(1), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch3', { key: '3', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(2), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch4', { key: '4', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(3), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch5', { key: '5', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(4), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch6', { key: '6', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(5), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch7', { key: '7', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(6), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switch8', { key: '8', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(7), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); - useShortcut('tab.switchLast', { key: '9', ctrl: true, scope: 'canvas', allowInInput: true }, () => switchToTabByIndex(-1), { enabled, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch1', { key: '1', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 0), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch2', { key: '2', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 1), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch3', { key: '3', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 2), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch4', { key: '4', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 3), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch5', { key: '5', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 4), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch6', { key: '6', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 5), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch7', { key: '7', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 6), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switch8', { key: '8', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, 7), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); + useShortcut('tab.switchLast', { key: '9', ctrl: true, scope: 'canvas', allowInInput: true }, event => switchToTabByIndex(event, -1), { ...canvasShortcutOptions, description: 'keyboard.shortcuts.tab.switchMerged' }); }; export default useKeyboardShortcuts; 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..26c02f457b --- /dev/null +++ b/src/web-ui/src/app/hooks/useTabCloseShortcut.test.tsx @@ -0,0 +1,375 @@ +// @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 { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService'; +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 <> + +