diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx deleted file mode 100644 index efe903596..000000000 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ /dev/null @@ -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
'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
, 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 ( - 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 && ( - - )} - - {nativeOverlayWidth > 0 && ( -
- )} - -
- -
- - {overlays} - - {/* Keybind map dialog (titlebar ⌨ button / ⌘/). */} - - - {/* Mounted at the shell root (after overlays) so success/error toasts - surface above every route and overlay — not just the chat view. */} - - - {/* Petdex floating mascot — in-window, always-on-top, reactive to agent - activity. Renders nothing unless a pet is installed + enabled. */} - - - ) -} diff --git a/apps/desktop/src/app/shell/gateway-menu-panel.tsx b/apps/desktop/src/app/shell/gateway-menu-panel.tsx index c5e542f36..bb753670b 100644 --- a/apps/desktop/src/app/shell/gateway-menu-panel.tsx +++ b/apps/desktop/src/app/shell/gateway-menu-panel.tsx @@ -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) diff --git a/apps/desktop/src/app/shell/group-setter.ts b/apps/desktop/src/app/shell/group-setter.ts new file mode 100644 index 000000000..b0f212f15 --- /dev/null +++ b/apps/desktop/src/app/shell/group-setter.ts @@ -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 = (id: string, items: readonly T[], side?: Side) => void diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 021832021..b4aae4295 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -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) => ( >(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 */}
{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 diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 9a9f09808..096af07a9 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -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 }) { 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 diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index fb0cae430..15ca9c9cb 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -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 ( + <> + + + + + + + + + + + ) +} + +/** 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: , + 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: , @@ -118,15 +183,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, onSelect: toggleHaptics }, - { - icon: , - id: 'keybinds', - label: t.titlebar.openKeybinds, - onSelect: () => { - triggerHaptic('open') - toggleKeybindPanel() - } - }, { icon: , 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 => ( ))} - {settingsTool && }
@@ -228,12 +281,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType { + onClick={event => { if (tool.to) { navigate(tool.to) } - tool.onSelect?.() + tool.onSelect?.(event) }} onPointerDown={event => event.stopPropagation()} size="icon-titlebar" diff --git a/apps/desktop/src/app/shell/use-group-registry.ts b/apps/desktop/src/app/shell/use-group-registry.ts deleted file mode 100644 index ef78dfde0..000000000 --- a/apps/desktop/src/app/shell/use-group-registry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' - -type Side = 'left' | 'right' -type Groups = Record> - -export type GroupSetter = (id: string, items: readonly T[], side?: Side) => void - -interface GroupRegistry { - flat: { left: T[]; right: T[] } - set: GroupSetter -} - -export function useGroupRegistry(): GroupRegistry { - const [groups, setGroups] = useState>({ left: {}, right: {} }) - - const set = useCallback>((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 } -}