mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 11:32:22 +00:00
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import type { ComponentProps, ReactNode } from 'react'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { Tip } from '@/components/ui/tooltip'
|
|
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,
|
|
toggleSidebarOpen
|
|
} from '@/store/layout'
|
|
|
|
import { appViewForPath, isOverlayView } from '../routes'
|
|
|
|
import { titlebarButtonClass } from './titlebar'
|
|
|
|
export interface TitlebarTool {
|
|
id: string
|
|
label: string
|
|
active?: boolean
|
|
className?: string
|
|
disabled?: boolean
|
|
hidden?: boolean
|
|
href?: string
|
|
icon: ReactNode
|
|
onSelect?: () => void
|
|
title?: string
|
|
to?: string
|
|
}
|
|
|
|
export type TitlebarToolSide = 'left' | 'right'
|
|
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
|
|
|
|
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
|
leftTools?: readonly TitlebarTool[]
|
|
tools?: readonly TitlebarTool[]
|
|
onOpenSettings: () => void
|
|
}
|
|
|
|
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
|
const { t } = useI18n()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const hapticsMuted = useStore($hapticsMuted)
|
|
const fileBrowserOpen = useStore($fileBrowserOpen)
|
|
const sidebarOpen = useStore($sidebarOpen)
|
|
const panesFlipped = useStore($panesFlipped)
|
|
|
|
const toggleHaptics = () => {
|
|
if (!hapticsMuted) {
|
|
triggerHaptic('tap')
|
|
}
|
|
|
|
toggleHapticsMuted()
|
|
|
|
if (hapticsMuted) {
|
|
window.requestAnimationFrame(() => triggerHaptic('success'))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
const leftToolbarTools: TitlebarTool[] = [
|
|
{
|
|
icon: <Codicon name="layout-sidebar-left" />,
|
|
id: 'sidebar',
|
|
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
|
|
onSelect: () => {
|
|
triggerHaptic('tap')
|
|
leftEdge.toggle()
|
|
}
|
|
},
|
|
{
|
|
icon: <Codicon name="arrow-swap" />,
|
|
id: 'flip-panes',
|
|
label: t.titlebar.swapSidebarSides,
|
|
onSelect: () => {
|
|
triggerHaptic('tap')
|
|
togglePanesFlipped()
|
|
},
|
|
title: t.titlebar.swapSidebarSidesTitle
|
|
},
|
|
...leftTools
|
|
]
|
|
|
|
const rightSidebarTool: TitlebarTool = {
|
|
icon: <Codicon name="layout-sidebar-right" />,
|
|
id: 'right-sidebar',
|
|
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
|
|
onSelect: () => {
|
|
triggerHaptic('tap')
|
|
rightEdge.toggle()
|
|
}
|
|
}
|
|
|
|
// Static system tools — always pinned to the screen's right edge.
|
|
const systemTools: TitlebarTool[] = [
|
|
{
|
|
active: hapticsMuted,
|
|
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
|
id: 'haptics',
|
|
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',
|
|
label: t.titlebar.openSettings,
|
|
onSelect: () => {
|
|
triggerHaptic('open')
|
|
onOpenSettings()
|
|
}
|
|
}
|
|
]
|
|
|
|
// While a full-screen overlay (settings, command center, …) is open it should
|
|
// visually own the window. These control clusters are `fixed` at a higher
|
|
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
|
|
// and let the overlay's own chrome (close button, drag region) take over.
|
|
if (isOverlayView(appViewForPath(location.pathname))) {
|
|
return null
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
aria-label={t.shell.windowControls}
|
|
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
|
>
|
|
{leftToolbarTools
|
|
.filter(tool => !tool.hidden)
|
|
.map(tool => (
|
|
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
|
))}
|
|
</div>
|
|
|
|
{/*
|
|
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
|
|
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
|
|
to either the static cluster's width (file-browser closed → cluster
|
|
sits flush against system tools) or the file-browser pane's width
|
|
(file-browser open → cluster sits flush against the file-browser pane,
|
|
i.e. at the preview pane's right edge). No margin hacks needed.
|
|
*/}
|
|
{visiblePaneTools.length > 0 && (
|
|
<div
|
|
aria-label={t.shell.paneControls}
|
|
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
|
>
|
|
{visiblePaneTools.map(tool => (
|
|
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
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 => (
|
|
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
|
))}
|
|
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
|
|
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
|
|
// Titlebar actions never show an active background — state reads from the
|
|
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
|
|
// for a11y.
|
|
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
|
|
|
|
if (tool.href) {
|
|
return (
|
|
<Tip label={tool.title ?? tool.label}>
|
|
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
|
<a
|
|
aria-label={tool.label}
|
|
href={tool.href}
|
|
onPointerDown={event => event.stopPropagation()}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{tool.icon}
|
|
</a>
|
|
</Button>
|
|
</Tip>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Tip label={tool.title ?? tool.label}>
|
|
<Button
|
|
aria-label={tool.label}
|
|
aria-pressed={tool.active ?? undefined}
|
|
className={className}
|
|
disabled={tool.disabled}
|
|
onClick={() => {
|
|
if (tool.to) {
|
|
navigate(tool.to)
|
|
}
|
|
|
|
tool.onSelect?.()
|
|
}}
|
|
onPointerDown={event => event.stopPropagation()}
|
|
size="icon-titlebar"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{tool.icon}
|
|
</Button>
|
|
</Tip>
|
|
)
|
|
}
|