feat(ui): 自由窗口——tab 拖出侧边栏成为可移动/缩放/置顶的浮动窗口 (v0.16.0)

- 拖出:标签栏 tab 拖到主会话列(HTML5 DnD 检测 + 虚线提示浮层)或右键菜单
  「移动到自由窗口」(右/底面板共用 TabBar),tab 从 pane 移动进 SidebarState.floats
- 窗口(FreeWindow,渲染于 [data-dsh-panel-host] z-42):头部 pointer 拖动
  (rAF 直写 + 松手提交)、拖到 pane 高亮停靠(center 合并)、SE 角缩放、
  点击置顶、头部右键(回到侧边栏/关闭)、X = closeTab 走正常生命周期
- 服务语义:openTab dedupe/id 聚焦浮动 tab = 置顶窗口(不展开面板);
  closeTab/activateTab 兼顾 floats;agent 终端 reconcile 覆盖浮窗;
  features 单调新增 'floatWindows',版本 0.16.0
- 持久化:floats 随会话 localStorage,sanitizeState 宽容校验 + 几何钳制,
  uid 播种覆盖 float 前缀;i18n 三键 19 语言同步
- 关键修复(e2e 发现):portal 覆盖层(头部 Menu)的合成事件沿 React 树
  冒泡回拖拽表面,劫持菜单行点击——拖拽起点加 DOM 同源守卫(jsdom 不复现)
- e2e-mount.sh:缺省 tarball 改取 mtime 最新(旧字典序选择会把冒烟挂到
  过期产物);新增 tests/e2e/float-window.e2e.ts 双用例
This commit is contained in:
Menghuan1918
2026-08-23 23:44:18 +08:00
parent d6d8db0416
commit f17a1b30ac
39 changed files with 2100 additions and 96 deletions
+11 -4
View File
@@ -4,8 +4,9 @@
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, all scoped to this pane).
* tab opens the tab context menu (float as a free window / close / close
* others / close to the left / close to the right, the close ones scoped to
* this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -63,6 +64,9 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Float a tab out as a free window (the tab context menu's entry; the
* drag-to-conversation gesture is handled at the Sidebar shell level). */
onFloatTab: (tabId: string) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
@@ -70,7 +74,7 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
@@ -271,6 +275,7 @@ export function TabBar(props: {
open={tabMenu !== null && tabMenuIndex >= 0}
onClose={() => { setTabMenu(null) }}
items={[
{ id: 'float', label: t('moveToFreeWindow') },
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
@@ -282,7 +287,9 @@ export function TabBar(props: {
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'close') {
if (id === 'float') {
onFloatTab(target.tabId)
} else if (id === 'close') {
onClose(target.tabId)
} else if (id === 'closeOthers') {
for (const tab of tabs) {