From bbe212de9ba159720a448b5fdb0ef4b9c242e06f Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 9 Sep 2026 21:37:34 -0700 Subject: [PATCH] feat(desktop): keep panel tabs in the titlebar --- apps/desktop/src/app/contrib/controller.tsx | 74 +--- apps/desktop/src/app/contrib/wiring.tsx | 22 +- .../src/app/shell/titlebar-controls.tsx | 130 ++----- .../pane-shell/tree/renderer/drag-session.ts | 14 + .../pane-shell/tree/renderer/index.tsx | 13 +- .../tree/renderer/tree-group.test.tsx | 22 ++ .../pane-shell/tree/renderer/tree-group.tsx | 364 ++++++++++-------- .../pane-shell/tree/renderer/tree-node.tsx | 19 +- .../pane-shell/tree/renderer/tree-split.tsx | 19 +- .../src/components/pane-shell/tree/store.ts | 7 +- .../pane-shell/tree/tab-key-hint-state.ts | 45 +++ .../pane-shell/tree/tab-key-hints.test.tsx | 97 +++++ .../pane-shell/tree/tab-key-hints.tsx | 43 +++ apps/desktop/src/components/ui/pane-tab.tsx | 9 +- 14 files changed, 533 insertions(+), 345 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/tab-key-hint-state.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/tab-key-hints.test.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/tab-key-hints.tsx diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index a1fa39755b..bf8d6162ad 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 ( -
- -
- ) -} - 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. */} -
- {/* 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). */} - - {/* - 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 && ( -
- {visiblePaneTools.map(tool => ( - - ))} -
- )} -
- {visibleSystemTools.map(tool => ( - - ))} +
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts index 594ef3ed53..cfce03016f 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -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 } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx index 3145f563d3..b4f3f7b1a8 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -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; } `} - + {/* Non-tiling panes: fixed cards above the tree, outside every zone. */} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx index 548e714adf..849efeb5e8 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx @@ -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: () =>
Terminal
+ }) + vi.stubGlobal('CSS', { escape: (value: string) => value }) + render() + const zone = container!.querySelector('[data-tree-group]')! + const strip = zone.querySelector('[data-zone-tabstrip]')! + const header = zone.querySelector('[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', diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 234cbdfce7..a582eb8d42 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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(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({
)} - {/* Header: the shared pane tab strip (PaneTabStrip + PaneTab). */} - {headerVisible && ( - - - // 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 && ( - - )} - - - } - > - {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) && ( +
+ {topEdge && leftEdge && ( +
+
+
+
+
+
+
+ )} + {headerVisible ? ( + + { + // 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 && ( + + )} + + + } + > + {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 = ( - 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 = ( + 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 ? ( - {chrome.tabLead()} - ) : null} - {tabLabel(paneId)} - - ) + // 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 ? ( + + + {chrome.tabLead()} + + + ) : null} + {tabLabel(paneId)} + + ) - // 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 {chrome.tabWrap ? chrome.tabWrap(tab) : tab} - })} + // 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 {chrome.tabWrap ? chrome.tabWrap(tab) : tab} + })} - {/* 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 && ( - noteActiveTreeGroup(node.id)} - > - } - label={newTab.label} - onSelect={newTab.onSelect} - /> - - )} - - + {newTab && !node.minimized && ( + noteActiveTreeGroup(node.id)} + > + } + label={newTab.label} + onSelect={newTab.onSelect} + /> + + )} + + + ) : ( +
+ )} + {topEdge && rightEdge && ( +
+
+
+
+ )} +
)} {/* 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)' diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx index 16c27304c5..7aafbdaded 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx @@ -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' ? ( - + ) : ( - + ) } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index f51069d6ed..b2c024eb50 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -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(null) const panes = useContributions('panes') const hiddenPanes = useStore($hiddenTreePanes) @@ -720,10 +734,13 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo )} {!narrowCollapsed && ( )}
diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 65647eea76..3050b6cf22 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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) { diff --git a/apps/desktop/src/components/pane-shell/tree/tab-key-hint-state.ts b/apps/desktop/src/components/pane-shell/tree/tab-key-hint-state.ts new file mode 100644 index 0000000000..6a4ac65c65 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/tab-key-hint-state.ts @@ -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 | 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) + } + }, []) +} diff --git a/apps/desktop/src/components/pane-shell/tree/tab-key-hints.test.tsx b/apps/desktop/src/components/pane-shell/tree/tab-key-hints.test.tsx new file mode 100644 index 0000000000..437189913d --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/tab-key-hints.test.tsx @@ -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 | undefined + +function Fixture() { + useTabKeyHints() + + return ( + <> + + left dot + + + right dot + + + ) +} + +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()) + 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() +}) diff --git a/apps/desktop/src/components/pane-shell/tree/tab-key-hints.tsx b/apps/desktop/src/components/pane-shell/tree/tab-key-hints.tsx new file mode 100644 index 0000000000..bb3fbe8242 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/tab-key-hints.tsx @@ -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 ( + + {children} + {held && } + + ) +} + +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 ( + + ) +} diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 51a8893f74..ebaf755f48 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -260,6 +260,8 @@ interface PaneTabStripProps extends React.ComponentProps<'div'> { listRef?: React.Ref /** 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(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( // 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}