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
+333
View File
@@ -0,0 +1,333 @@
/**
* One free window: a tab dragged out of the workbench floating over the
* conversation area at viewport coordinates (rendered inside the panel host,
* so desktop-shell transforms can never hijack its fixed containing block).
*
* The header drags the window with the panel-resize pattern — pointer
* capture + per-frame direct DOM writes + a store commit on release — and
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
* pane ([data-dsh-pane], either panel), that pane highlights live and
* releasing docks the tab into it (center merge); releasing anywhere else
* just moves the window. The SE corner resizes, any press raises (the
* floats array's order is the stacking order), the header right-click menu
* and the X button dock / close. The tab content reuses the regular tab
* renderer, so every tab type (terminal, editor, plugin tabs) floats
* unchanged.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { FloatWindow, SidebarTab } from './state.ts'
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
interface DragState {
/** Which handle owns the drag (the abort path reads it from this ref —
* the React `dragging` state lags a render behind pointerdown). */
mode: 'move' | 'resize'
pointerX: number
pointerY: number
startX: number
startY: number
startW: number
startH: number
/** Last geometry actually written to the DOM (the cancel-path fallback). */
applied: { x: number; y: number; w: number; h: number }
/** Settled after the final pointer event (the up position wins over the
* rAF-pending value — a fast flick coalesces its tail into the up event). */
committed: boolean
}
/** The pane under a viewport point, if any (rect hit-test; the dragged
* window itself is not a pane, so it cannot shadow the targets). */
function paneAt(x: number, y: number): HTMLElement | null {
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) continue
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
}
return null
}
/** Pointer-capture helpers tolerant of environments without the API (jsdom
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
* the optional calls keep them driving the drag; real browsers always have
* it and a missing pointerId can never occur there). */
const capturePointer = (element: HTMLElement, pointerId: number): void => {
element.setPointerCapture?.(pointerId)
}
const releasePointer = (element: HTMLElement, pointerId: number): void => {
element.releasePointerCapture?.(pointerId)
}
/** Whether the element holds the pointer (assumed true without the API). */
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
return element.hasPointerCapture?.(pointerId) !== false
}
export function FreeWindow(props: {
float: FloatWindow
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
onRaise: () => void
onMove: (x: number, y: number) => void
onResize: (w: number, h: number) => void
/** Dock the tab back; the pane id, or null for the active pane. */
onDock: (paneId: string | null) => void
onClose: () => void
}) {
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
const rootRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragState | null>(null)
// The pane currently highlighted as the dock target (a direct DOM write:
// per-frame React state would re-render every mounted tab at drag cadence).
const dockTargetRef = useRef<HTMLElement | null>(null)
// rAF coalescing: pointermove outruns the display, one write per frame.
const frameRef = useRef<number | null>(null)
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
}, [])
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
pendingRef.current = geo
if (frameRef.current !== null) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
})
}
/** Flush the pending frame synchronously (the release path's last write). */
const flushNow = (): void => {
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
pendingRef.current = null
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
}
const clearDockHighlight = (): void => {
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
dockTargetRef.current = null
}
/** Release a drag: `pane` (when set) docks instead of moving. */
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
drag.committed = true
dragRef.current = null
setDragging(null)
const target = pane ?? dockTargetRef.current
clearDockHighlight()
if (mode === 'move' && target !== null) {
onDock(target.getAttribute('data-dsh-pane'))
} else if (mode === 'move') {
onMove(geo.x, geo.y)
} else {
onResize(geo.w, geo.h)
}
}
/** Cancel-path settle (pointercancel / lostpointercapture): the last
* APPLIED geometry is the user-visible truth — commit it, never roll back
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
* the React `dragging` state can still be null when the cancel lands
* before the pointerdown re-render commits. */
const abortDrag = (): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
flushNow()
finishDrag(drag.mode, drag.applied, null)
}
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
w: float.w,
h: float.h,
}
}
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: float.x,
y: float.y,
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
}
}
return (
<div
ref={rootRef}
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
data-dsh-float-window
data-dsh-float-id={float.id}
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
onPointerDown={() => { onRaise() }}
>
<div
className={css.floatHeader}
onPointerDown={(event) => {
if (event.button !== 0) return
// Portaled overlays (the header Menu) live at document.body in the
// DOM, yet their events still bubble through the React tree into
// this handler — without the containment guard a menu-row click
// would start a drag, preventDefault (killing the row's compat
// mousedown) and capture the pointer (retargeting the release at
// the header, so the row's click never fires). Buttons inside the
// header (the close X) need the same exclusion: a capture here
// swallows their click.
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
if (event.target instanceof Element && event.target.closest('button') !== null) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'move',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('move')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
scheduleApply(geo)
const target = paneAt(event.clientX, event.clientY)
if (target !== dockTargetRef.current) {
clearDockHighlight()
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
dockTargetRef.current = target
}
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampMove(
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
)
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
onContextMenu={(event) => {
event.preventDefault()
setMenu({ x: event.clientX, y: event.clientY })
}}
>
{getTabIcon?.(float.tab) ?? null}
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
<button
type="button"
className={css.floatClose}
aria-label={t('close')}
onClick={(event) => { event.stopPropagation(); onClose() }}
>
<IconCloseFill14 />
</button>
<Menu
open={menu !== null}
onClose={() => { setMenu(null) }}
items={[
{ id: 'dock', label: t('dockToSidebar') },
{ id: 'close', label: t('close') },
]}
onSelect={(id) => {
setMenu(null)
if (id === 'dock') onDock(null)
else if (id === 'close') onClose()
}}
portal
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
/>
</div>
<div className={css.floatContent}>
{renderTab(float.tab, true, float.id)}
</div>
<div
className={css.floatResize}
onPointerDown={(event) => {
if (event.button !== 0) return
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'resize',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('resize')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
scheduleApply(clampResize(
drag.startW + (event.clientX - drag.pointerX),
drag.startH + (event.clientY - drag.pointerY),
))
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampResize(
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
)
finishDrag('resize', geo, null)
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
/>
</div>
)
}
+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>
)
}
+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) {
+3
View File
@@ -66,6 +66,9 @@ export const ar: Record<string, string> = {
closeOtherTabs: 'إغلاق التبويبات الأخرى',
closeLeftTabs: 'إغلاق التبويبات على اليسار',
closeRightTabs: 'إغلاق التبويبات على اليمين',
moveToFreeWindow: 'النقل إلى نافذة حرة',
floatDropHint: 'حرّك للفتح في نافذة حرة',
dockToSidebar: 'العودة إلى الشريط الجانبي',
collapse: 'طي الشريط الجانبي',
expand: 'توسيع الشريط الجانبي',
collapseBottomPanel: 'طي اللوحة السفلية',
+3
View File
@@ -51,6 +51,9 @@ export const de: Record<string, string> = {
closeOtherTabs: 'Andere Tabs schließen',
closeLeftTabs: 'Tabs links schließen',
closeRightTabs: 'Tabs rechts schließen',
moveToFreeWindow: 'In freies Fenster verschieben',
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
dockToSidebar: 'Zurück zur Seitenleiste',
collapse: 'Seitenleiste einklappen',
expand: 'Seitenleiste ausklappen',
collapseBottomPanel: 'Unteres Panel einklappen',
+3
View File
@@ -58,6 +58,9 @@ export const fr: Record<string, string> = {
closeOtherTabs: 'Fermer les autres onglets',
closeLeftTabs: 'Fermer les onglets à gauche',
closeRightTabs: 'Fermer les onglets à droite',
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
dockToSidebar: 'Revenir à la barre latérale',
collapse: 'Réduire la barre latérale',
expand: 'Déployer la barre latérale',
collapseBottomPanel: 'Réduire le panneau inférieur',
+3
View File
@@ -65,6 +65,9 @@ export const hi: Record<string, string> = {
closeOtherTabs: 'अन्य टैब बंद करें',
closeLeftTabs: 'बाएँ टैब बंद करें',
closeRightTabs: 'दाएँ टैब बंद करें',
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
dockToSidebar: 'साइडबार पर वापस जाएँ',
collapse: 'साइडबार संक्षिप्त करें',
expand: 'साइडबार विस्तृत करें',
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
+3
View File
@@ -63,6 +63,9 @@ export const id: Record<string, string> = {
closeOtherTabs: 'Tutup Tab Lain',
closeLeftTabs: 'Tutup Tab di Kiri',
closeRightTabs: 'Tutup Tab di Kanan',
moveToFreeWindow: 'Pindahkan ke jendela bebas',
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
dockToSidebar: 'Kembali ke bilah samping',
collapse: 'Lipat sidebar',
expand: 'Bentangkan sidebar',
collapseBottomPanel: 'Lipat panel bawah',
+3
View File
@@ -56,6 +56,9 @@ export const it: Record<string, string> = {
closeOtherTabs: 'Chiudi le altre schede',
closeLeftTabs: 'Chiudi le schede a sinistra',
closeRightTabs: 'Chiudi le schede a destra',
moveToFreeWindow: 'Sposta in finestra libera',
floatDropHint: 'Rilascia per aprire in una finestra libera',
dockToSidebar: 'Torna alla barra laterale',
collapse: 'Comprimi barra laterale',
expand: 'Espandi barra laterale',
collapseBottomPanel: 'Comprimi pannello inferiore',
+3
View File
@@ -65,6 +65,9 @@ export const ja: Record<string, string> = {
closeOtherTabs: '他のタブを閉じる',
closeLeftTabs: '左のタブを閉じる',
closeRightTabs: '右のタブを閉じる',
moveToFreeWindow: 'フリーウィンドウへ移動',
floatDropHint: '離すとフリーウィンドウで開きます',
dockToSidebar: 'サイドバーへ戻す',
collapse: 'サイドバーを折りたたむ',
expand: 'サイドバーを展開',
collapseBottomPanel: '下パネルを折りたたむ',
+3
View File
@@ -57,6 +57,9 @@ export const ko: Record<string, string> = {
closeOtherTabs: '다른 탭 닫기',
closeLeftTabs: '왼쪽 탭 닫기',
closeRightTabs: '오른쪽 탭 닫기',
moveToFreeWindow: '자유 창으로 이동',
floatDropHint: '놓으면 자유 창에서 열립니다',
dockToSidebar: '사이드바로 돌아가기',
collapse: '사이드바 접기',
expand: '사이드바 펼치기',
collapseBottomPanel: '하단 패널 접기',
+3
View File
@@ -63,6 +63,9 @@ export const nl: Record<string, string> = {
closeOtherTabs: 'Andere tabbladen sluiten',
closeLeftTabs: 'Tabbladen links sluiten',
closeRightTabs: 'Tabbladen rechts sluiten',
moveToFreeWindow: 'Naar vrij venster verplaatsen',
floatDropHint: 'Loslaten om in een vrij venster te openen',
dockToSidebar: 'Terug naar de zijbalk',
collapse: 'Zijbalk inklappen',
expand: 'Zijbalk uitklappen',
collapseBottomPanel: 'Onderpaneel inklappen',
+3
View File
@@ -67,6 +67,9 @@ export const pl: Record<string, string> = {
closeOtherTabs: 'Zamknij inne karty',
closeLeftTabs: 'Zamknij karty po lewej',
closeRightTabs: 'Zamknij karty po prawej',
moveToFreeWindow: 'Przenieś do wolnego okna',
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
dockToSidebar: 'Wróć do panelu bocznego',
collapse: 'Zwiń panel boczny',
expand: 'Rozwiń panel boczny',
collapseBottomPanel: 'Zwiń panel dolny',
+3
View File
@@ -48,6 +48,9 @@ export const pt: Record<string, string> = {
closeOtherTabs: 'Fechar outras abas',
closeLeftTabs: 'Fechar abas à esquerda',
closeRightTabs: 'Fechar abas à direita',
moveToFreeWindow: 'Mover para janela flutuante',
floatDropHint: 'Solte para abrir em uma janela flutuante',
dockToSidebar: 'Voltar para a barra lateral',
collapse: 'Recolher barra lateral',
expand: 'Expandir barra lateral',
collapseBottomPanel: 'Recolher painel inferior',
+3
View File
@@ -61,6 +61,9 @@ export const ru: Record<string, string> = {
closeOtherTabs: 'Закрыть остальные вкладки',
closeLeftTabs: 'Закрыть вкладки слева',
closeRightTabs: 'Закрыть вкладки справа',
moveToFreeWindow: 'Переместить в свободное окно',
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
dockToSidebar: 'Вернуть на боковую панель',
collapse: 'Свернуть боковую панель',
expand: 'Развернуть боковую панель',
collapseBottomPanel: 'Свернуть нижнюю панель',
+3
View File
@@ -48,6 +48,9 @@ export const sv: Record<string, string> = {
closeOtherTabs: 'Stäng andra flikar',
closeLeftTabs: 'Stäng flikar till vänster',
closeRightTabs: 'Stäng flikar till höger',
moveToFreeWindow: 'Flytta till fritt fönster',
floatDropHint: 'Släpp för att öppna i ett fritt fönster',
dockToSidebar: 'Tillbaka till sidopanelen',
collapse: 'Komprimera sidopanel',
expand: 'Expandera sidopanel',
collapseBottomPanel: 'Komprimera bottenpanel',
+3
View File
@@ -65,6 +65,9 @@ export const th: Record<string, string> = {
closeOtherTabs: 'ปิด Tab อื่น',
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
closeRightTabs: 'ปิด Tab ด้านขวา',
moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
dockToSidebar: 'กลับไปที่แถบข้าง',
collapse: 'ย่อแถบด้านข้าง',
expand: 'ขยายแถบด้านข้าง',
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
+3
View File
@@ -65,6 +65,9 @@ export const tr: Record<string, string> = {
closeOtherTabs: 'Diğer sekmeleri kapat',
closeLeftTabs: 'Soldaki sekmeleri kapat',
closeRightTabs: 'Sağdaki sekmeleri kapat',
moveToFreeWindow: 'Serbest pencereye taşı',
floatDropHint: 'Serbest pencerede açmak için bırakın',
dockToSidebar: 'Kenar çubuğuna geri dön',
collapse: 'Kenar çubuğunu daralt',
expand: 'Kenar çubuğunu genişlet',
collapseBottomPanel: 'Alt paneli daralt',
+3
View File
@@ -65,6 +65,9 @@ export const vi: Record<string, string> = {
closeOtherTabs: 'Đóng các tab khác',
closeLeftTabs: 'Đóng tab bên trái',
closeRightTabs: 'Đóng tab bên phải',
moveToFreeWindow: 'Chuyển sang cửa sổ tự do',
floatDropHint: 'Thả để mở trong cửa sổ tự do',
dockToSidebar: 'Quay lại thanh bên',
collapse: 'Thu gọn thanh bên',
expand: 'Mở rộng thanh bên',
collapseBottomPanel: 'Thu gọn panel dưới',
+3
View File
@@ -80,6 +80,9 @@ export const zhHK: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+3
View File
@@ -80,6 +80,9 @@ export const zhMO: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+3
View File
@@ -80,6 +80,9 @@ export const zhTW: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
+6
View File
@@ -72,6 +72,9 @@ export const zh = {
closeOtherTabs: '关闭其他页签',
closeLeftTabs: '关闭左侧页签',
closeRightTabs: '关闭右侧页签',
moveToFreeWindow: '移动到自由窗口',
floatDropHint: '松开以在自由窗口中打开',
dockToSidebar: '回到侧边栏',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
collapseBottomPanel: '折叠底部面板',
@@ -402,6 +405,9 @@ export const en: Record<keyof typeof zh, string> = {
closeOtherTabs: 'Close Other Tabs',
closeLeftTabs: 'Close Tabs to the Left',
closeRightTabs: 'Close Tabs to the Right',
moveToFreeWindow: 'Move to Free Window',
floatDropHint: 'Release to open in a free window',
dockToSidebar: 'Dock Back to Sidebar',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
collapseBottomPanel: 'Collapse bottom panel',
+36 -3
View File
@@ -22,8 +22,8 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
leafWithTab, openTabInActivePane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
export const SIDEBAR_SERVICE_VERSION = '0.16.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
@@ -485,6 +485,9 @@ export const SIDEBAR_SERVICE_VERSION = '0.15.2'
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
* - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
* id focus targets RAISE the floating window (never duplicate the tab or
* expand panels), closeTab on a floating tab closes it with its window.
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -497,6 +500,7 @@ export const SIDEBAR_FEATURES = [
'pluginSettings',
'urlTarget',
'settingSelect',
'floatWindows',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
@@ -651,6 +655,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(f => f.tab))
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
@@ -675,12 +680,24 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
// The pool covers free windows too — a floating instance focuses by
// raising, and the callback must see the real (patched) tab.
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(landed.floats.map(f => f.tab))
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open that focuses an existing FLOATING tab is already in
// sight (free windows render regardless of panel state): expanding a
// panel for it would point the user at a pane the content is not in.
if (
!isCreation
&& floatWithTab(landed, activated?.id ?? tab.id) !== undefined
) {
return landed
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
@@ -728,6 +745,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
// A floating tab closes WITH its window — the float is the tab's pane.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
closed = float.tab
return closeFloatByTab(state, tabId)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
@@ -764,6 +787,12 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
// A floating tab "activates" by raising its window — no pane switch.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
activated = float.tab
return raiseFloat(state, float.id)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
@@ -825,6 +854,10 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
// A floating instance focuses by raising its window (no duplicate tab,
// no panel expansion — the window is the tab's pane).
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
if (floated !== undefined) return raiseFloat(state, floated.id)
}
return openTabInActivePane(state, tab)
}
+137 -1
View File
@@ -282,6 +282,141 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding-right: 40px;
}
/* ── Free windows ──────────────────────────────────────────────────────── */
/* One floating tab: the same card surface as the panels, at viewport
coordinates inside the panel host (immune to shell transforms). z-index
42: above the panels (40) so a window drags across them, below the
toggle cluster (45) and the app's overlay stack (100+, issue #52).
Stacking among windows is the floats array order (siblings share the
z-index; DOM order wins). Deliberately NO box transition — the header/
resize drags write left/top/width/height per frame. Elevated surface,
so unlike the flat panels it earns a shadow token. */
.floatWindow {
position: absolute;
z-index: 42;
display: flex;
flex-direction: column;
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
box-shadow: var(--dsw-shadow-lv3);
overflow: hidden;
contain: layout style;
}
.floatWindowDragging {
will-change: left, top, width, height;
}
/* The header: the move/dock-back drag handle, mirroring the tab strip's
chrome (34px, hairline bottom, secondary label). */
.floatHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 4px 0 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
cursor: grab;
user-select: none;
}
.floatWindowDragging .floatHeader {
cursor: grabbing;
}
.floatTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mirrors .tabClose. */
.floatClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.floatClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.floatContent {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
}
/* The SE resize corner (mirror of .cornerHandle). */
.floatResize {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.floatResize:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* A pane highlighted as the dock target while a float header drag hovers it
(the attribute is written directly by the drag at pointer cadence;
skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */
.pane[data-dsh-float-dock-over] {
outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -2px;
}
/* The drag-out hint over the conversation column: a dashed frame marking
the drop zone, pointer-transparent so it never disturbs the drag it
describes. z-index 46: above the windows (42) for unambiguous feedback,
still below the app's overlay stack. */
.floatDropHint {
position: absolute;
z-index: 46;
pointer-events: none;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 8px;
background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.floatDropHintLabel {
padding: 4px 12px;
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
}
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
@@ -293,7 +428,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
browsers and in shells without drag regions — safe everywhere. */
.toggleCluster,
.toggleButton,
.tabBar {
.tabBar,
.floatHeader {
-webkit-app-region: no-drag;
}
+4
View File
@@ -29,6 +29,8 @@ export interface WorkbenchActions {
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
/** Float a docked tab out as a free window (tab context menu entry). */
floatTab: (tabId: string) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions.
@@ -167,6 +169,7 @@ function LeafView(props: {
return (
<div
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
data-dsh-pane={leaf.id}
onPointerDown={() => { actions.focusPane(leaf.id) }}
onDragOver={(event) => {
event.preventDefault()
@@ -207,6 +210,7 @@ function LeafView(props: {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
onFloatTab={actions.floatTab}
/>
{leaf.tabs.length > 0 ? (
/*
+272 -54
View File
@@ -58,6 +58,23 @@ export interface SidebarSplit {
export type SplitNode = SidebarLeaf | SidebarSplit
/**
* One free window: a tab dragged out of the workbench onto the conversation
* area floats in the panel host at viewport coordinates. The tab is OWNED by
* the window exactly like a pane owns its tabs (moved, not copied); geometry
* persists with the session so a reload restores the window in place.
* Stacking order is the array order (last = topmost).
*/
export interface FloatWindow {
id: string
tab: SidebarTab
/** Viewport coordinates of the window's top-left corner. */
x: number
y: number
w: number
h: number
}
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
@@ -87,6 +104,8 @@ export interface SidebarState {
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
* tabs never cross panels — the two panels only share panel-size drags). */
bottomSplits: SplitNode
/** Free windows (tabs dragged out onto the conversation area). */
floats: FloatWindow[]
}
export const PANEL_MIN = 280
@@ -94,9 +113,16 @@ export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
/** Bottom panel geometry contract (mirrors the width contract; the upper
* bound is the viewport, enforced by {@link setBottomHeight}). */
* bound is the viewport, enforced by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
/** Free-window geometry contract: the floor keeps the window usable (a
* header plus some content), the ceiling is the viewport. */
export const FLOAT_MIN_W = 320
export const FLOAT_MIN_H = 200
/** Geometry a fresh free window starts with (clamped to the viewport). */
export const FLOAT_DEFAULT_W = 480
export const FLOAT_DEFAULT_H = 360
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -143,6 +169,14 @@ function maxCounterId(parsed: unknown): number {
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
// Free windows mint through the same shared counter; their ids must seed it
// too (a persisted float:N with a lower pane max would collide otherwise).
const floats = (parsed as Record<string, unknown> | null)?.floats
if (Array.isArray(floats)) {
for (const float of floats) {
if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
}
}
return max
}
@@ -181,6 +215,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed:
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
floats: [],
}
}
@@ -280,10 +315,22 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state (either tree, any pane). */
/** Whether a tab exists anywhere in a state (either tree, any pane, or any
* free window — a floating tab is as open as a docked one). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| state.floats.some(float => float.tab.id === tabId)
}
/** The free window holding a tab id, if any. */
export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
return state.floats.find(float => float.tab.id === tabId)
}
/** The free window with the given window id, if any. */
export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
return state.floats.find(float => float.id === floatId)
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
@@ -453,25 +500,26 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
/** Update the display fields of one open tab (title / path / meta) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. The tab may live in either tree. */
* missing tab id is a no-op. The tab may live in either tree or a free
* window. */
export function patchTab(
state: SidebarState,
tabId: string,
patch: { title?: string; path?: string; meta?: unknown },
): SidebarState {
let changed = false
const apply = (tab: SidebarTab): SidebarTab => {
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
}
const walk = (node: SplitNode): SplitNode => {
if (node.kind === 'leaf') {
const tabs = node.tabs.map(tab => {
if (tab.id !== tabId) return tab
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
})
const tabs = node.tabs.map(tab => (tab.id === tabId ? apply(tab) : tab))
return tabs === node.tabs ? node : { ...node, tabs }
}
const children = node.children.map(walk)
@@ -479,7 +527,8 @@ export function patchTab(
}
const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
return changed ? { ...state, splits, bottomSplits } : state
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
return changed ? { ...state, splits, bottomSplits, floats } : state
}
/**
@@ -504,11 +553,14 @@ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): Sideb
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
// Id-based safety net: if a tab with the same id exists, focus it.
// Id-based safety net: if a tab with the same id exists, focus it — in a
// pane (activate) or in a free window (raise, no panel switch).
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
const floated = floatWithTab(state, tab.id)
if (floated !== undefined) return raiseFloat(state, floated.id)
return {
...state,
activePane: targetId,
@@ -674,6 +726,137 @@ export function resizeSplitIn(state: SidebarState, splitId: string, index: numbe
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
// ── Free windows ────────────────────────────────────────────────────────────
/** The viewport size, or Infinity where there is no (usable) window — unit
* tests stub partial window objects, and a NaN bound would poison geometry. */
function viewportW(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
}
function viewportH(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
}
/** Clamp free-window geometry: sizes respect the floor and the viewport, and
* the position keeps the whole window inside the viewport. Without a window
* (unit tests) only the floor applies — the caller's values pass through. */
export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
return {
x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
w: width,
h: height,
}
}
/**
* Float a docked tab: remove it from its pane (either tree; an emptied pane
* collapses like any move) and append a free window centered on the drop
* point, with the default size clamped to the viewport. The stacking order
* is the array order, so a fresh window is born topmost. An unknown tab id
* (or one already floating) is a strict no-op.
*/
export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
let source: SidebarLeaf | undefined
let key: 'splits' | 'bottomSplits' | undefined
for (const treeKey of ['splits', 'bottomSplits'] as const) {
source = leafWithTab(state[treeKey], tabId)
if (source !== undefined) { key = treeKey; break }
}
if (key === undefined || source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let node = mapLeaf(state[key], source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) node = removeLeafAt(node, source.id)
const window = clampFloatGeometry(
x - FLOAT_DEFAULT_W / 2,
y - FLOAT_DEFAULT_H / 2,
FLOAT_DEFAULT_W,
FLOAT_DEFAULT_H,
)
const next: SidebarState = {
...state,
[key]: node,
floats: [...state.floats, { id: uid('float'), tab, ...window }],
}
// The pane the user was working in may have just collapsed with the tab.
if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
return next
}
/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const geo = clampFloatGeometry(x, y, float.w, float.h)
if (geo.x === float.x && geo.y === float.y) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
}
/** Resize a free window from its SE corner: the top-left corner stays
* anchored, sizes clamp to the floor and to the viewport's remaining room. */
export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
if (width === float.w && height === float.h) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
}
/** Bring a free window to the top (the array's end). Already topmost (or the
* only window) returns the same reference — no persist churn on every click. */
export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
if (state.floats.length < 2) return state
const index = state.floats.findIndex(f => f.id === floatId)
if (index < 0 || index === state.floats.length - 1) return state
const floats = [...state.floats]
const [raised] = floats.splice(index, 1)
floats.push(raised!)
return { ...state, floats }
}
/** Dock a free window back into a pane (center merge): the tab joins the
* target pane and activates. `toPane` defaults to the active pane with the
* right tree's first leaf as the stale-id fallback (mirrors
* {@link openTabInActivePane}). Unknown window ids are a no-op. */
export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
return {
...state,
floats: state.floats.filter(f => f.id !== floatId),
activePane: targetId,
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, float.tab]
leaf.active = float.tab.id
}),
}
}
/** Close the free window holding a tab (the tab closes WITH the window —
* the caller fires the descriptor's onClose lifecycle). */
export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
if (!state.floats.some(f => f.tab.id === tabId)) return state
return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -709,6 +892,7 @@ export function reconcileAgentTerminals(
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(float => float.tab))
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
@@ -716,17 +900,22 @@ export function reconcileAgentTerminals(
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
// a FLOATED agent terminal leaves with its window.
let splits = state.splits
let floats = state.floats
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
if (floats.some(float => float.tab.id === tab.id)) {
floats = floats.filter(float => float.tab.id !== tab.id)
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
let next: SidebarState = { ...state, splits, floats }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
@@ -843,6 +1032,31 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
// Free windows arrived with the float feature: a missing field on an older
// persisted state defaults to none (like the bottom-panel fields above).
// Malformed ENTRIES drop individually — unlike the split trees, where
// corruption resets the whole state, a bad window must not cost the user
// their whole layout. Geometry clamps into the CURRENT viewport so a stale
// off-screen window stays reachable; the window ids join the same
// uniqueness set as the pane/split ids (the uid counter mints for both).
const floats: FloatWindow[] = []
if (Array.isArray(record.floats)) {
for (const entry of record.floats) {
if (entry === null || typeof entry !== 'object') continue
const candidate = entry as Record<string, unknown>
if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
const tab = sanitizePersistedTab(candidate.tab)
if (tab === undefined || tab === 'diff') continue
if (
typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
|| typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
|| typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
|| typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
) continue
seen.add(candidate.id)
floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
}
}
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
@@ -869,6 +1083,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
floats,
}
}
@@ -902,6 +1117,42 @@ function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>):
return fresh
}
/**
* Validate one persisted tab record. @returns the clean tab, `'diff'` for an
* ephemeral diff tab (dropped everywhere — diff tabs never survive a reload),
* or undefined when the record is malformed (structural corruption when it
* comes from a split-tree leaf; a malformed FLOAT tab only drops the window).
*/
function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (candidate.type === 'diff') return 'diff'
// Tab types are an open set (external plugins register their own); accept
// any string type here — an unregistered type renders an <OrphanedTab/> at
// view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home tab —
// no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
return { id: candidate.id, type: 'editor', title: 'Files', meta: { treeOpen: true, ...meta } }
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the persisted
// value already went through JSON.parse, so it is inherently serializable —
// carry it through verbatim (absent on older states).
return {
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
}
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
@@ -911,46 +1162,13 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
const tabs: SidebarTab[] = []
let droppedDiff = false
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
// they never survive a reload, so a stale ref (e.g. a wrong staged
// side persisted by an older build) cannot resurface as a dead tab
// showing "no text changes" after every refresh.
if (candidate.type === 'diff') {
const clean = sanitizePersistedTab(tab)
if (clean === undefined) return undefined
if (clean === 'diff') {
droppedDiff = true
continue
}
// Tab types are an open set (external plugins register their own);
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home
// tab — no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
tabs.push({
id: candidate.id,
type: 'editor',
title: 'Files',
meta: { treeOpen: true, ...meta },
})
continue
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
})
tabs.push(clean)
}
const active = typeof record.active === 'string' ? record.active : null
// An active pointer into a dropped diff tab is expected after the drop;