* fix(desktop): unify dialog/overlay buttons on shared Button component
Replace raw <button> action/text controls across the modal layer (boot
failure, install, update, onboarding, clarify, model-visibility,
notifications, gateway menu) with the shared Button + its variants
(text / ghost / icon-xs). Drops the bespoke square-cornered styling so
every dialog matches the app's slightly-rounded button system, and
swaps clarify-tool's hardcoded "Skip" for the existing i18n string.
* feat(desktop): add dev-only dialog gallery for auditing overlays
A code-split, DEV-gated harness (toggle ⌘/Ctrl+Alt+Shift+D) that triggers
every dialog/overlay so their buttons can be eyeballed in one place:
store-driven overlays (boot failure, updates, notifications, sudo/secret)
plus in-place dialogs (confirm, profile create/rename, attach-url, model
picker/visibility, clarify, tool approval). Never ships to production.
* fix(desktop): use Ctrl+Shift+D for dialog gallery (mac-friendly)
The Cmd/Ctrl+Alt+Shift+D chord is impractical on macOS (Option mangles
the keypress). Ctrl+Shift+D is the same chord on every platform and uses
neither Cmd nor Option.
* fix(desktop): stop overriding button icon size to size-4
Action buttons hardcoded size-4 icons, overriding the Button component's
built-in size-3.5. That extra 2px is why boot-failure / onboarding / gateway
buttons looked chunkier than the settings "Apply" (size-3.5 spinner) despite
being the same component+size. Drop the overrides so icons inherit 3.5.
* feat(desktop): add BrandMark, use it in the updates overlay hero
New BrandMark renders the white logo.png on a hardcoded brand-blue tile
(#0000F2 light / #222 dark), replacing the generic Sparkles hero glyph in
the "update available" overlay. Trying it here first to iterate on the look.
NOTE: apps/desktop/public/logo.png is currently a 1x1 placeholder — the tile
renders now; the glyph appears once the real white logo art is dropped in.
* feat(desktop): add real logo.png asset, render it white in BrandMark
logo.png is blue line-art on transparent, so force it white via filter to
read on both the brand-blue (#0000F2) and near-black (#222) tiles. Bump the
glyph to 62% of the tile for the portrait aspect.
* fix(desktop): BrandMark renders logo as-is, no light bg/radius/padding
Drop the white filter, the hardcoded light-mode blue tile, the radius, and
the inner padding. Logo now fills the tile over a transparent surface in
light mode; dark keeps the #222 tile.
* fix(desktop): bump updates-overlay BrandMark to size-16
* feat(desktop): use downscaled karb.webp in BrandMark
Swap the BrandMark glyph to karb.webp, downscaled from 1129x1418/888KB to
254x320/81KB for the hero badge.
* feat(desktop): use nous-girl mark in BrandMark, invert in dark
Key the white background to transparent so only the black line-art remains
(384px/20KB webp). Light mode shows black art; dark mode flips it white via
dark:invert on the #222 tile. Drop the now-unused karb.webp and logo.png.
* fix(desktop): BrandMark uses nous-girl as-is (no transparent/invert)
The dark-mode invert read as a creepy negative. Use the opaque black-on-white
mark unchanged in both themes; drop the white-key, dark:invert, and #222 tile.
* fix(desktop): give BrandMark an explicit white bg tile
* fix(desktop): use nous-girl.jpg directly in BrandMark
* perf(desktop): downscale nous-girl.jpg to 256x256 (466KB -> 19KB)
* style(desktop): bump nous light --theme-secondary to 14% blue
* fix(desktop): outline button is transparent, not chrome-filled
The outline variant used bg-background (the chrome color), so on cards/overlays
with a different surface it rendered as an odd gray-blue fill (visible on the
boot overlay's Repair install / Use local gateway). Make it bg-transparent so
it inherits the surface like a real outline. Reverts the unrelated
--theme-secondary tweak.
* fix(desktop): clean outline button — thin border, no shadow/fill
Drop shadow-xs and the resting fills (light chrome bg, dark bg-input/30) so
outline is just a thin clean border with a subtle hover, in both themes.
* fix(desktop): stop forcing tertiary bg on outline buttons
A global [data-variant='outline'] rule set background: var(--ui-bg-tertiary),
which (attribute-selector specificity) overrode the cva bg-transparent — so
outline buttons always showed the pale tertiary fill on cards/overlays
regardless of the variant classes. Scope that fill to secondary only; outline
is now a true transparent border.
* style(desktop): unified overlay design system + restore #38631 flat-UI
Overlays/dialogs/toasts share a custom shadow-nous (downward-weighted) and
--stroke-nous hairline instead of hard borders: boot-failure, install,
notifications, model-picker, onboarding, prompt-overlays, updates, Dialog.
- button: outline is a 1px inset ring (no fill/shadow); chrome lives in Button
- BrandMark: 256px nous-girl mark replaces sparkle glyphs (updates/onboarding/about)
- onboarding: conditional header, lemniscate-bloom loaders, OTP device-code boxes,
NOUS CONNECTED hero (ascii decode) + cuneiform easter egg, "Begin" matrix exit
- shared LogView + ErrorState; math/ascii loaders over "Loading..." text
- appearance-settings flattened to SegmentedControl/ListRow; keybind-panel on
shadow-nous + text-variant reset
- restore flat-UI clobbered by #38631's stale-squash (4a1907bd1): command-center,
profiles, skills, messaging, cron de-boxed; shared SearchField + PAGE_INSET_X;
profiles back on OverlaySplitLayout; skills tabs+search one row, no row dividers
* refactor(desktop): clean pass — drop dead code, dedupe, fix stale docs
- log-view: drop unused `bare` prop + forwardRef (no caller uses ref)
- install-overlay: drop `stateOverride` (only the removed dev gallery used it)
- profiles: ProfilesViewProps down to { onClose } (drop vestigial section/titlebar)
- onboarding: hoist shared PROVIDER_ROW_CLASS (was duplicated 2x)
- brand-mark / error-state: tighten comments, fix stale AlertCircle reference
654 lines
23 KiB
TypeScript
654 lines
23 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { IconBookmark, IconBookmarkFilled, IconDownload, IconTrash } from '@tabler/icons-react'
|
|
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { PageLoader } from '@/components/page-loader'
|
|
import { Button } from '@/components/ui/button'
|
|
import { SearchField } from '@/components/ui/search-field'
|
|
import { SegmentedControl } from '@/components/ui/segmented-control'
|
|
import {
|
|
getActionStatus,
|
|
getLogs,
|
|
getStatus,
|
|
getUsageAnalytics,
|
|
restartGateway,
|
|
updateHermes
|
|
} from '@/hermes'
|
|
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
|
import { useI18n } from '@/i18n'
|
|
import { sessionTitle } from '@/lib/chat-runtime'
|
|
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
|
|
import { exportSession } from '@/lib/session-export'
|
|
import { cn } from '@/lib/utils'
|
|
import { upsertDesktopActionTask } from '@/store/activity'
|
|
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
|
import { $sessions, sessionPinId } from '@/store/session'
|
|
|
|
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
|
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
|
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
|
import { OverlayView } from '../overlays/overlay-view'
|
|
|
|
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
|
|
|
|
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
|
|
|
|
const USAGE_PERIODS = [7, 30, 90] as const
|
|
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
|
|
|
interface CommandCenterViewProps {
|
|
initialSection?: CommandCenterSection
|
|
onClose: () => void
|
|
onDeleteSession: (sessionId: string) => Promise<void>
|
|
// Accepted for call-site parity; navigation lives in the global Cmd+K palette.
|
|
onNavigateRoute?: (path: string) => void
|
|
onOpenSession: (sessionId: string) => void
|
|
}
|
|
|
|
function formatTimestamp(value?: number | null): string {
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
const date = new Date(value * 1000)
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return ''
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
|
}
|
|
|
|
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
const [debounced, setDebounced] = useState(value)
|
|
|
|
useEffect(() => {
|
|
const id = window.setTimeout(() => setDebounced(value), delayMs)
|
|
|
|
return () => window.clearTimeout(id)
|
|
}, [delayMs, value])
|
|
|
|
return debounced
|
|
}
|
|
|
|
function RowIconButton({
|
|
children,
|
|
className,
|
|
onClick,
|
|
title
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
onClick: (event: MouseEvent<HTMLButtonElement>) => void
|
|
title: string
|
|
}) {
|
|
return (
|
|
<Button
|
|
aria-label={title}
|
|
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
|
onClick={onClick}
|
|
size="icon-xs"
|
|
title={title}
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function EmptyPanel({ action, description, title }: { action?: ReactNode; description: string; title?: string }) {
|
|
return (
|
|
<div className="grid min-h-48 place-items-center px-6 text-center">
|
|
<div>
|
|
{title && (
|
|
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
|
)}
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{description}
|
|
</div>
|
|
{action && <div className="mt-3 flex justify-center">{action}</div>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
|
const { t } = useI18n()
|
|
const cc = t.commandCenter
|
|
const sessions = useStore($sessions)
|
|
const pinnedSessionIds = useStore($pinnedSessionIds)
|
|
|
|
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
|
|
|
|
const [query, setQuery] = useState('')
|
|
const [status, setStatus] = useState<StatusResponse | null>(null)
|
|
const [logs, setLogs] = useState<string[]>([])
|
|
const [systemLoading, setSystemLoading] = useState(false)
|
|
const [systemError, setSystemError] = useState('')
|
|
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
|
|
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
|
|
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
|
|
const [usageLoading, setUsageLoading] = useState(false)
|
|
const [usageError, setUsageError] = useState('')
|
|
const usageRequestRef = useRef(0)
|
|
|
|
const debouncedQuery = useDebouncedValue(query.trim(), 180)
|
|
|
|
const filteredSessions = useMemo(() => {
|
|
const sorted = [...sessions].sort((a, b) => {
|
|
const left = a.last_active || a.started_at || 0
|
|
const right = b.last_active || b.started_at || 0
|
|
|
|
return right - left
|
|
})
|
|
|
|
const needle = debouncedQuery.toLowerCase()
|
|
|
|
if (!needle) {
|
|
return sorted
|
|
}
|
|
|
|
return sorted.filter(session => {
|
|
const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()
|
|
|
|
return haystack.includes(needle)
|
|
})
|
|
}, [debouncedQuery, sessions])
|
|
|
|
const refreshSystem = useCallback(async () => {
|
|
setSystemLoading(true)
|
|
setSystemError('')
|
|
|
|
try {
|
|
const [nextStatus, nextLogs] = await Promise.all([
|
|
getStatus(),
|
|
getLogs({
|
|
file: 'agent',
|
|
lines: 120
|
|
})
|
|
])
|
|
|
|
setStatus(nextStatus)
|
|
setLogs(nextLogs.lines)
|
|
} catch (error) {
|
|
setSystemError(error instanceof Error ? error.message : String(error))
|
|
} finally {
|
|
setSystemLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const refreshUsage = useCallback(async (days: UsagePeriod) => {
|
|
const requestId = usageRequestRef.current + 1
|
|
usageRequestRef.current = requestId
|
|
setUsageLoading(true)
|
|
setUsageError('')
|
|
|
|
try {
|
|
const response = await getUsageAnalytics(days)
|
|
|
|
if (usageRequestRef.current === requestId) {
|
|
setUsage(response)
|
|
}
|
|
} catch (error) {
|
|
if (usageRequestRef.current === requestId) {
|
|
setUsageError(error instanceof Error ? error.message : String(error))
|
|
}
|
|
} finally {
|
|
if (usageRequestRef.current === requestId) {
|
|
setUsageLoading(false)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (section === 'system' && !status && !systemLoading) {
|
|
void refreshSystem()
|
|
}
|
|
}, [refreshSystem, section, status, systemLoading])
|
|
|
|
useEffect(() => {
|
|
if (section === 'usage') {
|
|
void refreshUsage(usagePeriod)
|
|
}
|
|
}, [refreshUsage, section, usagePeriod])
|
|
|
|
useRefreshHotkey(() => {
|
|
if (section === 'system') {
|
|
void refreshSystem()
|
|
} else if (section === 'usage') {
|
|
void refreshUsage(usagePeriod)
|
|
}
|
|
})
|
|
|
|
const sessionListHasResults = filteredSessions.length > 0
|
|
|
|
const runSystemAction = useCallback(
|
|
async (kind: 'restart' | 'update') => {
|
|
setSystemError('')
|
|
|
|
try {
|
|
const started = kind === 'restart' ? await restartGateway() : await updateHermes()
|
|
let nextStatus: ActionStatusResponse | null = null
|
|
|
|
for (let attempt = 0; attempt < 18; attempt += 1) {
|
|
await new Promise(resolve => window.setTimeout(resolve, 1200))
|
|
const polled = await getActionStatus(started.name, 180)
|
|
nextStatus = polled
|
|
setSystemAction(polled)
|
|
upsertDesktopActionTask(polled)
|
|
|
|
if (!polled.running) {
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!nextStatus) {
|
|
const pendingStatus = {
|
|
exit_code: null,
|
|
lines: [cc.actionStartedWaiting],
|
|
name: started.name,
|
|
pid: started.pid,
|
|
running: true
|
|
}
|
|
|
|
setSystemAction(pendingStatus)
|
|
upsertDesktopActionTask(pendingStatus)
|
|
}
|
|
} catch (error) {
|
|
setSystemError(error instanceof Error ? error.message : String(error))
|
|
} finally {
|
|
void refreshSystem()
|
|
}
|
|
},
|
|
[cc, refreshSystem]
|
|
)
|
|
|
|
return (
|
|
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
|
<OverlaySplitLayout>
|
|
<OverlaySidebar>
|
|
{SECTIONS.map(value => (
|
|
<OverlayNavItem
|
|
active={section === value}
|
|
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
|
key={value}
|
|
label={cc.sections[value]}
|
|
onClick={() => setSection(value)}
|
|
/>
|
|
))}
|
|
</OverlaySidebar>
|
|
|
|
<OverlayMain>
|
|
<header className="mb-4 flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-[length:var(--conversation-text-font-size)] font-semibold text-foreground">
|
|
{cc.sections[section]}
|
|
</h2>
|
|
<p className="mt-0.5 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{cc.sectionDescriptions[section]}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{section === 'sessions' && (
|
|
<SearchField
|
|
containerClassName="max-w-[40vw]"
|
|
onChange={next => setQuery(next)}
|
|
placeholder={cc.searchPlaceholder}
|
|
value={query}
|
|
/>
|
|
)}
|
|
{section === 'usage' && (
|
|
<SegmentedControl
|
|
onChange={id => setUsagePeriod(Number(id) as UsagePeriod)}
|
|
options={USAGE_PERIODS.map(value => ({ id: String(value), label: cc.days(value) }))}
|
|
value={String(usagePeriod)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{section === 'sessions' ? (
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
{!sessionListHasResults ? (
|
|
<EmptyPanel description={debouncedQuery ? cc.noResults : cc.noSessions} />
|
|
) : (
|
|
<ul>
|
|
{filteredSessions.map(session => {
|
|
const pinId = sessionPinId(session)
|
|
const pinned = pinnedSessionIds.includes(pinId)
|
|
|
|
return (
|
|
<li className="group flex items-center gap-2 py-2" key={session.id}>
|
|
<button
|
|
className="min-w-0 flex-1 text-left"
|
|
onClick={() => onOpenSession(session.id)}
|
|
type="button"
|
|
>
|
|
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
|
{sessionTitle(session)}
|
|
</div>
|
|
<div className="truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{formatTimestamp(session.last_active || session.started_at)}
|
|
</div>
|
|
</button>
|
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
<RowIconButton
|
|
onClick={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
|
title={pinned ? cc.unpinSession : cc.pinSession}
|
|
>
|
|
{pinned ? (
|
|
<IconBookmarkFilled className="size-3.5" />
|
|
) : (
|
|
<IconBookmark className="size-3.5" />
|
|
)}
|
|
</RowIconButton>
|
|
<RowIconButton
|
|
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
|
title={cc.exportSession}
|
|
>
|
|
<IconDownload className="size-3.5" />
|
|
</RowIconButton>
|
|
<RowIconButton
|
|
className="hover:text-destructive"
|
|
onClick={() => void onDeleteSession(session.id)}
|
|
title={cc.deleteSession}
|
|
>
|
|
<IconTrash className="size-3.5" />
|
|
</RowIconButton>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : section === 'usage' ? (
|
|
<UsagePanel
|
|
error={usageError}
|
|
loading={usageLoading}
|
|
onRefresh={() => void refreshUsage(usagePeriod)}
|
|
period={usagePeriod}
|
|
usage={usage}
|
|
/>
|
|
) : (
|
|
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
|
|
<div className="border-b border-(--ui-stroke-tertiary) pb-4">
|
|
{status ? (
|
|
<div className="grid gap-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
'size-2 rounded-full',
|
|
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
|
|
)}
|
|
/>
|
|
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
|
{status.gateway_running ? cc.gatewayRunning : cc.gatewayStopped}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{cc.hermesActiveSessions(status.version, status.active_sessions)}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
|
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
|
|
{cc.restartMessaging}
|
|
</Button>
|
|
<Button onClick={() => void runSystemAction('update')} size="xs" variant="textStrong">
|
|
{cc.updateHermes}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{systemAction && (
|
|
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{systemAction.name} ·{' '}
|
|
{systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<PageLoader className="min-h-32" label={cc.loadingStatus} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-col">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
|
{cc.recentLogs}
|
|
</span>
|
|
{systemError && (
|
|
<span className="inline-flex items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
|
<AlertCircle className="size-3.5" />
|
|
{systemError}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)">
|
|
{logs.length ? logs.join('\n') : cc.noLogs}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</OverlayMain>
|
|
</OverlaySplitLayout>
|
|
</OverlayView>
|
|
)
|
|
}
|
|
|
|
function formatTokens(value: null | number | undefined): string {
|
|
const num = Number(value || 0)
|
|
|
|
if (num >= 1_000_000) {
|
|
return `${(num / 1_000_000).toFixed(1)}M`
|
|
}
|
|
|
|
if (num >= 1_000) {
|
|
return `${(num / 1_000).toFixed(1)}K`
|
|
}
|
|
|
|
return num.toLocaleString()
|
|
}
|
|
|
|
function formatCost(value: null | number | undefined): string {
|
|
const num = Number(value || 0)
|
|
|
|
if (num === 0) {
|
|
return '$0.00'
|
|
}
|
|
|
|
if (num < 0.01) {
|
|
return '<$0.01'
|
|
}
|
|
|
|
return `$${num.toFixed(2)}`
|
|
}
|
|
|
|
function formatInteger(value: null | number | undefined): string {
|
|
return Number(value ?? 0).toLocaleString()
|
|
}
|
|
|
|
interface UsagePanelProps {
|
|
error: string
|
|
loading: boolean
|
|
onRefresh: () => void
|
|
period: UsagePeriod
|
|
usage: AnalyticsResponse | null
|
|
}
|
|
|
|
function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProps) {
|
|
const { t } = useI18n()
|
|
const cc = t.commandCenter
|
|
const daily = useMemo(() => usage?.daily ?? [], [usage])
|
|
const totals = usage?.totals
|
|
const byModel = usage?.by_model ?? []
|
|
const topSkills = usage?.skills?.top_skills ?? []
|
|
|
|
const maxTokens = useMemo(() => {
|
|
if (!daily.length) {
|
|
return 1
|
|
}
|
|
|
|
return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1)
|
|
}, [daily])
|
|
|
|
if (!totals) {
|
|
return (
|
|
<div className="min-h-0 flex-1">
|
|
{loading ? (
|
|
<PageLoader className="min-h-48" label={cc.loadingUsage} />
|
|
) : (
|
|
<EmptyPanel
|
|
action={
|
|
<Button onClick={onRefresh} size="xs" variant="text">
|
|
{cc.retry}
|
|
</Button>
|
|
}
|
|
description={cc.noUsage(period)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto pb-2">
|
|
{error && (
|
|
<span className="inline-flex items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
|
<AlertCircle className="size-3.5" />
|
|
{error}
|
|
</span>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 border-b border-(--ui-stroke-tertiary) pb-5 sm:grid-cols-4">
|
|
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
|
|
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
|
|
<UsageStat
|
|
label={cc.statTokens}
|
|
value={`${formatTokens(totals.total_input)} / ${formatTokens(totals.total_output)}`}
|
|
/>
|
|
<UsageStat
|
|
hint={totals.total_actual_cost > 0 ? cc.actualCost(formatCost(totals.total_actual_cost)) : undefined}
|
|
label={cc.statCost}
|
|
value={formatCost(totals.total_estimated_cost)}
|
|
/>
|
|
</div>
|
|
|
|
<section>
|
|
<div className="mb-2 flex items-baseline justify-between">
|
|
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
|
{cc.dailyTokens}
|
|
</span>
|
|
<span className="flex items-center gap-3 text-[0.65rem] text-(--ui-text-tertiary)">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="size-2 rounded-[1px] bg-[color:var(--dt-primary)]/60" /> {cc.input}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="size-2 rounded-[1px] bg-emerald-500/70" /> {cc.output}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{daily.length === 0 ? (
|
|
<div className="grid h-24 place-items-center text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{cc.noDailyActivity}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex h-24 items-end gap-px">
|
|
{daily.map(entry => {
|
|
const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96)
|
|
const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96)
|
|
|
|
return (
|
|
<div
|
|
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
|
|
key={entry.day}
|
|
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
|
|
>
|
|
<div
|
|
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
|
|
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
|
|
/>
|
|
<div
|
|
className="w-full bg-emerald-500/60"
|
|
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="mt-1 flex justify-between text-[0.6rem] text-(--ui-text-tertiary)">
|
|
<span>{daily[0]?.day}</span>
|
|
<span>{daily[daily.length - 1]?.day}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<div className="grid min-h-0 gap-x-8 gap-y-5 border-t border-(--ui-stroke-tertiary) pt-5 sm:grid-cols-2">
|
|
<UsageList
|
|
emptyLabel={cc.noModelUsage}
|
|
rows={byModel.slice(0, 6).map(entry => ({
|
|
key: entry.model,
|
|
label: entry.model,
|
|
value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} · ${formatCost(entry.estimated_cost)}`
|
|
}))}
|
|
title={cc.topModels}
|
|
/>
|
|
<UsageList
|
|
emptyLabel={cc.noSkillActivity}
|
|
rows={topSkills.slice(0, 6).map(entry => ({
|
|
key: entry.skill,
|
|
label: entry.skill,
|
|
value: cc.actions(entry.total_count.toLocaleString())
|
|
}))}
|
|
title={cc.topSkills}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function UsageList({
|
|
emptyLabel,
|
|
rows,
|
|
title
|
|
}: {
|
|
emptyLabel: string
|
|
rows: Array<{ key: string; label: string; value: string }>
|
|
title: string
|
|
}) {
|
|
return (
|
|
<section className="min-w-0">
|
|
<div className="mb-1.5 text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
|
{title}
|
|
</div>
|
|
{rows.length === 0 ? (
|
|
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{emptyLabel}
|
|
</div>
|
|
) : (
|
|
<ul>
|
|
{rows.map(row => (
|
|
<li className="flex items-center justify-between gap-2 py-1.5" key={row.key}>
|
|
<span className="min-w-0 truncate font-mono text-[0.7rem] text-foreground">{row.label}</span>
|
|
<span className="shrink-0 text-[0.65rem] text-(--ui-text-tertiary)">{row.value}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className="text-[0.625rem] font-medium uppercase tracking-[0.12em] text-(--ui-text-tertiary)">{label}</div>
|
|
<div className="mt-1 truncate text-base font-semibold tracking-tight text-foreground">{value}</div>
|
|
{hint && <div className="mt-0.5 truncate text-[0.62rem] text-(--ui-text-tertiary)">{hint}</div>}
|
|
</div>
|
|
)
|
|
}
|