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
+131 -6
View File
@@ -28,16 +28,16 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs,
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
resizeFloat, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
@@ -48,7 +48,8 @@ import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { FreeWindow } from './FreeWindow.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
@@ -86,7 +87,7 @@ const AUTO_OPEN_DEBOUNCE_MS = 500
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
* propagate exactly as before.
*/
const swallowOsFileDrag = (event: DragEvent): void => {
const swallowOsFileDrag = (event: ReactDragEvent): void => {
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
event.preventDefault()
event.stopPropagation()
@@ -682,6 +683,85 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// above, this is the belt-and-braces retry for the open moment itself).
}, [measureCenter, state?.bottomOpen])
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this shell watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
}, [narrow, sessionId, store])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
@@ -1013,6 +1093,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
// The tab context menu's "move to free window": no drop point exists, so
// the window is born over the conversation column's center (the user's
// focus area; clamped into the viewport by the reducer) — the same
// landing the drag-out gesture produces.
floatTab: (tabId) => {
const col = centerColRef.current
const rect = col !== null && col.isConnected ? col.getBoundingClientRect() : null
const x = rect !== null ? (rect.left + rect.right) / 2 : window.innerWidth / 2
const y = rect !== null ? (rect.top + rect.bottom) / 2 : window.innerHeight / 2
store.reduce(s => floatTab(s, tabId, x, y))
},
}), [store, sessionId, cwd])
/**
@@ -1385,6 +1476,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
</div>
</div>
)}
{/*
Free windows: tabs dragged out onto the conversation area (or floated
from the tab context menu). They live in the panel host like the
panels (viewport coordinates, immune to desktop-shell transforms) but
are independent of panel state — a window stays up while panels
collapse. The floats array's order is the stacking order; the content
reuses the regular tab renderer, so every tab type floats unchanged.
*/}
{state.floats.map(float => (
<FreeWindow
key={float.id}
float={float}
renderTab={renderTab}
getTabIcon={tabIconOf}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
onClose={() => { ctx.betterSidebar?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{floatHint !== null && (
<div
className={css.floatDropHint}
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</div>
)
}