feat(desktop): focused-session-aware titlebar + statusbar
parent
2afbe77763
commit
0f398f8e9c
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Reference in New Issue