871 lines
38 KiB
TypeScript
871 lines
38 KiB
TypeScript
/**
|
|
* Group node renderer — a ZONE: header strip (tabs when stacked, minimize
|
|
* chevron) + the active pane's content, resolved from the contribution
|
|
* registry (`area: 'panes'`). Empty zones exist only in editor-authored
|
|
* trees (drop targets until the first structural op prunes them).
|
|
*
|
|
* Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and
|
|
* every zone lights up as a whole-region drop target. Right-click opens the
|
|
* contextual zone menu (tab close verbs + header/minimize toggles).
|
|
*/
|
|
|
|
import { useStore } from '@nanostores/react'
|
|
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
|
|
|
|
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { DecodeText } from '@/components/ui/decode-text'
|
|
import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance'
|
|
import {
|
|
PANE_TAB_STRIP_LINE_LEFT,
|
|
PANE_TAB_STRIP_LINE_RIGHT,
|
|
PaneStripGlyph,
|
|
PaneTab,
|
|
paneTabCloseItems,
|
|
PaneTabLabel,
|
|
PaneTabStrip
|
|
} from '@/components/ui/pane-tab'
|
|
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
|
import { useContributions } from '@/contrib/react/use-contributions'
|
|
import { useI18n } from '@/i18n'
|
|
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
import { $layoutEditMode } from '../../edit-mode'
|
|
import { useWindowControlsOverlap } from '../../geometry'
|
|
import { emptyPaneLifecycleState, reconcilePaneLifecycle } from '../../pane-lifecycle'
|
|
import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility'
|
|
import {
|
|
$workspaceMode,
|
|
$workspaceOwnerKey,
|
|
contributesToWorkspace,
|
|
rememberActivePane,
|
|
resolveRememberedActivePane,
|
|
workspaceScopeKey
|
|
} from '../../workspace-scope'
|
|
import type { DropPosition, GroupNode } from '../model'
|
|
import {
|
|
$dropHint,
|
|
$hiddenTreePanes,
|
|
$narrowViewport,
|
|
$newSessionTabAction,
|
|
$panesWithCloser,
|
|
$treeDragging,
|
|
$treePaneEpochs,
|
|
activateTreePane,
|
|
closeAllTreeTabs,
|
|
closeOtherTreeTabs,
|
|
closeTabPane,
|
|
closeTreeTabsToRight,
|
|
collapseTreePane,
|
|
hideOnlyZoneTabs,
|
|
isCollapsePane,
|
|
isMainStripPane,
|
|
isSessionStripPane,
|
|
noteActiveTreeGroup,
|
|
reloadTreePane,
|
|
restoreTreePane,
|
|
SESSION_TILE_DRAG,
|
|
setStripTabHidden,
|
|
setTreeGroupMinimized,
|
|
setTreeGroupTabStrip,
|
|
treeTabCloseTargets
|
|
} from '../store'
|
|
import {
|
|
$tabSelection,
|
|
clearTabSelection,
|
|
isToggleSelectClick,
|
|
selectionFor,
|
|
selectTabRange,
|
|
toggleTabSelected
|
|
} from '../tab-selection'
|
|
|
|
import { startPaneDrag } from './drag-session'
|
|
import { tabStripVisibleForZone } from './strip-visibility'
|
|
import { useActiveTabVisible } from './tab-strip-scroll'
|
|
import { paneChrome } from './track-model'
|
|
|
|
/** Right-click zone menu: the tab verbs (close this / others / to the right /
|
|
* all) plus the strip's own chrome toggles. Same items and icons as a session
|
|
* tab's menu, so every tab in a strip answers a right-click the same way —
|
|
* a pane with no domain menu of its own (the file tree, a terminal, the main
|
|
* tab on a fresh draft) falls through to this one. */
|
|
function ZoneMenu({
|
|
children,
|
|
closable,
|
|
minimizable = true,
|
|
minimized,
|
|
nodeId,
|
|
stripVisible,
|
|
targetPane
|
|
}: {
|
|
children: ReactNode
|
|
/** The pane the menu closes (the right-clicked chip / the active pane);
|
|
* undefined = not closable (the main zone). */
|
|
closable?: () => string | undefined
|
|
/** False for the zone hosting the uncloseable workspace — collapsing the
|
|
* MAIN pane strands the app behind a strip. */
|
|
minimizable?: boolean
|
|
minimized?: boolean
|
|
nodeId: string
|
|
/** Whether the strip is on screen — the Hide/Show row toggles against what
|
|
* the user can see, not against the stored mode (a zone on auto has none). */
|
|
stripVisible?: boolean
|
|
/** The right-clicked chip (else the active pane) — what the close-others /
|
|
* to-the-right / all verbs measure from. Called when the menu RENDERS, not
|
|
* on every zone re-render: resolving the siblings reads the layout tree,
|
|
* and subscribing every zone to it made a sash drag re-render every
|
|
* mounted pane. */
|
|
targetPane: () => string
|
|
}) {
|
|
const { t } = useI18n()
|
|
// Hiding the strip takes this menu with it, so the row that hides it is the
|
|
// last place to say how to get it back — the status bar's hide row does the
|
|
// same for the same reason.
|
|
const toggleHint = useKeybindHint('view.toggleTabStrip')
|
|
|
|
// Resolved at render: the menu mounts on open, after the right-click set
|
|
// menuPane — so an uncloseable target hides Close instead of offering a
|
|
// dead action, and the counts describe the chip actually clicked.
|
|
const items = (kit: MenuKit) => {
|
|
const paneId = closable?.()
|
|
const targetId = targetPane()
|
|
|
|
return (
|
|
<>
|
|
{renderActionItem(kit, {
|
|
icon: 'refresh',
|
|
label: t.zones.reload,
|
|
onSelect: () => reloadTreePane(targetId)
|
|
})}
|
|
<kit.Separator />
|
|
{paneTabCloseItems(kit, {
|
|
counts: treeTabCloseTargets(targetId),
|
|
onClose: paneId !== undefined ? () => closeTabPane(paneId) : undefined,
|
|
onCloseAll: () => closeAllTreeTabs(targetId),
|
|
onCloseOthers: () => closeOtherTreeTabs(targetId),
|
|
onCloseToRight: () => closeTreeTabsToRight(targetId)
|
|
})}
|
|
{(() => {
|
|
// Show/hide rows for the zone's hide-only chrome tabs (sessions /
|
|
// Bots) — their Close replacement. Resolved when the menu OPENS,
|
|
// same no-subscription contract as the close-verb counts above.
|
|
const hideOnly = hideOnlyZoneTabs(nodeId)
|
|
|
|
if (hideOnly.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<kit.Separator />
|
|
{hideOnly.map(tab =>
|
|
renderActionItem(kit, {
|
|
icon: tab.hidden ? 'eye' : 'eye-closed',
|
|
key: `strip-tab-${tab.id}`,
|
|
label: tab.hidden ? t.zones.showStripTab(tab.title) : t.zones.hideStripTab(tab.title),
|
|
onSelect: () => setStripTabHidden(tab.id, !tab.hidden)
|
|
})
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
<kit.Separator />
|
|
{renderActionItem(kit, {
|
|
icon: stripVisible ? 'eye-closed' : 'eye',
|
|
key: 'zone-tabstrip',
|
|
label: (
|
|
<>
|
|
{/* The hint's `ml-auto` makes the label the row's flexible part,
|
|
so without this it breaks mid-phrase before the menu widens. */}
|
|
<span className="whitespace-nowrap">{stripVisible ? t.zones.hideTabStrip : t.zones.showTabStrip}</span>
|
|
{toggleHint && <span className="ml-auto pl-2 text-(--ui-text-quaternary)">{toggleHint}</span>}
|
|
</>
|
|
),
|
|
onSelect: () => setTreeGroupTabStrip(nodeId, stripVisible ? 'never' : 'always')
|
|
})}
|
|
{minimizable &&
|
|
renderActionItem(kit, {
|
|
// Same action-direction contract as the strip button below: the
|
|
// icon points where the zone will GO (restore opens upward).
|
|
icon: minimized ? 'chevron-up' : 'chevron-down',
|
|
label: minimized ? t.zones.restore : t.zones.minimize,
|
|
onSelect: () => setTreeGroupMinimized(nodeId, !minimized)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ActionsContextMenu contentClassName="w-40" items={items}>
|
|
{children}
|
|
</ActionsContextMenu>
|
|
)
|
|
}
|
|
|
|
export function TreeGroup({
|
|
node,
|
|
parentAxis,
|
|
railSide = 'left'
|
|
}: {
|
|
node: GroupNode
|
|
parentAxis?: 'column' | 'row'
|
|
railSide?: 'left' | 'right'
|
|
}) {
|
|
const { t } = useI18n()
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const stripRef = useRef<HTMLDivElement>(null)
|
|
// The scrolling tab list inside the header (the strip also holds the
|
|
// minimize chevron, which must not scroll away).
|
|
const tabsRef = useRef<HTMLDivElement>(null)
|
|
// The chip under the last right-click — the pane the zone menu's Split
|
|
// actions carry into the new zone (header background = the active pane).
|
|
// STATE, not a ref: the menu items (incl. Close's visibility) are JSX
|
|
// evaluated during THIS component's render — a ref write on right-click
|
|
// doesn't re-render, so the menu showed the PREVIOUS target's items (Close
|
|
// missing on an inactive tile tab whose zone-active was the uncloseable
|
|
// workspace).
|
|
const [menuPane, setMenuPane] = useState<string | undefined>(undefined)
|
|
const panes = useContributions('panes')
|
|
// Coarse drag flag only (set once at drag start/end). The per-frame drop
|
|
// HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny
|
|
// overlay, not every zone's header/body (and not the menuDirections walk).
|
|
const dragging = useStore($treeDragging)
|
|
const editMode = useStore($layoutEditMode)
|
|
const wcOverlap = useWindowControlsOverlap(ref, true)
|
|
|
|
const hiddenPanes = useStore($hiddenTreePanes)
|
|
const narrow = useStore($narrowViewport)
|
|
const workspaceMode = useStore($workspaceMode)
|
|
const workspaceOwnerKey = useStore($workspaceOwnerKey)
|
|
const newSessionTabAction = useStore($newSessionTabAction)
|
|
const panesWithCloser = useStore($panesWithCloser)
|
|
// Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but
|
|
// the one holding it, so this subscription is quiet during normal use.
|
|
const tabSelection = useStore($tabSelection)
|
|
// Reload epochs: only an explicit tab-menu Reload writes here, so this
|
|
// subscription costs nothing on a normal render.
|
|
const paneEpochs = useStore($treePaneEpochs)
|
|
|
|
const paneFor = (id: string) => panes.find(p => p.id === id)
|
|
|
|
// Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed
|
|
// panes drop out of the header; the active pane falls back to the first
|
|
// shown one (render-side — the tree keeps `active`).
|
|
// Edit mode forces toggle-hidden panes visible so they can be rearranged
|
|
// (mirrors tree-split's paneGone) — restores itself on exit.
|
|
const paneShown = (id: string) =>
|
|
Boolean(paneFor(id)) &&
|
|
contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) &&
|
|
(editMode || !hiddenPanes.has(id)) &&
|
|
!(narrow && paneChrome(paneFor(id)).collapsible)
|
|
|
|
const shown = node.panes.filter(paneShown)
|
|
const memoryKey = workspaceScopeKey(workspaceMode, workspaceOwnerKey)
|
|
|
|
const activeId = shown.includes(node.active)
|
|
? node.active
|
|
: (resolveRememberedActivePane(memoryKey, shown) ?? shown[0] ?? '')
|
|
|
|
const active = paneFor(activeId)
|
|
const isEmpty = shown.length === 0
|
|
|
|
useEffect(() => {
|
|
if (activeId) {
|
|
rememberActivePane(memoryKey, activeId)
|
|
}
|
|
}, [activeId, memoryKey])
|
|
|
|
// BOUNDED KEEP-ALIVE: the active pane is visible, a small per-zone LRU stays
|
|
// hot-hidden, and older panes park (unmount). This preserves fast tab
|
|
// round-trips without letting a long-lived zone pin every transcript it has
|
|
// ever visited. Stateful resources can opt out of parking (the terminal keeps
|
|
// its PTY alive while hidden). Lazy remains deliberate: restored background
|
|
// tabs have no lifecycle entry and do not mount until first activation.
|
|
const lifecycleRef = useRef(emptyPaneLifecycleState())
|
|
|
|
if (!node.minimized && !isEmpty) {
|
|
lifecycleRef.current = reconcilePaneLifecycle(lifecycleRef.current, {
|
|
activeId,
|
|
keepAlive: id => Boolean(paneChrome(paneFor(id)).lifecycleKeepAlive),
|
|
paneIds: shown
|
|
})
|
|
}
|
|
|
|
const paneLifecycle = lifecycleRef.current.entries
|
|
const keptPanes = shown.filter(id => paneLifecycle[id] && paneLifecycle[id].lifecycle !== 'parked')
|
|
|
|
// ONE header style: the app's compact pane-header. Whether this zone shows
|
|
// it is the resolver's call, not this component's — see strip-visibility.ts
|
|
// for the precedence. The same resolver answers for the toggle command, so
|
|
// the keystroke and the screen always agree about which way "toggle" points.
|
|
const stripVisible = tabStripVisibleForZone({
|
|
active: activeId,
|
|
isCollapsePane,
|
|
mode: node.tabStrip,
|
|
paneFor,
|
|
shown
|
|
})
|
|
|
|
// A group collapses ALONG its parent split's axis. In a row that means the
|
|
// WIDTH collapses — a full-width horizontal header would strand a tall
|
|
// empty column, so the minimized form is a narrow vertical rail instead
|
|
// (tabs reading top-to-bottom). In a column (stacked zones) the horizontal
|
|
// header IS the collapsed form, exactly as before.
|
|
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
|
|
// A minimized group IS its header, so it shows one regardless.
|
|
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || stripVisible)
|
|
|
|
// Keep the activated tab — and, on the last one, the trailing "+" — inside
|
|
// the strip's scroll window. Opening a tab past the right edge otherwise
|
|
// left both the new tab and the button that made it out of view.
|
|
useActiveTabVisible(tabsRef, activeId, {
|
|
enabled: headerVisible,
|
|
last: shown[shown.length - 1] === activeId,
|
|
tabCount: shown.length
|
|
})
|
|
|
|
// Zone-menu close targets read the layout tree, but this component must NOT
|
|
// subscribe to it: `useStore($layoutTree)` here wires every zone — and
|
|
// therefore every mounted pane and its whole transcript — to the entire
|
|
// tree. A sash drag rewrites the tree once per frame, so dragging the
|
|
// sidebar re-rendered all five tiles' message lists on every pointermove
|
|
// (measured: TreeGroup 180 renders cascading into ChatView/Thread/TileChat
|
|
// at ~4.5s each, holding the drag at ~3fps). The menu's items are resolved
|
|
// when it OPENS, so they read the tree with `.get()` at that moment instead.
|
|
const targetPane = () => menuPane ?? activeId
|
|
|
|
// Close targets the right-clicked chip (falling back to the active pane);
|
|
// panes that declare `uncloseable` (the main workspace) or `hideOnly`
|
|
// (sessions / Bots — show/hide replaces Close) are exempt.
|
|
const closable = () => {
|
|
const paneId = targetPane()
|
|
const chrome = paneChrome(paneFor(paneId))
|
|
|
|
return chrome.uncloseable || chrome.hideOnly ? undefined : paneId
|
|
}
|
|
|
|
// The zone hosting the uncloseable workspace never minimizes — collapsing
|
|
// MAIN strands the whole app behind a strip.
|
|
const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable)
|
|
|
|
// Middle-click / ⌘-click on a tab: one routing for every tab kind, the same
|
|
// one the zone menu's Close and ⌘W use.
|
|
const closeTab = (paneId: string) => closeTabPane(paneId)
|
|
|
|
// A pane whose store owns Close keeps the gesture even when the pane itself
|
|
// is uncloseable — the workspace tab empties to a fresh draft rather than
|
|
// leaving the tree. Hide-only chrome (sessions / Bots) opts out of every
|
|
// close gesture: its tabs are shown/hidden (zone menu, ⌘K), never closed —
|
|
// an accidental ✕ on standing chrome removed Bot Mode until the next launch.
|
|
const closeableTab = (paneId: string) =>
|
|
!paneChrome(paneFor(paneId)).hideOnly && (!paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId))
|
|
|
|
// A pane's own live label when it has one, else its registered string.
|
|
const tabLabel = (paneId: string) => paneChrome(paneFor(paneId)).tabTitle?.() ?? paneFor(paneId)?.title ?? paneId
|
|
|
|
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
|
|
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
|
|
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
|
|
|
|
// Same menu on the header strip and the edit veil — one prop bag.
|
|
const zoneMenu = {
|
|
closable,
|
|
minimizable,
|
|
minimized: node.minimized,
|
|
nodeId: node.id,
|
|
stripVisible,
|
|
targetPane
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-editor-surface-background)"
|
|
data-tree-group={node.id}
|
|
// Advertises the visible tab strip so panes can drop their own
|
|
// self-naming labels (see [data-pane-self-label] in styles.css).
|
|
data-zone-header={headerVisible || undefined}
|
|
// The zone menu opens from the strip, the rail, the edit veil and the
|
|
// body. Only the strip can name a chip, so resolve the target HERE for
|
|
// every one of them — otherwise a right-click off the strip reused the
|
|
// PREVIOUS target, and landing on the uncloseable workspace dropped
|
|
// Close from the menu for a pane that closes fine.
|
|
onContextMenu={e => {
|
|
setMenuPane((e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined)
|
|
}}
|
|
ref={ref}
|
|
style={wcOverlap ? { paddingTop: wcOverlap.y + wcOverlap.height } : undefined}
|
|
>
|
|
{wcOverlap && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute z-10 [-webkit-app-region:drag]"
|
|
style={{ height: wcOverlap.height, left: wcOverlap.x, top: wcOverlap.y, width: wcOverlap.width }}
|
|
/>
|
|
)}
|
|
|
|
{/* Minimized in a ROW: a narrow vertical rail — same PaneTab shell as
|
|
the horizontal strip, just `vertical`. Click a tab to restore +
|
|
activate; click anywhere else on the rail to restore. */}
|
|
{verticalCollapse && (
|
|
<ZoneMenu {...zoneMenu}>
|
|
<div
|
|
className={cn(
|
|
'flex h-full w-7 shrink-0 cursor-pointer select-none flex-col items-stretch bg-(--ui-sidebar-surface-background)',
|
|
// Strip line faces the content the zone collapsed away from.
|
|
railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT
|
|
)}
|
|
onClick={() => restoreTreePane(activeId)}
|
|
title={t.zones.restore}
|
|
>
|
|
<div
|
|
className="flex min-h-0 flex-col overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
role="tablist"
|
|
>
|
|
{shown.map(paneId => {
|
|
const closeable = closeableTab(paneId)
|
|
|
|
return (
|
|
<PaneTab
|
|
// Match the horizontal minimized strip: no tab is "active"
|
|
// while collapsed (there's no content surface to merge into).
|
|
aria-selected={paneId === activeId}
|
|
data-tree-tab={paneId}
|
|
key={paneId}
|
|
onClick={event => {
|
|
event.stopPropagation()
|
|
restoreTreePane(paneId)
|
|
}}
|
|
onClose={closeable ? () => closeTab(paneId) : undefined}
|
|
role="tab"
|
|
side={railSide}
|
|
vertical
|
|
>
|
|
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
|
</PaneTab>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</ZoneMenu>
|
|
)}
|
|
|
|
{/* Header: the shared pane tab strip (PaneTabStrip + PaneTab). */}
|
|
{headerVisible && (
|
|
<ZoneMenu {...zoneMenu}>
|
|
<PaneTabStrip
|
|
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
|
|
data-zone-tabstrip={node.id}
|
|
listRef={tabsRef}
|
|
onPointerDown={e =>
|
|
// Tap the header to collapse to it / expand back — the DetailPane
|
|
// / sidebar-section gesture (never for the main zone). Drag still
|
|
// moves the pane. No double-tap hide belongs here: hiding the
|
|
// strip unmounts every affordance the zone has, including the
|
|
// menu offering "Show", so it stays a named command.
|
|
startPaneDrag(activeId, e, () => minimizable && toggleCollapse(), undefined, active?.title ?? activeId)
|
|
}
|
|
ref={stripRef}
|
|
style={{ cursor: 'grab' }}
|
|
trailing={
|
|
<>
|
|
{minimizable && (
|
|
<button
|
|
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
|
|
className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
|
|
onClick={toggleCollapse}
|
|
onPointerDown={e => e.stopPropagation()}
|
|
type="button"
|
|
>
|
|
<Codicon name={node.minimized ? 'chevron-up' : 'chevron-down'} size="0.75rem" />
|
|
</button>
|
|
)}
|
|
<StripDropCaret groupId={node.id} stripRef={stripRef} />
|
|
</>
|
|
}
|
|
>
|
|
{shown.map(paneId => {
|
|
const isActive = paneId === activeId && !node.minimized
|
|
const chrome = paneChrome(paneFor(paneId))
|
|
const closeable = closeableTab(paneId)
|
|
const title = paneFor(paneId)?.title ?? paneId
|
|
const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId)
|
|
|
|
const tab = (
|
|
<PaneTab
|
|
active={isActive}
|
|
aria-selected={isActive}
|
|
data-tree-tab={paneId}
|
|
key={paneId}
|
|
onClose={closeable ? () => closeTab(paneId) : undefined}
|
|
onPointerDown={e => {
|
|
// Chrome's tab-selection grammar, ahead of activate/drag:
|
|
// Shift-click ranges from the anchor, ⌥-click (Ctrl-click
|
|
// off-Mac) toggles. Neither activates nor starts a drag —
|
|
// the press IS the selection edit. ⌘-click stays close
|
|
// (PaneTab claims it first) and ⌃-click stays the macOS
|
|
// context menu.
|
|
if (e.button === 0 && e.shiftKey) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
selectTabRange(node.id, shown, paneId, activeId)
|
|
|
|
return
|
|
}
|
|
|
|
if (isToggleSelectClick(e)) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
toggleTabSelected(node.id, paneId, activeId)
|
|
|
|
return
|
|
}
|
|
|
|
// Tabs ACTIVATE (restoring a collapsed group). Minimize
|
|
// lives on the chevron / single-pane label — overloading
|
|
// the active tab made double-click a minimize/restore/hide
|
|
// lottery. A plain click also collapses any multi-tab
|
|
// selection back to the one tab (Chrome semantics).
|
|
const onTap = () => {
|
|
clearTabSelection()
|
|
|
|
if (node.minimized) {
|
|
restoreTreePane(paneId)
|
|
}
|
|
|
|
activateTreePane(node.id, paneId)
|
|
}
|
|
|
|
// Claim the press so the STRIP's own pane-drag handler
|
|
// (parent onPointerDown) can't also fire. startPaneDrag
|
|
// does this internally; the session drag (shared with
|
|
// sidebar rows) doesn't, so do it here for both paths.
|
|
if (e.button === 0) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
|
|
// Dragging a SELECTED tab carries the whole selection as
|
|
// one block through the generic pane move — a multi-tab
|
|
// drag outranks the pane's own tab drag (the session drop
|
|
// language is single-session).
|
|
const dragSelection = selectionFor(node.id, shown, paneId)
|
|
|
|
if (dragSelection) {
|
|
startPaneDrag(
|
|
paneId,
|
|
e,
|
|
onTap,
|
|
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
|
t.zones.tabCount(dragSelection.length),
|
|
dragSelection
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// A pane may own its tab drag (a session tab speaks the
|
|
// session drop language — link/stack/split); `false` defers
|
|
// to the generic pane move (the workspace tab on a fresh
|
|
// draft has no session to link).
|
|
if (!chrome.tabDrag?.(e, onTap)) {
|
|
startPaneDrag(
|
|
paneId,
|
|
e,
|
|
onTap,
|
|
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
|
title
|
|
)
|
|
}
|
|
}}
|
|
role="tab"
|
|
selected={isSelected}
|
|
style={{ cursor: 'grab' }}
|
|
>
|
|
{chrome.tabLead ? (
|
|
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
|
|
) : null}
|
|
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
|
</PaneTab>
|
|
)
|
|
|
|
// A pane may wrap ITS tab in a domain menu (session verbs on a
|
|
// tile tab); the wrapper needs the key since it's the root.
|
|
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
|
|
})}
|
|
|
|
{/* Plain "+" after the last tab of a CHAT strip (the workspace
|
|
zone, or any zone holding session tabs) — always shown. Creates
|
|
a new session tab (mirrors ⌘T) via the app-registered action;
|
|
the pointerdown focuses this zone first, so the tab lands in
|
|
THIS strip. Hidden when unwired or the zone is minimized. */}
|
|
{shown.some(isSessionStripPane) && newSessionTabAction && !node.minimized && (
|
|
<span
|
|
// The action docks into the FOCUSED chat zone; clicking a
|
|
// background strip's "+" must make THAT zone the focused one
|
|
// first, or the tab opens in whichever zone was last clicked.
|
|
// (pointerdown's own focus tracking would land after the click
|
|
// handler reads the anchor.)
|
|
onPointerDownCapture={() => noteActiveTreeGroup(node.id)}
|
|
>
|
|
<PaneStripGlyph
|
|
icon={<Codicon name="add" size="0.8125rem" />}
|
|
label={t.zones.newSessionTab}
|
|
onSelect={() => newSessionTabAction()}
|
|
/>
|
|
</span>
|
|
)}
|
|
</PaneTabStrip>
|
|
</ZoneMenu>
|
|
)}
|
|
|
|
{/* Body: the zone's pane content — the active pane and bounded hot-hidden
|
|
cache stay mounted in absolute layers; parked panes are unmounted.
|
|
`visibility` (not display) keeps the hidden pane's layout box, so
|
|
scroll positions and measurements survive the round-trip — which also
|
|
makes a hidden layer's rect identical to the visible one's, hence the
|
|
marker document-wide lookups filter on (see pane-visibility.ts). */}
|
|
{!node.minimized && (
|
|
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
{isEmpty ? (
|
|
<div className="grid h-full place-items-center">
|
|
{/* Same decode primitive as the CONNECTING boot overlay. */}
|
|
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
|
</div>
|
|
) : (
|
|
keptPanes.map(paneId => {
|
|
const pane = paneFor(paneId)
|
|
const isActive = paneId === activeId
|
|
|
|
return (
|
|
<div
|
|
aria-hidden={!isActive || undefined}
|
|
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
|
|
key={paneId}
|
|
{...hiddenPaneProps(!isActive)}
|
|
>
|
|
{pane?.render ? (
|
|
// Visibility flows to the pane so a kept-alive chat surface
|
|
// can gate its hot (per-token) subscriptions while hidden;
|
|
// the group id identifies the ZONE it lives in, for state
|
|
// that is per-zone rather than per-tab (composer pop-out).
|
|
// The reload epoch keys the CONTENT, not this layer: a
|
|
// Reload remounts the contribution (effects re-run, state
|
|
// resets) while the layer — and every other tab — stays.
|
|
<PaneGroupContext.Provider value={node.id}>
|
|
<PaneLifecycleContext.Provider value={paneLifecycle[paneId]?.lifecycle ?? 'visible'}>
|
|
<PaneVisibleContext.Provider value={isActive}>
|
|
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
|
|
<ContribRender render={pane.render} />
|
|
</ContribBoundary>
|
|
</PaneVisibleContext.Provider>
|
|
</PaneLifecycleContext.Provider>
|
|
</PaneGroupContext.Provider>
|
|
) : (
|
|
isActive && (
|
|
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
|
|
{t.zones.missingPane(paneId)}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit-mode veil: the BODY is a drag handle for the active pane. It
|
|
starts below the header so tabs/headers stay directly interactive
|
|
(drag any tab, right-click for the zone menu). */}
|
|
{editMode && !dragging && !isEmpty && !node.minimized && (
|
|
<ZoneMenu {...zoneMenu}>
|
|
<div
|
|
// z-50: pane CONTENT may carry its own stacked chrome (the
|
|
// terminal rail is z-40) — the edit veil must cover all of it.
|
|
// The scrim mixes the accent over the CHROME BG (not transparent)
|
|
// so it properly dims content in dark themes instead of leaving a
|
|
// barely-tinted wash; the light blur reads as "edit mode" the same
|
|
// way the zone editor's backdrop does.
|
|
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
|
|
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, active?.title ?? activeId)}
|
|
style={{
|
|
top: headerVisible ? 28 : 0,
|
|
background:
|
|
'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))',
|
|
outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)'
|
|
}}
|
|
>
|
|
<span className="flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-md border border-(--ui-stroke-secondary) bg-popover px-2 py-1 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-(--ui-text-secondary)">
|
|
<Codicon className="shrink-0" name="gripper" size="0.8125rem" />
|
|
<span className="min-w-0 truncate">{active?.title ?? activeId}</span>
|
|
</span>
|
|
</div>
|
|
</ZoneMenu>
|
|
)}
|
|
|
|
{/* FancyZones drop overlay — its own component so the per-frame drop
|
|
hint re-renders only this (tiny) node, not the whole zone. */}
|
|
<ZoneDropOverlay node={node} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab-strip insertion caret
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The insertion divider for a stack drop: a 2px vertical line at the slot the
|
|
* dragged tab will land in (before `stack.before`, or after the last tab).
|
|
* Absolute over the strip — pure overlay, zero layout shift. #000 on light,
|
|
* #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders
|
|
* only this node (same isolation contract as ZoneDropOverlay).
|
|
*/
|
|
function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject<HTMLDivElement | null> }) {
|
|
const hint = useStore($dropHint)
|
|
const strip = stripRef.current
|
|
const stack = hint?.groupId === groupId ? hint.stack : undefined
|
|
|
|
if (stack === undefined || !strip) {
|
|
return null
|
|
}
|
|
|
|
// Slot x: the before-tab's left edge, or the last tab's right edge.
|
|
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-tree-tab]')]
|
|
const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1)
|
|
|
|
if (!target) {
|
|
return null
|
|
}
|
|
|
|
const stripRect = strip.getBoundingClientRect()
|
|
const targetRect = target.getBoundingClientRect()
|
|
const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left
|
|
|
|
// A short centered tick (~60% of the tab), not a full-height wall — reads
|
|
// as an insertion point between labels, browser-tab style.
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute z-50 w-px -translate-x-1/2 bg-black dark:bg-white"
|
|
style={{
|
|
height: targetRect.height * 0.6,
|
|
left: x,
|
|
top: targetRect.top - stripRect.top + targetRect.height * 0.2
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FancyZones drop overlay
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in
|
|
* zones-engine); on a drag that starts under the cursor that ramp reads as
|
|
* lag, so the sheets snap in far faster — same softening, instant feel. */
|
|
const OVERLAY_FADE_MS = 80
|
|
|
|
/** Sheet inset from the zone edge (px). */
|
|
const REGION_PAD = 6
|
|
|
|
/** The sheet's box per drop position — longhand insets so CSS transitions can
|
|
* interpolate the px↔% change: the target GLIDES between the full zone and
|
|
* the hovered half instead of snapping (VS Code dock preview). */
|
|
const REGION: Record<DropPosition, CSSProperties> = {
|
|
bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' },
|
|
center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD },
|
|
left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD },
|
|
right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD },
|
|
top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }
|
|
}
|
|
|
|
/**
|
|
* The FancyZones drop overlay for one zone. Split out of TreeGroup so the
|
|
* per-pointermove `$dropHint` churn re-renders only this lightweight node —
|
|
* the zone's header, body, and menu-direction walk stay put during a drag.
|
|
*
|
|
* ONE dashed sheet per zone (DROP_SHEET_CLASS — the composer drop and the zone
|
|
* targets speak identically): a quiet outline over every eligible zone,
|
|
* accent-lit over the target, morphing to the hovered half for an edge split.
|
|
*/
|
|
function ZoneDropOverlay({ node }: { node: GroupNode }) {
|
|
const dragging = useStore($treeDragging)
|
|
const hint = useStore($dropHint)
|
|
|
|
if (dragging === null) {
|
|
return null
|
|
}
|
|
|
|
// A session drag (sidebar row) reuses this exact overlay — over ANY zone
|
|
// that hosts a MAIN tile (stack into its tabs / split its edges); only a
|
|
// CHAT zone's center is a link-to-chat (the composer overlay owns that
|
|
// visual). Standing side chrome — the sidebar, files, terminal — hosts no
|
|
// main tile, so a session can't land there: those zones stay DARK rather
|
|
// than painting an idle outline the drop would only refuse. Same test
|
|
// `tileZoneHost` (session-drag.ts) resolves the drop with, so what lights
|
|
// up and what commits cannot disagree.
|
|
const sessionDrag = dragging === SESSION_TILE_DRAG
|
|
const chatZone = node.panes.some(isSessionStripPane)
|
|
|
|
if (sessionDrag && !chatZone && !node.panes.some(isMainStripPane)) {
|
|
return null
|
|
}
|
|
|
|
const isDragSource = node.panes.includes(dragging)
|
|
|
|
// The source zone, when it holds only the dragged pane, has nothing to drop.
|
|
if (isDragSource && node.panes.length === 1) {
|
|
return null
|
|
}
|
|
|
|
const primary = hint?.groupId === node.id
|
|
|
|
// Hovering the target's TAB STRIP: the insertion caret (StripDropCaret)
|
|
// owns the affordance — the zone sheet stands down so the two never stack.
|
|
if (primary && hint?.stack !== undefined) {
|
|
return null
|
|
}
|
|
|
|
const active = hint?.groupIds?.includes(node.id) ?? false
|
|
const multi = (hint?.groupIds?.length ?? 0) > 1
|
|
// Sub-positions only exist for a single-zone target (a Shift-span merges).
|
|
const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center'
|
|
// Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside
|
|
// the surface (ChatDropOverlay — the same sheet) owns that region; this sheet
|
|
// fades out so the two never stack. A non-chat zone's center has no chat to
|
|
// link, so it shows the normal stack sheet. Edges act like a tab.
|
|
const centerLink = sessionDrag && primary && pos === 'center' && chatZone
|
|
|
|
return (
|
|
<div
|
|
className="pointer-events-none absolute inset-0 z-40"
|
|
style={{ animation: `hermes-zone-fade ${OVERLAY_FADE_MS}ms linear both` }}
|
|
>
|
|
<div
|
|
className={cn(
|
|
DROP_SHEET_CLASS,
|
|
// Transition ONLY the box + colors. `transition-all` also animated
|
|
// backdrop-filter, and a blur interpolating while the insets glide
|
|
// re-blurs half a zone every frame — the single most expensive
|
|
// paint in the whole drag.
|
|
'absolute transition-[top,right,bottom,left,background-color,border-color,opacity] duration-150 ease-out',
|
|
// Blur only the live target — idle outlines must not fog the app.
|
|
active && !centerLink && DROP_SHEET_BLUR_CLASS,
|
|
centerLink && 'opacity-0'
|
|
)}
|
|
style={{
|
|
...REGION[pos],
|
|
// Accent over a card wash so the fill dims content on dark themes
|
|
// (a bare accent alpha disappears there).
|
|
background: active
|
|
? 'color-mix(in srgb, var(--ui-accent) 18%, color-mix(in srgb, var(--dt-card) 55%, transparent))'
|
|
: 'color-mix(in srgb, var(--ui-accent) 5%, color-mix(in srgb, var(--dt-card) 25%, transparent))',
|
|
borderColor: `color-mix(in srgb, var(--ui-accent) ${active ? 75 : 28}%, transparent)`
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|