Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/web-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
- **Desktop**:通过 **Tauri** 加载运行
- **Server/Web**:构建为静态资源,由后端提供访问

## 使用快捷键关闭标签

桌面端使用 **Ctrl+W**(Windows/Linux)或 **Cmd+W**(macOS)关闭当前操作区域的标签。
在中间会话区域操作时关闭当前会话标签;在文件或终端区域操作时关闭该区域的当前标签。
多个分栏按焦点所在分组响应,空的内容区域不会转而关闭另一侧的会话。

快捷键与标签关闭按钮共用未保存内容检查和终端生命周期处理。
已有的“关闭标签”自定义快捷键对两个区域均生效。
弹窗交互和长按按键产生的重复事件不会继续关闭其他标签。
数字切换标签、重新打开标签和布局快捷键也只作用于当前画布。
在设置页录入新快捷键时,按键用于录入,不会执行原有命令。

## 创建项目

左侧导航的新增按钮展示两张可点击卡片,首层不设底部操作按钮。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,6 +23,7 @@ interface UseKeyboardShortcutsOptions {
}

export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) => {
const mode = useContext(CanvasStoreModeContext);
const {
enabled = true,
missionControlEnabled = true,
Expand All @@ -31,6 +34,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {})
const {
primaryGroup,
secondaryGroup,
tertiaryGroup,
activeGroupId,
layout,
closeTab,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -68,6 +94,7 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {})
() => toggleMissionControl(),
{
enabled: enabled && missionControlEnabled,
when: ownsCanvasEvent,
priority: 10,
description: 'keyboard.shortcuts.canvas.missionControl',
}
Expand All @@ -78,47 +105,59 @@ 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+\
useShortcut(
'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+`
useShortcut(
'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
useShortcut(
'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
Expand All @@ -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;
14 changes: 14 additions & 0 deletions src/web-ui/src/app/hooks/useGlobalSceneShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand Down
Loading
Loading