feat(desktop): focused-session-aware titlebar + statusbar

fix/verification-admin-route-recovery
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent 2afbe77763
commit 0f398f8e9c
8 changed files with 174 additions and 330 deletions

View File

@ -1,237 +0,0 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { FloatingPet } from '@/components/pet/floating-pet'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_PANE_ID,
setSidebarOpen
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
function subscribeWindowSize(cb: () => void) {
window.addEventListener('resize', cb)
window.addEventListener('fullscreenchange', cb)
return () => {
window.removeEventListener('resize', cb)
window.removeEventListener('fullscreenchange', cb)
}
}
const viewportIsFullscreen = () =>
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
// macOS, where window controls sit on the left and are reported via
// windowButtonPosition instead). The right tool cluster has to clear them.
// Prefer the EXACT width measured from the live Window Controls Overlay
// (precise + self-correcting across DPI/host themes); fall back to the static
// reservation the main process sends when the WCO API isn't available.
const measuredOverlayWidth = useWindowControlsOverlayWidth()
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// When the native window controls overlay our titlebar band — Windows and
// WSLg both paint Electron's Window Controls Overlay and report
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
// normally lives IN that band) would render at y=0 under the fixed titlebar
// tool cluster and collide with it. Drop the right rail one titlebar-height so
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
// The static system cluster (haptics, profiles, settings, right-sidebar) is
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
// in a separate cluster anchored further left.
//
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
// between the pane-tool cluster and the system cluster so they don't sit
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
// to keep the formula generic for any pane-tool count.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const fileBrowserWidth =
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
// Where the pane-tool cluster's right edge sits, measured from the inner
// titlebar padding (--titlebar-tools-right). Two anchors:
// - file-browser closed → flush against static cluster's left edge
// - file-browser open → flush against the file-browser pane's left edge
// (= preview pane's right edge)
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
// is enough.
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
: systemToolsWidth
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"
onOpenChange={setSidebarOpen}
open={sidebarOpen}
style={
{
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
// Drops the right rail below the titlebar band when the OS/host paints
// window controls over it (Windows/WSLg); 0px elsewhere.
'--right-rail-top-inset': rightRailTopInset,
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
// Sourced from the layout store rather than the PaneShell-emitted
// --pane-*-width vars because the titlebar is a sibling of PaneShell
// and CSS variables resolve at the consumer's scope.
'--shell-preview-toolbar-gap': previewToolbarGap
} as CSSProperties
}
>
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
{nativeOverlayWidth > 0 && (
<div
aria-hidden
className="pointer-events-none fixed right-0 top-0 z-[4] h-(--titlebar-height) w-(--titlebar-tools-right) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
/>
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
{/* The compact pop-out drops the statusbar it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
<KeybindPanel />
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay not just the chat view. */}
<NotificationStack />
{/* Petdex floating mascot in-window, always-on-top, reactive to agent
activity. Renders nothing unless a pet is installed + enabled. */}
<FloatingPet />
</SidebarProvider>
)
}

View File

@ -36,21 +36,27 @@ function useGatewayLogTail(): string[] {
useEffect(() => {
let cancelled = false
const load = () =>
getLogs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (cancelled) {
return
}
// async: getLogs THROWS (not rejects) when the desktop bridge is missing
// (plain-browser mode) — a sync throw here would take down the root
// error boundary before the .catch even attaches.
const load = async () => {
try {
const res = await getLogs({ file: 'gui', lines: LOG_TAIL })
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
})
.catch(() => {})
if (cancelled) {
return
}
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
} catch {
// Bridge/gateway unavailable — keep the last tail.
}
}
void load()
const timer = window.setInterval(load, LOG_POLL_MS)

View File

@ -0,0 +1,6 @@
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
// MessagingView, ChatPreviewRail, …). The live implementation is the
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
type Side = 'left' | 'right'
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void

View File

@ -22,9 +22,13 @@ import {
$connection,
$currentCwd,
$currentUsage,
$selectedStoredSessionId,
$sessions,
$sessionStartedAt,
$turnStartedAt,
sessionMatchesStoredId
} from '@/store/session'
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
@ -40,6 +44,8 @@ import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
function workspaceLabel(cwd: string): string {
const normalized = cwd.replace(/[\\/]+$/, '')
const leaf = normalized.split(/[\\/]/).filter(Boolean).pop()
@ -80,15 +86,15 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const fileMenu = t.fileMenu
const activeSessionId = useStore($activeSessionId)
const primaryActiveSessionId = useStore($activeSessionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
const terminalTakeover = useStore($terminalTakeover)
const busy = useStore($busy)
const primaryBusy = useStore($busy)
const currentCwd = useStore($currentCwd)
const currentUsage = useStore($currentUsage)
const primaryUsage = useStore($currentUsage)
const gatewayRestarting = useStore($gatewayRestarting)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const primarySessionStartedAt = useStore($sessionStartedAt)
const primaryTurnStartedAt = useStore($turnStartedAt)
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
@ -97,11 +103,42 @@ export function useStatusbarItems({
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
// The FOCUSED session (interacted tile, else the primary — the same
// derivation the titlebar title follows): every session-scoped readout
// below (context count, timers, busy pulse) tracks it, so clicking into a
// tile makes the statusbar describe THAT session.
const focusedStoredSessionId = useStore($focusedStoredSessionId)
const focusedRuntimeId = useStore($focusedRuntimeId)
const focusedState = useStore($focusedSessionState)
const sessions = useStore($sessions)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
const activeSessionId = primaryFocused ? primaryActiveSessionId : (focusedRuntimeId ?? null)
const busy = primaryFocused ? primaryBusy : Boolean(focusedState?.busy)
// EMPTY_USAGE (module constant) keeps the fallback referentially stable —
// a fresh `{...}` each render would bust the usage-label memos below.
const currentUsage = primaryFocused ? primaryUsage : (focusedState?.usage ?? EMPTY_USAGE)
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : (focusedState?.turnStartedAt ?? null)
// A tile's session-start comes from its stored row (the cache only knows
// runtime state); seconds → ms.
const focusedRow = focusedStoredSessionId
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
: null
const sessionStartedAt = primaryFocused
? primarySessionStartedAt
: focusedRow?.started_at
? focusedRow.started_at * 1000
: null
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
const gatewayMenuContent = useMemo(
() => (close: () => void) => (
<GatewayMenuPanel

View File

@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Kbd, KbdCombo } from '@/components/ui/kbd'
import { useContributions } from '@/contrib/react/use-contributions'
import { useI18n } from '@/i18n'
import {
KEYBIND_ACTIONS,
allKeybindActions,
KEYBIND_CATEGORIES,
KEYBIND_PANEL_ACTION,
KEYBIND_READONLY,
type KeybindActionMeta,
type KeybindReadonly
type KeybindReadonly,
KEYBINDS_AREA
} from '@/lib/keybinds/actions'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
@ -22,6 +24,7 @@ import {
$capture,
$keybindPanelOpen,
beginCapture,
bindingsFor,
closeKeybindPanel,
conflictsFor,
endCapture,
@ -36,6 +39,9 @@ export function KeybindPanel() {
const bindings = useStore($bindings)
const k = t.keybinds
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
// Subscribe so contributed actions appear/disappear live in the map.
useContributions(KEYBINDS_AREA)
const actionList = allKeybindActions()
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
@ -74,7 +80,7 @@ export function KeybindPanel() {
{/* Body */}
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
{KEYBIND_CATEGORIES.map(category => {
const actions = KEYBIND_ACTIONS.filter(
const actions = actionList.filter(
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
)
@ -139,9 +145,12 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
const bindings = useStore($bindings)
const capture = useStore($capture)
const combos = bindings[action.id] ?? []
// bindingsFor resolves stored overrides for late-registered (contributed)
// actions too — $bindings only carries built-ins, so a raw lookup would show
// the default instead of the user's rebinding for a plugin/contrib action.
const combos = bindingsFor(action.id, bindings)
const capturing = capture === action.id
const label = k.actions[action.id] ?? action.id
const label = k.actions[action.id] ?? action.label ?? action.id
const isDefault = arraysEqual(combos, [...action.defaults])
const conflict = combos

View File

@ -25,6 +25,10 @@ export interface StatusbarMenuItem {
export interface StatusbarItem {
id: string
/** Escape hatch: render an arbitrary node into the bar (own state, tooltip,
* events). When set, it OWNS the slot label/variant/onSelect are ignored.
* This is how a plugin drops a full stateful React component into the bar. */
render?: () => ReactNode
label?: ReactNode
detail?: ReactNode
icon?: ReactNode
@ -92,6 +96,11 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const [menuOpen, setMenuOpen] = useState(false)
// Render escape hatch: the contribution owns its own chrome/state/tooltip.
if (item.render) {
return <>{item.render()}</>
}
const content = (
<>
{item.icon}
@ -100,7 +109,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</>
)
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
if (item.variant === 'menu' && (item.menuContent || !!item.menuItems?.length)) {
// The `Tip` helper can't wrap a menu: its TooltipTrigger needs a DOM child,
// but DropdownMenu's Root renders no element, so the hover listeners never
// land on the button and the tooltip silently never shows. Compose the two

View File

@ -1,7 +1,9 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
@ -9,10 +11,8 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleKeybindPanel } from '@/store/keybinds'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
@ -32,7 +32,7 @@ export interface TitlebarTool {
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
onSelect?: (event?: MouseEvent) => void
title?: string
to?: string
}
@ -46,14 +46,60 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
onOpenSettings: () => void
}
/**
* The layout button's glyph. Morphs into its composite reset form the
* layout icon wearing a small counter-clockwise arrow badge ("layout, back
* to how it was") ONLY while the pointer is on the button AND /Ctrl is
* held: hover gates via CSS (`group/tool` on the button), the modifier via
* the window listener. Pressing the modifier elsewhere changes nothing.
*/
function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
return (
<>
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
<Codicon name="layout" />
</span>
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
<Codicon name="layout" />
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
</span>
</span>
</>
)
}
/** Live /Ctrl tracking mod-click affordances telegraph themselves (the
* layout button morphs into its reset form while the modifier is down). */
function useModifierHeld(): boolean {
const [held, setHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setHeld(event.metaKey || event.ctrlKey)
const clear = () => setHeld(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return held
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const modHeld = useModifierHeld()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
@ -67,14 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
// 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,
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
// show/hide affordances.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftToolbarTools: TitlebarTool[] = [
{
@ -111,6 +156,26 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
// Static system tools — always pinned to the screen's right edge.
const systemTools: TitlebarTool[] = [
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
// LayoutGlyph) — the mod-click telegraphs itself before it happens.
icon: <LayoutGlyph modHeld={modHeld} />,
id: 'layout',
label: t.titlebar.layoutEditor,
onSelect: event => {
if (event?.metaKey || event?.ctrlKey) {
triggerHaptic('warning')
resetLayoutTree()
return
}
triggerHaptic('open')
toggleLayoutEditMode()
},
title: t.titlebar.layoutEditorTitle
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
icon: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
toggleKeybindPanel()
}
},
{
icon: <Codicon name="settings-gear" />,
id: 'settings',
@ -147,8 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
const visiblePaneTools = tools.filter(tool => !tool.hidden)
return (
@ -187,10 +241,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
aria-label={t.shell.appControls}
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visibleSystemToolsBeforeSettings.map(tool => (
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
</>
@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
aria-pressed={tool.active ?? undefined}
className={className}
disabled={tool.disabled}
onClick={() => {
onClick={event => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
tool.onSelect?.(event)
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"

View File

@ -1,39 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
type Side = 'left' | 'right'
type Groups<T> = Record<Side, Record<string, readonly T[]>>
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
interface GroupRegistry<T> {
flat: { left: T[]; right: T[] }
set: GroupSetter<T>
}
export function useGroupRegistry<T>(): GroupRegistry<T> {
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
setGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (items.length === 0) {
delete next[side][id]
} else {
next[side][id] = items
}
return next
})
}, [])
const flat = useMemo(
() => ({
left: Object.values(groups.left).flat(),
right: Object.values(groups.right).flat()
}),
[groups]
)
return { flat, set }
}