mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 11:32:22 +00:00
Add the in-window floating pet (sprite, speech bubble, contact shadow, profile-scoped, resize-safe) and a pop-out always-on-top overlay window with gestures and notifications. Add the Cmd+K pet picker page plus the appearance gallery and size slider in settings. Includes the pet stores, electron overlay wiring, i18n strings, and store tests.
212 lines
9.8 KiB
TypeScript
212 lines
9.8 KiB
TypeScript
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 { 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/Linux reserve for the OS-painted 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.
|
|
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
|
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
|
|
|
// 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,
|
|
// 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} />
|
|
)}
|
|
|
|
<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>
|
|
)
|
|
}
|