feat: file preview and folder tree etc
This commit is contained in:
@@ -2,17 +2,18 @@ import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$fileBrowserOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
@@ -21,41 +22,27 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
sidebar: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
leftStatusbarItems,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
sidebar,
|
||||
statusbarItems,
|
||||
titlebarTools,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarWidth = useStore($sidebarWidth)
|
||||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
// The shell grid should describe visible app chrome only. Titlebar buttons
|
||||
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
|
||||
// column for a closed sidebar pushes/clips the actual chat surface.
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
||||
@@ -63,18 +50,28 @@ export function AppShell({
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
|
||||
const showInspectorRail = rightRailOpen && inspectorOpen
|
||||
// The static system cluster (file-browser, haptics, settings) is hardcoded
|
||||
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
|
||||
// separate cluster anchored further left.
|
||||
const SYSTEM_TOOL_COUNT = 3
|
||||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
|
||||
|
||||
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
|
||||
// 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 ? FILE_BROWSER_DEFAULT_WIDTH : systemToolsWidth
|
||||
|
||||
const previewColumn = showPreviewRail
|
||||
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width))))`
|
||||
: '0px'
|
||||
|
||||
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
|
||||
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
|
||||
// (the leftmost button is at `tools-right + gap + paneCount * size`).
|
||||
// Otherwise the static cluster's footprint is enough.
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
|
||||
: systemToolsWidth
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -115,69 +112,51 @@ export function AppShell({
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--preview-width': previewWidth,
|
||||
'--sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--chat-center-offset': '0px',
|
||||
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--shell-preview-pane-width': previewColumn,
|
||||
'--shell-right-sidebar-width': inspectorColumn,
|
||||
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
|
||||
'--shell-preview-toolbar-gap': showPreviewRail
|
||||
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
|
||||
: '0px',
|
||||
// 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': '0.75rem',
|
||||
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
|
||||
'--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
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main
|
||||
className="relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none"
|
||||
style={
|
||||
{
|
||||
'--inspector-col': inspectorColumn,
|
||||
'--preview-col': previewColumn,
|
||||
gridTemplateColumns: shellGridColumns,
|
||||
gridTemplateRows: 'minmax(0,1fr) auto'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<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]"
|
||||
/>
|
||||
|
||||
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</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}
|
||||
{children}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--pane-chat-sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
</PaneShell>
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['models', 'sessions', 'system'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const currentView = appViewForPath(location.pathname)
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const agentsOpen = currentView === 'agents'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const overlayOpen = OVERLAY_VIEWS.has(currentView)
|
||||
|
||||
// Overlay routes (settings/command-center/agents) stash the underlying path
|
||||
// so closing them returns there instead of bouncing to /.
|
||||
const returnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlayOpen) {
|
||||
returnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [location.hash, location.pathname, location.search, overlayOpen])
|
||||
|
||||
const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>(
|
||||
() => SECTIONS.find(value => value === new URLSearchParams(location.search).get('section')),
|
||||
[location.search]
|
||||
)
|
||||
|
||||
const openCommandCenterSection = useCallback(
|
||||
(section: CommandCenterSection) => navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const closeOverlayToPreviousRoute = useCallback(
|
||||
() => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const toggleCommandCenter = useCallback(() => {
|
||||
if (commandCenterOpen) {
|
||||
closeOverlayToPreviousRoute()
|
||||
} else {
|
||||
navigate(COMMAND_CENTER_ROUTE)
|
||||
}
|
||||
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
|
||||
|
||||
const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate])
|
||||
|
||||
return {
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getLogs, getStatus } from '@/hermes'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
const LOG_TAIL = 12
|
||||
|
||||
export function useStatusSnapshot(gatewayState: string | undefined) {
|
||||
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
|
||||
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [next, logs] = await Promise.all([
|
||||
getStatus(),
|
||||
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
|
||||
])
|
||||
|
||||
if (cancelled) {return}
|
||||
|
||||
setStatusSnapshot(next)
|
||||
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
|
||||
} catch {
|
||||
// Keep last snapshot through transient gateway flaps.
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const timer = window.setInterval(() => void refresh(), REFRESH_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState])
|
||||
|
||||
return { gatewayLogLines, statusSnapshot }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { buildGatewayLogItems } from '@/lib/gateway-events'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
browseSessionCwd: () => Promise<void>
|
||||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
gatewayLogLines: readonly string[]
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
|
||||
export function useStatusbarItems({
|
||||
agentsOpen,
|
||||
browseSessionCwd,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
gatewayLogLines,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentBranch = useStore($currentBranch)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
|
||||
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() =>
|
||||
Object.entries(statusSnapshot?.gateway_platforms || {})
|
||||
.sort(([l], [r]) => l.localeCompare(r))
|
||||
.map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })),
|
||||
[statusSnapshot?.gateway_platforms]
|
||||
)
|
||||
|
||||
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() => [
|
||||
{ id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') },
|
||||
...buildGatewayLogItems(gatewayLogLines),
|
||||
...platformMenuItems
|
||||
],
|
||||
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning } = useMemo(() => {
|
||||
const actions = Object.values(desktopActionTasks)
|
||||
const running = actions.filter(t => t.status.running).length
|
||||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
|
||||
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
|
||||
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
|
||||
|
||||
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
onSelect: toggleCommandCenter,
|
||||
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
|
||||
detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
|
||||
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
||||
id: 'gateway-health',
|
||||
label: 'Gateway',
|
||||
menuClassName: 'w-96',
|
||||
menuItems: gatewayMenuItems,
|
||||
title: 'Gateway and platform health',
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
className: cn(
|
||||
agentsOpen && 'bg-accent/55 text-foreground',
|
||||
bgFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
),
|
||||
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
|
||||
icon:
|
||||
bgFailed > 0 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
),
|
||||
id: 'agents',
|
||||
label: 'Agents',
|
||||
onSelect: openAgents,
|
||||
title: agentsOpen ? 'Close agents' : 'Open agents',
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
[
|
||||
agentsOpen,
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
gatewayMenuItems,
|
||||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
toggleCommandCenter
|
||||
]
|
||||
)
|
||||
|
||||
const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
detail: <LiveDuration since={turnStartedAt} />,
|
||||
hidden: !busy || !turnStartedAt,
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
id: 'running-timer',
|
||||
label: 'Running',
|
||||
title: 'Current turn elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: contextBar || undefined,
|
||||
hidden: !contextUsage,
|
||||
id: 'context-usage',
|
||||
label: contextUsage,
|
||||
title: 'Context usage',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: <LiveDuration since={sessionStartedAt} />,
|
||||
hidden: !sessionStartedAt,
|
||||
id: 'session-timer',
|
||||
label: 'Session',
|
||||
title: 'Runtime session elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: currentProvider || '',
|
||||
icon: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
id: 'cwd',
|
||||
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
|
||||
onSelect: () => void browseSessionCwd(),
|
||||
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
hidden: !currentBranch,
|
||||
icon: <GitBranch className="size-3" />,
|
||||
id: 'branch',
|
||||
label: currentBranch,
|
||||
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
|
||||
variant: 'text'
|
||||
}
|
||||
],
|
||||
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
() => [...coreLeftStatusbarItems, ...extraLeftItems],
|
||||
[coreLeftStatusbarItems, extraLeftItems]
|
||||
)
|
||||
|
||||
const statusbarItems = useMemo(
|
||||
() => [...extraRightItems, ...coreRightStatusbarItems],
|
||||
[coreRightStatusbarItems, extraRightItems]
|
||||
)
|
||||
|
||||
return { leftStatusbarItems, statusbarItems }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
@@ -56,17 +56,17 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{leftItems.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{items.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
@@ -147,7 +147,7 @@ function StatusbarItemView({
|
||||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
|
||||
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90', item.className)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { FolderOpen, NotebookTabs, Settings, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
@@ -29,21 +29,15 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
||||
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
showInspectorToggle: boolean
|
||||
tools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
showInspectorToggle,
|
||||
tools = [],
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
@@ -70,17 +64,16 @@ export function TitlebarControls({
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightToolbarTools: TitlebarTool[] = [
|
||||
...tools,
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: inspectorOpen,
|
||||
hidden: !showInspectorToggle,
|
||||
icon: <SlidersHorizontal />,
|
||||
id: 'session-details',
|
||||
label: inspectorOpen ? 'Hide session details' : 'Show session details',
|
||||
active: fileBrowserOpen,
|
||||
icon: <FolderOpen />,
|
||||
id: 'file-browser',
|
||||
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -101,6 +94,9 @@ export function TitlebarControls({
|
||||
}
|
||||
]
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -114,15 +110,32 @@ export function TitlebarControls({
|
||||
))}
|
||||
</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="Pane controls"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px 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="App controls"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{rightToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user