feat(desktop): keep panel tabs in the titlebar

This commit is contained in:
brooklyn!
2026-09-09 21:37:34 -07:00
parent cfdbbb6e35
commit bbe212de9b
14 changed files with 533 additions and 345 deletions
+1 -73
View File
@@ -37,8 +37,6 @@ import {
import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope'
import { SidebarProvider } from '@/components/ui/sidebar'
import { discoverBundledPlugins } from '@/contrib/plugins'
import { Slot } from '@/contrib/react/slot'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { translateNow } from '@/i18n'
@@ -800,26 +798,6 @@ registerPaneCloser('files', () =>
// ---------------------------------------------------------------------------
interface TitlebarSlotProps {
area: 'titleBar.center' | 'titleBar.left' | 'titleBar.right'
className: string
style?: CSSProperties
}
function TitlebarSlot({ area, className, style }: TitlebarSlotProps) {
const items = useContributions(area)
if (items.length === 0) {
return null
}
return (
<div className={className} style={style}>
<Slot area={area} />
</div>
)
}
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
const statusbarVisible = useStore($statusbarVisible)
@@ -864,57 +842,7 @@ export function ContribController() {
data-contrib-shell=""
style={{ '--titlebar-height': '0px' } as CSSProperties}
>
{/* Title bar: fixed chrome outside the grid, composable via slots.
Layout contract (no contribution can break it):
- a full-bar DRAG BASE underneath (pointer-events-none, like
AppShell's drag strips) — everywhere without content drags
the window;
- each slot region is width-fit, no-drag, pointer-events-auto,
so every contribution is clickable by construction;
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
tree-published --workspace-left/right vars (pure CSS, no rect
threading), clamped to clear the REAL TitlebarControls
clusters (fixed, z-70); center is truly window-centered. */}
<div className="relative flex h-[34px] shrink-0 items-center bg-(--ui-sidebar-surface-background) text-xs">
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
clusters instead of overlapping them — Electron's no-drag
carve-out of fixed/transformed elements is unreliable, so a
full-bar drag base kills their clicks. In-flow slot content
still carves via its own no-drag wrapper (the same pattern as
the app's session-title button). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<TitlebarSlot
area="titleBar.left"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 24px) + 1rem))'
}}
/>
<TitlebarSlot
area="titleBar.center"
className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"
/>
<TitlebarSlot
area="titleBar.right"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
// Five static cluster buttons: four systemTools plus the
// always-present right-sidebar toggle (titlebar-controls.tsx).
// Keep in sync with wiring.tsx's SYSTEM_TOOL_COUNT.
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 5 * var(--titlebar-control-size, 24px) + 0.5rem))'
}}
/>
</div>
<LayoutTreeRoot />
<LayoutTreeRoot titlebar />
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
<SessionTileCloseConfirm />
+7 -15
View File
@@ -1144,20 +1144,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
const titlebarToolsRight = titlebarToolsRightCss(nativeOverlayWidth, titlebarChrome)
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
// against the static system cluster — in the tree layout the titlebar band
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
// Count every button the static cluster actually renders: four systemTools
// (layout, haptics, keybinds, settings) PLUS the always-present
// right-sidebar toggle (see titlebar-controls.tsx). A shared width that
// under-counts leaves the find bar, the titlebar header padding, and the
// pane-cluster anchor overlapping the fifth button.
const SYSTEM_TOOL_COUNT = 5
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
const systemToolsWidth = titlebarToolsWidthCss(SYSTEM_TOOL_COUNT)
// App controls live on the left; flip and the right toggle share the right.
const titlebarToolsWidth = titlebarToolsWidthCss(2)
const titlebarToolsWidth =
paneToolCount > 0 ? `calc(${systemToolsWidth} + ${titlebarToolsWidthCss(paneToolCount)})` : systemToolsWidth
const leftToolsWidth = titlebarToolsWidthCss(
4 + [...leftTitlebarTools, ...rightTitlebarTools].filter(tool => !tool.hidden).length
)
return (
<ContribWiringContext.Provider value={api}>
@@ -1167,10 +1159,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{
'--titlebar-controls-left': `${controlsPos.left}px`,
'--titlebar-controls-top': `${controlsPos.top}px`,
'--titlebar-controls-width': leftToolsWidth,
'--titlebar-controls-y-nudge': titlebarControlsYNudge(titlebarChrome),
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
'--shell-preview-toolbar-gap': systemToolsWidth
'--titlebar-tools-width': titlebarToolsWidth
} as CSSProperties
}
>
@@ -8,12 +8,12 @@ import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { Slot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { triggerHaptic } from '@/lib/haptics'
import { formatModifierToken } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleHud } from '@/store/hud'
import {
$fileBrowserOpen,
@@ -134,7 +134,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const navigate = useNavigate()
const location = useLocation()
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const sidebarOpen = useStore($sidebarOpen)
@@ -142,18 +141,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const unreadBadge = unreadCount > 0 ? unreadCount : undefined
const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : ''
const toggleHaptics = () => {
if (!hapticsMuted) {
triggerHaptic('tap')
}
toggleHapticsMuted()
if (hapticsMuted) {
window.requestAnimationFrame(() => triggerHaptic('success'))
}
}
// POSITIONAL toggles: each button shows/hides everything on its physical
// side of the main zone (the layout tree collapses the whole side), so they
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
@@ -164,30 +151,28 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar
const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar
const leftToolbarTools: TitlebarTool[] = [
{
actionId: 'view.toggleSidebar',
badge: panesFlipped ? undefined : unreadBadge,
icon: <TitlebarIcon name="layout-sidebar-left" />,
id: 'sidebar',
label: `${leftLabel}${panesFlipped ? '' : unreadHint}`,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
}
},
{
actionId: 'view.flipPanes',
icon: <TitlebarIcon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
}
},
...leftTools
]
const sidebarTool: TitlebarTool = {
actionId: 'view.toggleSidebar',
badge: panesFlipped ? undefined : unreadBadge,
icon: <TitlebarIcon name="layout-sidebar-left" />,
id: 'sidebar',
label: `${leftLabel}${panesFlipped ? '' : unreadHint}`,
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
}
}
const flipTool: TitlebarTool = {
actionId: 'view.flipPanes',
icon: <TitlebarIcon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
}
}
const rightSidebarTool: TitlebarTool = {
actionId: 'view.toggleRightSidebar',
@@ -202,8 +187,18 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
tour: 'right-pane-toggle'
}
// Static system tools — always pinned to the screen's right edge.
// App actions stay visible beside the left sidebar toggle.
const systemTools: TitlebarTool[] = [
{
actionId: 'nav.settings',
icon: <TitlebarIcon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
},
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
@@ -237,23 +232,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
triggerHaptic('open')
toggleHud(hudTargetSessionId())
}
},
{
active: hapticsMuted,
icon: <TitlebarIcon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
actionId: 'nav.settings',
icon: <TitlebarIcon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
}
]
@@ -265,8 +243,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const visiblePaneTools = tools.filter(tool => !tool.hidden)
const visibleLeftTools = [sidebarTool, ...systemTools, ...leftTools, ...tools].filter(tool => !tool.hidden)
return (
<>
@@ -277,42 +254,19 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)'
)}
>
{leftToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
{visibleLeftTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<Slot area="titleBar.left" />
<Slot area="titleBar.center" />
<Slot area="titleBar.right" />
</div>
{/*
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
to either the static cluster's width (file-browser closed → cluster
sits flush against system tools) or the file-browser pane's width
(file-browser open → cluster sits flush against the file-browser pane,
i.e. at the preview pane's right edge). No margin hacks needed.
*/}
{visiblePaneTools.length > 0 && (
<div
aria-label={t.shell.paneControls}
className={cn(
titlebarToolClusterClass,
'top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))]'
)}
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
)}
<div
aria-label={t.shell.appControls}
className={cn(titlebarToolClusterClass, 'right-(--titlebar-tools-right) top-(--titlebar-controls-top)')}
>
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<TitlebarToolButton navigate={navigate} tool={flipTool} />
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
@@ -514,6 +514,20 @@ export function startPaneDrag(
enterZoneMode()
}
// A strip is an exact target. Resolve it before the fuzzy zone engine:
// near a panel seam, proximity can otherwise pick the neighboring sidebar.
const hitStrip = !shift && strips.find(strip => rectContains(strip.rect, x, y))
if (hitStrip) {
return {
kind: 'group',
groupId: hitStrip.groupId,
groupIds: [hitStrip.groupId],
pos: 'center',
stack: slotBefore(hitStrip.slots, x, moving)
}
}
// The hint updates on highlight-set changes AND on sub-zone position
// changes (center/edge regions within the same primary zone).
const point = { x, y }
@@ -26,6 +26,7 @@ import { type ReactNode, useEffect } from 'react'
import { useLayoutEditHotkey } from '../../edit-mode'
import { publishWorkspaceGeometry } from '../../geometry'
import { $layoutTree, trackActiveTreeGroup } from '../store'
import { useTabKeyHints } from '../tab-key-hint-state'
import { ZoneEditor } from '../zone-editor'
import { TreeEditBar } from './edit-bar'
@@ -33,10 +34,11 @@ import { FloatingPanes } from './floating-panes'
import { NarrowOverlays } from './narrow-overlays'
import { TreeNode } from './tree-node'
export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
export function LayoutTreeRoot({ children, titlebar = false }: { children?: ReactNode; titlebar?: boolean }) {
const tree = useStore($layoutTree)
useLayoutEditHotkey(true)
useTabKeyHints()
// Track the interacted zone so ⌘W closes the right tab even when nothing is
// DOM-focused.
useEffect(trackActiveTreeGroup, [])
@@ -72,7 +74,14 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
display: none;
}
`}</style>
<TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} />
<TreeNode
leftEdge={titlebar}
node={tree}
rightEdge={titlebar}
root
rootRow={tree.type === 'split' && tree.orientation === 'row'}
topEdge={titlebar}
/>
<NarrowOverlays />
{/* Non-tiling panes: fixed cards above the tree, outside every zone. */}
<FloatingPanes />
@@ -57,6 +57,28 @@ afterEach(() => {
})
describe('TreeGroup', () => {
it('keeps a top-edge strip inside its panel and yields native drag while moving a pane', () => {
disposePane = registry.register({
area: 'panes',
data: { placement: 'main' },
id: 'terminal',
title: 'Terminal',
render: () => <div>Terminal</div>
})
vi.stubGlobal('CSS', { escape: (value: string) => value })
render(<TreeGroup leftEdge node={terminalGroup(false)} rightEdge topEdge />)
const zone = container!.querySelector('[data-tree-group]')!
const strip = zone.querySelector<HTMLElement>('[data-zone-tabstrip]')!
const header = zone.querySelector<HTMLElement>('[data-panel-header]')!
expect(strip.closest('[data-tree-group]')).toBe(zone)
expect(header.contains(strip)).toBe(true)
expect(zone.querySelectorAll('[data-window-drag-handle]').length).toBeGreaterThan(0)
act(() => $treeDragging.set('terminal'))
expect(strip.style).toHaveProperty('WebkitAppRegion', 'no-drag')
act(() => $treeDragging.set(null))
expect(strip.style).toHaveProperty('WebkitAppRegion', '')
})
it('points the docked-zone chevron in the collapse or restore action direction', () => {
disposePane = registry.register({
area: 'panes',
@@ -12,6 +12,7 @@
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
import { DecodeText } from '@/components/ui/decode-text'
@@ -72,6 +73,7 @@ import {
setTreeGroupTabStrip,
treeTabCloseTargets
} from '../store'
import { TabKeyHint } from '../tab-key-hints'
import {
$tabSelection,
clearTabSelection,
@@ -219,11 +221,17 @@ function ZoneMenu({
export function TreeGroup({
node,
parentAxis,
railSide = 'left'
railSide = 'left',
topEdge = false,
leftEdge = false,
rightEdge = false
}: {
node: GroupNode
parentAxis?: 'column' | 'row'
railSide?: 'left' | 'right'
topEdge?: boolean
leftEdge?: boolean
rightEdge?: boolean
}) {
const { t } = useI18n()
const ref = useRef<HTMLDivElement>(null)
@@ -245,7 +253,7 @@ export function TreeGroup({
// 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 wcOverlap = useWindowControlsOverlap(ref, !topEdge)
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
@@ -419,6 +427,7 @@ export function TreeGroup({
<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}
data-window-top={topEdge || undefined}
// 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}
@@ -487,181 +496,218 @@ export function TreeGroup({
</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 =>
// Drag still moves the pane. Tapping the strip never collapses:
// the chevron is the collapse affordance. Overloading the header
// (and, in a lone-tab zone, the tab sitting in it) made a click
// on the active chip fold the zone — and on a row-docked tile
// the chip vanished with the body, leaving no mouse path back.
// A minimized strip still restores on tap (it IS the handle).
startPaneDrag(
activeId,
e,
node.minimized ? () => restoreTreePane(activeId) : undefined,
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)
{/* Keep the header INSIDE its zone: titlebar drops use the same panel
bounds, strip refs, focus ownership and split geometry as the body. */}
{(headerVisible || topEdge) && (
<div
className="flex min-w-0 shrink-0 bg-(--ui-sidebar-surface-background)"
data-panel-header=""
style={topEdge ? { height: TITLEBAR_HEIGHT } : undefined}
>
{topEdge && leftEdge && (
<div className="flex shrink-0">
<div className="w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]" data-window-drag-handle="" />
<div className="relative w-(--titlebar-controls-width,96px)">
<div
className="absolute inset-x-0 top-0 h-(--titlebar-controls-top,5px) [-webkit-app-region:drag]"
data-window-drag-handle=""
/>
</div>
<div className="w-3 [-webkit-app-region:drag]" data-window-drag-handle="" />
</div>
)}
{headerVisible ? (
<ZoneMenu {...zoneMenu}>
<PaneTabStrip
className="flex-1"
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
data-zone-tabstrip={node.id}
listRef={tabsRef}
onPointerDown={event => {
// Native titlebar gaps move the window; tabs keep their own drag.
if (!topEdge) {
startPaneDrag(
activeId,
event,
node.minimized ? () => restoreTreePane(activeId) : undefined,
undefined,
active?.title ?? activeId
)
}
}}
ref={stripRef}
style={{ cursor: 'grab', WebkitAppRegion: dragging ? 'no-drag' : undefined } as CSSProperties}
titlebar={topEdge}
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 [-webkit-app-region:no-drag] 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, index) => {
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)
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
}
return
}
if (isToggleSelectClick(e)) {
e.preventDefault()
e.stopPropagation()
toggleTabSelected(node.id, paneId, activeId)
if (isToggleSelectClick(e)) {
e.preventDefault()
e.stopPropagation()
toggleTabSelected(node.id, paneId, activeId)
return
}
return
}
// Tabs ACTIVATE (restoring a collapsed group). Minimize
// lives on the chevron — 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()
// Tabs ACTIVATE (restoring a collapsed group). Minimize
// lives on the chevron — 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)
}
if (node.minimized) {
restoreTreePane(paneId)
}
activateTreePane(node.id, 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()
}
// 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)
// 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
)
if (dragSelection) {
startPaneDrag(
paneId,
e,
onTap,
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
t.zones.tabCount(dragSelection.length),
dragSelection
)
return
}
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 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">
<TabKeyHint groupId={node.id} slot={index + 1}>
{chrome.tabLead()}
</TabKeyHint>
</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>
})}
// 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 — it mints another tab of the kind
{/* Plain "+" after the last tab — it mints another tab of the kind
you are LOOKING AT, so a Browser strip makes another Browser
and a chat strip makes another session (mirrors ⌘T, via the
app-registered action). The pointerdown focuses this zone
first, so the tab lands in THIS strip. Hidden when the active
pane is one of a kind and the zone holds no session tabs. */}
{newTab && !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={newTab.label}
onSelect={newTab.onSelect}
/>
</span>
)}
</PaneTabStrip>
</ZoneMenu>
{newTab && !node.minimized && (
<span
className="flex shrink-0 items-center [-webkit-app-region:no-drag]"
// 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={newTab.label}
onSelect={newTab.onSelect}
/>
</span>
)}
</PaneTabStrip>
</ZoneMenu>
) : (
<div className="min-w-0 flex-1 [-webkit-app-region:drag]" />
)}
{topEdge && rightEdge && (
<div className="flex shrink-0">
<div
className="w-6"
data-window-drag-handle=""
style={{ WebkitAppRegion: dragging ? 'no-drag' : 'drag' } as CSSProperties}
/>
<div className="w-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,24px))] [-webkit-app-region:no-drag]" />
</div>
)}
</div>
)}
{/* Body: the zone's pane content — the active pane and bounded hot-hidden
@@ -735,7 +781,7 @@ export function TreeGroup({
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,
top: topEdge ? TITLEBAR_HEIGHT : 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)'
@@ -17,17 +17,30 @@ export function TreeNode({
parentAxis,
railSide,
root,
rootRow
rootRow,
topEdge = false,
leftEdge = false,
rightEdge = false
}: {
node: LayoutNode
parentAxis?: 'column' | 'row'
railSide?: 'left' | 'right'
root?: boolean
rootRow?: boolean
topEdge?: boolean
leftEdge?: boolean
rightEdge?: boolean
}) {
return node.type === 'split' ? (
<TreeSplit node={node} root={root} rootRow={rootRow} />
<TreeSplit leftEdge={leftEdge} node={node} rightEdge={rightEdge} root={root} rootRow={rootRow} topEdge={topEdge} />
) : (
<TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} />
<TreeGroup
leftEdge={leftEdge}
node={node}
parentAxis={parentAxis}
railSide={railSide}
rightEdge={rightEdge}
topEdge={topEdge}
/>
)
}
@@ -87,7 +87,21 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot)
}
export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boolean; rootRow?: boolean }) {
export function TreeSplit({
node,
root,
rootRow,
topEdge = false,
leftEdge = false,
rightEdge = false
}: {
node: SplitNode
root?: boolean
rootRow?: boolean
topEdge?: boolean
leftEdge?: boolean
rightEdge?: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const panes = useContributions('panes')
const hiddenPanes = useStore($hiddenTreePanes)
@@ -720,10 +734,13 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
)}
{!narrowCollapsed && (
<TreeNode
leftEdge={leftEdge && (!horizontal || i === visibleOrder[0])}
node={child}
parentAxis={axis}
railSide={horizontal ? railSideFor(i) : undefined}
rightEdge={rightEdge && (!horizontal || i === visibleOrder[visibleOrder.length - 1])}
rootRow={rootRow || childRootRow(child)}
topEdge={topEdge && (horizontal || i === visibleOrder[0])}
/>
)}
</div>
@@ -755,6 +755,11 @@ export function tabStripVisibleForGroup(group: GroupNode): boolean {
})
}
/** Shared target for tab-number hints and shortcut dispatch. */
export function treeTabSlotTarget(): GroupNode | null {
return tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
}
/** ⌘1…⌘9: activate the Nth *visible* tab of the target zone — the first of
* hovered / focused / workspace that is a real tab strip (≥2 shown panes).
* Pointing at the sidebar (or nothing) therefore still switches main's tabs
@@ -763,7 +768,7 @@ export function tabStripVisibleForGroup(group: GroupNode): boolean {
* must also route back to the chat) — or null so it falls back to its
* default (profile switch) when no zone qualifies. */
export function activateTreeTabSlot(slot: number): null | string {
const group = tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
const group = treeTabSlotTarget()
const panes = group ? shownPanesInGroup(group) : []
if (!group || slot < 1 || slot > panes.length) {
@@ -0,0 +1,45 @@
import { atom } from 'nanostores'
import { useEffect } from 'react'
import { isMacPlatform } from '@/lib/platform'
import { $capture } from '@/store/keybinds'
export const $heldTabModifier = atom(false)
/** One delayed observer; it never consumes or dispatches a key. */
export function useTabKeyHints() {
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
let down = false
const clear = () => {
clearTimeout(timer)
down = false
$heldTabModifier.set(false)
}
const sync = (event: KeyboardEvent) => {
const held = isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!held || event.altKey || event.shiftKey || event.isComposing || $capture.get()) {
clear()
} else if (!down) {
down = true
timer = setTimeout(() => $heldTabModifier.set(true), 400)
}
}
window.addEventListener('keydown', sync, true)
window.addEventListener('keyup', sync, true)
window.addEventListener('blur', clear)
const stopCapture = $capture.listen(clear)
return () => {
clear()
stopCapture()
window.removeEventListener('keydown', sync, true)
window.removeEventListener('keyup', sync, true)
window.removeEventListener('blur', clear)
}
}, [])
}
@@ -0,0 +1,97 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { isMacPlatform } from '@/lib/platform'
import { $bindings, $capture } from '@/store/keybinds'
import { group, split } from './model'
import { $activeTreeGroup, $hoveredTreeGroup, $layoutTree } from './store'
import { $heldTabModifier, useTabKeyHints } from './tab-key-hint-state'
import { TabKeyHint } from './tab-key-hints'
const disposers: Array<() => void> = []
const container = globalThis.document.createElement('div')
let root: ReturnType<typeof createRoot> | undefined
function Fixture() {
useTabKeyHints()
return (
<>
<TabKeyHint groupId="left" slot={1}>
<span>left dot</span>
</TabKeyHint>
<TabKeyHint groupId="right" slot={1}>
<span>right dot</span>
</TabKeyHint>
</>
)
}
function modifier(held: boolean) {
window.dispatchEvent(
new KeyboardEvent(held ? 'keydown' : 'keyup', {
key: isMacPlatform() ? 'Meta' : 'Control',
code: isMacPlatform() ? 'MetaLeft' : 'ControlLeft',
metaKey: isMacPlatform() && held,
ctrlKey: !isMacPlatform() && held
})
)
}
afterEach(() => {
act(() => root?.unmount())
container.remove()
disposers.splice(0).forEach(dispose => dispose())
$layoutTree.set(null)
$activeTreeGroup.set(null)
$hoveredTreeGroup.set(null)
$capture.set(null)
vi.useRealTimers()
})
it('reveals only the shortcut target after a hold and clears on release, blur or capture', () => {
vi.useFakeTimers()
for (const id of ['workspace', 'a', 'b', 'c']) {
disposers.push(registry.register({ area: 'panes', id, title: id, data: { placement: 'main' }, render: () => null }))
}
$layoutTree.set(split('row', [group(['workspace', 'a'], { id: 'left' }), group(['b', 'c'], { id: 'right' })]))
$activeTreeGroup.set('left')
$hoveredTreeGroup.set('right')
globalThis.document.body.append(container)
root = createRoot(container)
act(() => root!.render(<Fixture />))
act(() => {
modifier(true)
vi.advanceTimersByTime(399)
})
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
act(() => vi.advanceTimersByTime(1))
expect(container.querySelector('[data-tab-key-hint]')?.parentElement?.textContent).toBe('right dot1')
act(() => $hoveredTreeGroup.set(null))
expect(container.querySelector('[data-tab-key-hint]')?.parentElement?.textContent).toBe('left dot1')
const originalBindings = $bindings.get()
act(() => $bindings.set({ ...originalBindings, 'profile.switch.1': [] }))
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
act(() => $bindings.set(originalBindings))
act(() => modifier(false))
expect($heldTabModifier.get()).toBe(false)
act(() => {
modifier(true)
vi.advanceTimersByTime(400)
window.dispatchEvent(new Event('blur'))
})
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
act(() => {
modifier(true)
vi.advanceTimersByTime(400)
$capture.set('profile.switch.1')
})
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
})
@@ -0,0 +1,43 @@
import { useStore } from '@nanostores/react'
import { type ReactNode } from 'react'
import { $registryVersion } from '@/contrib/registry'
import { $comboIndex } from '@/store/keybinds'
import { $activeTreeGroup, $hiddenTreePanes, $hoveredTreeGroup, $layoutTree, treeTabSlotTarget } from './store'
import { $heldTabModifier } from './tab-key-hint-state'
/** Subscribe in the tiny lead, not the pane/transcript subtree. */
export function TabKeyHint({ children, groupId, slot }: { children: ReactNode; groupId: string; slot: number }) {
const held = useStore($heldTabModifier)
return (
<span className="relative flex shrink-0 items-center [&:has([data-tab-key-hint])>span:first-child]:invisible">
<span className="flex items-center">{children}</span>
{held && <HeldTabKeyHint groupId={groupId} slot={slot} />}
</span>
)
}
function HeldTabKeyHint({ groupId, slot }: { groupId: string; slot: number }) {
useStore($activeTreeGroup)
useStore($hoveredTreeGroup)
useStore($layoutTree)
useStore($hiddenTreePanes)
useStore($registryVersion)
const bindings = useStore($comboIndex)
if (slot > 9 || bindings.get(`mod+${slot}`) !== `profile.switch.${slot}` || treeTabSlotTarget()?.id !== groupId) {
return null
}
return (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[0.625rem] font-semibold leading-none tabular-nums text-(--ui-text-secondary)"
data-tab-key-hint={slot}
>
{slot}
</span>
)
}
+6 -3
View File
@@ -260,6 +260,8 @@ interface PaneTabStripProps extends React.ComponentProps<'div'> {
listRef?: React.Ref<HTMLDivElement>
/** Non-scrolling trailing chrome pinned to the right (the minimize chevron). */
trailing?: React.ReactNode
/** Top-edge panel header shares the native window-control band. */
titlebar?: boolean
}
/**
@@ -272,7 +274,7 @@ interface PaneTabStripProps extends React.ComponentProps<'div'> {
* `data-zone-tabstrip`, drop carets) ride on the usual div props.
*/
export const PaneTabStrip = React.forwardRef<HTMLDivElement, PaneTabStripProps>(function PaneTabStrip(
{ children, className, listRef, trailing, ...props },
{ children, className, listRef, trailing, titlebar = false, ...props },
ref
) {
return (
@@ -281,7 +283,8 @@ export const PaneTabStrip = React.forwardRef<HTMLDivElement, PaneTabStripProps>(
// as one piece of chrome with the titlebar above it. No bottom rule — the
// active tab's primary underline is the only seam.
className={cn(
'group/pane-header relative flex h-7 shrink-0 select-none bg-(--ui-sidebar-surface-background) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)]',
'group/pane-header relative flex min-w-0 shrink-0 select-none bg-(--ui-sidebar-surface-background) [--pane-tab-active-bg:var(--ui-sidebar-surface-background)]',
titlebar ? 'h-full flex-1 [-webkit-app-region:drag]' : 'h-7 [-webkit-app-region:no-drag]',
className
)}
ref={ref}
@@ -327,7 +330,7 @@ export function PaneStripGlyph({ active, disabled, icon, label, onSelect }: Omit
aria-label={label}
aria-pressed={active ?? undefined}
className={cn(
'self-center bg-transparent select-none',
'self-center bg-transparent select-none [-webkit-app-region:no-drag]',
active ? 'opacity-100' : 'opacity-60 hover:opacity-100'
)}
disabled={disabled}