Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import { CommandCenterView } from './index'
|
||||
|
||||
// #99410: the Command Center → Sessions trash button hard-deleted the session
|
||||
// (row + messages + request_dump files) instantly, with no confirmation —
|
||||
// e6708af1f (#61470) guarded the sidebar/tab/header entry points via
|
||||
// DeleteSessionDialog but missed this independent one. The delete must be
|
||||
// gated behind the shared ConfirmDialog: no onDeleteSession call on the trash
|
||||
// click alone, the call only after an explicit confirm, and never on cancel.
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<typeof HermesApi>()),
|
||||
getActionStatus: vi.fn(() => Promise.resolve({ running: false })),
|
||||
getLogs: vi.fn(() => Promise.resolve({ lines: [] })),
|
||||
getStatus: vi.fn(() => Promise.resolve({})),
|
||||
getUsageAnalytics: vi.fn(() => Promise.resolve({})),
|
||||
restartGateway: vi.fn(),
|
||||
updateHermes: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() }))
|
||||
vi.mock('./maintenance', () => ({ MaintenancePanel: () => null }))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SESSION: SessionInfo = {
|
||||
ended_at: null,
|
||||
id: 'sess-1',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_756_600_000,
|
||||
message_count: 3,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
started_at: 1_756_500_000,
|
||||
title: 'Precious conversation'
|
||||
} as SessionInfo
|
||||
|
||||
function renderCommandCenter(onDeleteSession: (id: string) => Promise<void>) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<CommandCenterView
|
||||
initialSection="sessions"
|
||||
onClose={() => {}}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onOpenSession={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Command Center session delete confirmation (#99410)', () => {
|
||||
beforeEach(() => {
|
||||
$sessions.set([SESSION])
|
||||
})
|
||||
|
||||
it('does NOT delete on trash click alone — a confirm dialog appears instead', async () => {
|
||||
const onDeleteSession = vi.fn(() => Promise.resolve())
|
||||
renderCommandCenter(onDeleteSession)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete session' }))
|
||||
|
||||
expect(onDeleteSession).not.toHaveBeenCalled()
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('deletes only after explicit confirm', async () => {
|
||||
const onDeleteSession = vi.fn(() => Promise.resolve())
|
||||
renderCommandCenter(onDeleteSession)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete session' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
await waitFor(() => expect(onDeleteSession).toHaveBeenCalledWith('sess-1'))
|
||||
expect(dialog).toBeTruthy()
|
||||
})
|
||||
|
||||
it('cancel closes the dialog without deleting', async () => {
|
||||
const onDeleteSession = vi.fn(() => Promise.resolve())
|
||||
renderCommandCenter(onDeleteSession)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete session' }))
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(onDeleteSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,707 @@
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, SessionInfo, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
BookmarkFilled,
|
||||
Download,
|
||||
MessageCircle,
|
||||
Trash2,
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { fmtDateTime } from '@/lib/time'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
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, OverlayNav, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { MaintenancePanel } from './maintenance'
|
||||
|
||||
export type CommandCenterSection = 'maintenance' | 'sessions' | 'system' | 'usage'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'usage', 'maintenance'] as const satisfies readonly CommandCenterSection[]
|
||||
|
||||
const LOG_FILES = ['agent', 'errors', 'gateway', 'desktop'] as const
|
||||
const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
|
||||
|
||||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
|
||||
// Stable empty arrays so the selector returns the same reference when we're
|
||||
// not on the Sessions tab — useStoreSelector bails out on Object.is, so the
|
||||
// component never re-renders from $sessions ticks while on System/Usage/etc.
|
||||
const EMPTY_SESSIONS: readonly never[] = []
|
||||
const EMPTY_PINNED: readonly string[] = []
|
||||
|
||||
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 fmtDateTime.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 (
|
||||
<Tip label={title}>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
onClick={onClick}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
// $sessions ticks on every streaming token (title updates, new sessions),
|
||||
// but we only need the data on the Sessions tab. Subscribe conditionally so
|
||||
// the System/Usage/Maintenance tabs don't re-render on every stream delta.
|
||||
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
|
||||
const sessions = useStoreSelector($sessions, s => (section === 'sessions' ? s : EMPTY_SESSIONS))
|
||||
const pinnedSessionIds = useStoreSelector($pinnedSessionIds, s => (section === 'sessions' ? s : EMPTY_PINNED))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [pendingDelete, setPendingDelete] = useState<SessionInfo | null>(null)
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [logFile, setLogFile] = useState<(typeof LOG_FILES)[number]>('agent')
|
||||
const [logLevel, setLogLevel] = useState<(typeof LOG_LEVELS)[number]>('ALL')
|
||||
const [logQuery, setLogQuery] = useState('')
|
||||
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: logFile,
|
||||
level: logLevel,
|
||||
lines: 200
|
||||
})
|
||||
])
|
||||
|
||||
setStatus(nextStatus)
|
||||
setLogs(nextLogs.lines)
|
||||
} catch (error) {
|
||||
setSystemError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setSystemLoading(false)
|
||||
}
|
||||
}, [logFile, logLevel])
|
||||
|
||||
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(() => {
|
||||
// Refetch when the panel opens and whenever the log file/level filters
|
||||
// change (refreshSystem's identity tracks them).
|
||||
if (section === 'system') {
|
||||
void refreshSystem()
|
||||
}
|
||||
}, [refreshSystem, section])
|
||||
|
||||
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
|
||||
|
||||
// Client-side substring filter over the fetched tail (matches `hermes logs --search`).
|
||||
const visibleLogs = useMemo(() => {
|
||||
const needle = logQuery.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return logs
|
||||
}
|
||||
|
||||
return logs.filter(line => line.toLowerCase().includes(needle))
|
||||
}, [logQuery, logs])
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
})),
|
||||
[cc, section, setSection]
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav groups={navGroups} />
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
|
||||
{/* Redundant on narrow — the nav dropdown already names the section. */}
|
||||
<div className="min-w-0 max-[47.5rem]:hidden">
|
||||
<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 ? <BookmarkFilled className="size-3.5" /> : <Bookmark className="size-3.5" />}
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
||||
title={cc.exportSession}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => setPendingDelete(session)}
|
||||
title={cc.deleteSession}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</RowIconButton>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : section === 'usage' ? (
|
||||
<UsagePanel
|
||||
error={usageError}
|
||||
loading={usageLoading}
|
||||
onRefresh={() => void refreshUsage(usagePeriod)}
|
||||
period={usagePeriod}
|
||||
usage={usage}
|
||||
/>
|
||||
) : section === 'maintenance' ? (
|
||||
<MaintenancePanel />
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
|
||||
<div>
|
||||
{status ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3 max-[47.5rem]:flex-col max-[47.5rem]:gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 shrink-0 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 flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap max-[47.5rem]:whitespace-normal">
|
||||
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
|
||||
{cc.restartGateway}
|
||||
</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 pt-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
{cc.recentLogs}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ResponsiveTabs
|
||||
align="end"
|
||||
onChange={id => setLogFile(id as (typeof LOG_FILES)[number])}
|
||||
tabs={LOG_FILES.map(value => ({ id: value, label: value }))}
|
||||
value={logFile}
|
||||
/>
|
||||
<ResponsiveTabs
|
||||
align="end"
|
||||
onChange={id => setLogLevel(id as (typeof LOG_LEVELS)[number])}
|
||||
tabs={LOG_LEVELS.map(value => ({
|
||||
id: value,
|
||||
label: value === 'ALL' ? 'all' : value.toLowerCase()
|
||||
}))}
|
||||
value={logLevel}
|
||||
/>
|
||||
<SearchField
|
||||
containerClassName="w-44"
|
||||
onChange={next => setLogQuery(next)}
|
||||
placeholder={cc.logSearchPlaceholder}
|
||||
value={logQuery}
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
<LogTail
|
||||
className="flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)"
|
||||
emptyLabel={cc.noLogs}
|
||||
lines={systemLoading && logs.length === 0 ? null : visibleLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
{pendingDelete && (
|
||||
<ConfirmDialog
|
||||
busyLabel={t.sidebar.row.deleting}
|
||||
confirmLabel={t.common.delete}
|
||||
description={t.sidebar.row.deleteDesc(sessionTitle(pendingDelete))}
|
||||
destructive
|
||||
doneLabel={t.sidebar.row.deleted}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={() => void onDeleteSession(pendingDelete.id)}
|
||||
open
|
||||
title={t.sidebar.row.deleteTitle}
|
||||
/>
|
||||
)}
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
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 py-2 sm:grid-cols-3">
|
||||
<UsageStat label={cc.statSessions} value={compactNumber(totals.total_sessions)} />
|
||||
<UsageStat label={cc.statApiCalls} value={compactNumber(totals.total_api_calls)} />
|
||||
<UsageStat
|
||||
label={cc.statTokens}
|
||||
value={`${compactNumber(totals.total_input)} / ${compactNumber(totals.total_output)}`}
|
||||
/>
|
||||
</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 ${compactNumber(entry.input_tokens)} · out ${compactNumber(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 pt-1 sm:grid-cols-2">
|
||||
<UsageList
|
||||
emptyLabel={cc.noModelUsage}
|
||||
rows={byModel.slice(0, 6).map(entry => ({
|
||||
key: entry.model,
|
||||
label: entry.model,
|
||||
value: `${compactNumber((entry.input_tokens || 0) + (entry.output_tokens || 0))}`
|
||||
}))}
|
||||
title={cc.topModels}
|
||||
/>
|
||||
<UsageList
|
||||
emptyLabel={cc.noSkillActivity}
|
||||
rows={topSkills.slice(0, 6).map(entry => ({
|
||||
key: entry.skill,
|
||||
label: entry.skill,
|
||||
value: cc.actions(compactNumber(entry.total_count))
|
||||
}))}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type ActionResponse,
|
||||
type CuratorStatusResponse,
|
||||
type DebugShareResponse,
|
||||
getActionStatus,
|
||||
getCuratorStatus,
|
||||
getMemoryStatus,
|
||||
type MemoryStatusResponse,
|
||||
resetMemory,
|
||||
runBackup,
|
||||
runCurator,
|
||||
runDebugShare,
|
||||
runDoctor,
|
||||
runSecurityAudit,
|
||||
setCuratorPaused
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { confirm } from '@/store/confirm'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ActionStatusResponse } from '@/types/hermes'
|
||||
|
||||
const ACTION_POLL_MS = 1200
|
||||
const ACTION_POLL_LIMIT = 240 // ~5 minutes of polling before giving up.
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (size >= 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
if (size >= 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
return `${size} B`
|
||||
}
|
||||
|
||||
/** Maintenance panel — desktop parity for `hermes doctor` / `security audit` /
|
||||
* `backup` / `debug share` / `curator` / `memory` (the dashboard System page's
|
||||
* ops section). Spawn-based actions tail their logs inline via the shared
|
||||
* /api/actions status endpoint. */
|
||||
export function MaintenancePanel() {
|
||||
const { t } = useI18n()
|
||||
const mm = t.commandCenter.maintenance
|
||||
|
||||
const [actionName, setActionName] = useState<null | string>(null)
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(null)
|
||||
const [curator, setCurator] = useState<CuratorStatusResponse | null>(null)
|
||||
const [curatorBusy, setCuratorBusy] = useState(false)
|
||||
const [memory, setMemory] = useState<MemoryStatusResponse | null>(null)
|
||||
const [memoryBusy, setMemoryBusy] = useState(false)
|
||||
const [share, setShare] = useState<DebugShareResponse | null>(null)
|
||||
const [sharing, setSharing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getCuratorStatus()
|
||||
.then(next => !cancelled && setCurator(next))
|
||||
.catch(() => {})
|
||||
getMemoryStatus()
|
||||
.then(next => !cancelled && setMemory(next))
|
||||
.catch(() => {})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
// Tail the most recently launched spawn action.
|
||||
useEffect(() => {
|
||||
if (!actionName) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let polls = 0
|
||||
let timer: null | number = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getActionStatus(actionName, 200)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setActionStatus(status)
|
||||
upsertDesktopActionTask(status)
|
||||
polls += 1
|
||||
|
||||
if (status.running && polls < ACTION_POLL_LIMIT) {
|
||||
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
|
||||
}
|
||||
} catch {
|
||||
// Status endpoint hiccup — stop tailing; the activity rail still has the task.
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [actionName])
|
||||
|
||||
const launch = useCallback(
|
||||
async (label: string, start: () => Promise<ActionResponse>) => {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const started = await start()
|
||||
setActionStatus(null)
|
||||
setActionName(started.name)
|
||||
notify({ kind: 'success', title: mm.actionStarted(label), message: '' })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
notifyError(err, mm.actionFailed(label))
|
||||
}
|
||||
},
|
||||
[mm]
|
||||
)
|
||||
|
||||
const shareDebug = useCallback(async () => {
|
||||
setSharing(true)
|
||||
setShare(null)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
setShare(await runDebugShare())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
notifyError(err, mm.debugShareFailed)
|
||||
} finally {
|
||||
setSharing(false)
|
||||
}
|
||||
}, [mm])
|
||||
|
||||
const toggleCurator = useCallback(async () => {
|
||||
if (!curator) {
|
||||
return
|
||||
}
|
||||
|
||||
setCuratorBusy(true)
|
||||
|
||||
try {
|
||||
const next = !curator.paused
|
||||
await setCuratorPaused(next)
|
||||
setCurator({ ...curator, paused: next })
|
||||
} catch (err) {
|
||||
notifyError(err, mm.actionFailed(mm.curator))
|
||||
} finally {
|
||||
setCuratorBusy(false)
|
||||
}
|
||||
}, [curator, mm])
|
||||
|
||||
const doResetMemory = useCallback(
|
||||
async (target: 'all' | 'memory' | 'user', label: string) => {
|
||||
if (!(await confirm({ destructive: true, title: mm.resetConfirm(label) }))) {
|
||||
return
|
||||
}
|
||||
|
||||
setMemoryBusy(true)
|
||||
|
||||
try {
|
||||
const result = await resetMemory(target)
|
||||
notify({ kind: 'success', title: mm.resetDone(result.deleted.join(', ') || label), message: '' })
|
||||
setMemory(await getMemoryStatus())
|
||||
} catch (err) {
|
||||
notifyError(err, mm.resetFailed)
|
||||
} finally {
|
||||
setMemoryBusy(false)
|
||||
}
|
||||
},
|
||||
[mm]
|
||||
)
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionLabel>{mm.runOps}</SectionLabel>
|
||||
<OpRow
|
||||
description={mm.doctorDesc}
|
||||
disabled={actionStatus?.running === true}
|
||||
label={mm.doctor}
|
||||
onRun={() => void launch(mm.doctor, runDoctor)}
|
||||
/>
|
||||
<OpRow
|
||||
description={mm.securityAuditDesc}
|
||||
disabled={actionStatus?.running === true}
|
||||
label={mm.securityAudit}
|
||||
onRun={() => void launch(mm.securityAudit, runSecurityAudit)}
|
||||
/>
|
||||
<OpRow
|
||||
description={mm.backupDesc}
|
||||
disabled={actionStatus?.running === true}
|
||||
label={mm.backup}
|
||||
onRun={() => void launch(mm.backup, runBackup)}
|
||||
/>
|
||||
<OpRow
|
||||
description={mm.debugShareDesc}
|
||||
disabled={sharing}
|
||||
label={sharing ? mm.debugShareRunning : mm.debugShare}
|
||||
onRun={() => void shareDebug()}
|
||||
/>
|
||||
|
||||
{share && Object.keys(share.urls).length > 0 && (
|
||||
<div className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
|
||||
<div className="mb-1.5 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{mm.debugShareLinks}
|
||||
</div>
|
||||
{Object.entries(share.urls).map(([key, url]) => (
|
||||
<div className="flex items-center justify-between gap-2 py-1" key={key}>
|
||||
<span className="min-w-0 truncate font-mono text-[0.7rem]">
|
||||
{key}: {url}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void window.hermesDesktop.writeClipboard(url)
|
||||
notify({ durationMs: 1500, kind: 'success', message: mm.linkCopied })
|
||||
}}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{mm.copyLink}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionStatus && (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{mm.viewLog}
|
||||
{actionStatus.running && <span className="normal-case tracking-normal">{mm.running}</span>}
|
||||
</div>
|
||||
<pre
|
||||
className="max-h-48 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)"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{actionStatus.lines.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionLabel>{mm.curator}</SectionLabel>
|
||||
{!curator ? (
|
||||
<PageLoader className="min-h-16" label={mm.curator} />
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium">{mm.curator}</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
!curator.enabled
|
||||
? 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
: curator.paused
|
||||
? 'bg-amber-500/15 text-amber-400'
|
||||
: 'bg-emerald-500/15 text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{!curator.enabled ? mm.curatorDisabled : curator.paused ? mm.curatorPaused : mm.curatorActive}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{mm.curatorDesc}
|
||||
{' · '}
|
||||
{curator.last_run_at ? mm.curatorLastRun(curator.last_run_at) : mm.curatorNeverRan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{curator.enabled && (
|
||||
<Button disabled={curatorBusy} onClick={() => void toggleCurator()} size="xs" variant="text">
|
||||
{curator.paused ? mm.resume : mm.pause}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={actionStatus?.running === true}
|
||||
onClick={() => void launch(mm.curator, runCurator)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{mm.runNow}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionLabel>{mm.memoryData}</SectionLabel>
|
||||
{!memory ? (
|
||||
<PageLoader className="min-h-16" label={mm.memoryData} />
|
||||
) : (
|
||||
<div>
|
||||
<div className="py-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{mm.memoryDataDesc}
|
||||
{' · '}
|
||||
{mm.memoryProvider(memory.active || mm.builtinMemory)}
|
||||
</div>
|
||||
<MemoryFileRow
|
||||
busy={memoryBusy}
|
||||
label={mm.memoryFile}
|
||||
onReset={() => void doResetMemory('memory', mm.memoryFile)}
|
||||
resetLabel={mm.resetMemory}
|
||||
size={memory.builtin_files.memory}
|
||||
sizeLabel={memory.builtin_files.memory > 0 ? formatBytes(memory.builtin_files.memory) : mm.empty}
|
||||
/>
|
||||
<MemoryFileRow
|
||||
busy={memoryBusy}
|
||||
label={mm.userFile}
|
||||
onReset={() => void doResetMemory('user', mm.userFile)}
|
||||
resetLabel={mm.resetUser}
|
||||
size={memory.builtin_files.user}
|
||||
sizeLabel={memory.builtin_files.user > 0 ? formatBytes(memory.builtin_files.user) : mm.empty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="mb-1.5 text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpRow({
|
||||
description,
|
||||
disabled,
|
||||
label,
|
||||
onRun
|
||||
}: {
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onRun: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{label}</div>
|
||||
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<Button disabled={disabled} onClick={onRun} size="xs" variant="textStrong">
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryFileRow({
|
||||
busy,
|
||||
label,
|
||||
onReset,
|
||||
resetLabel,
|
||||
size,
|
||||
sizeLabel
|
||||
}: {
|
||||
busy: boolean
|
||||
label: string
|
||||
onReset: () => void
|
||||
resetLabel: string
|
||||
size: number
|
||||
sizeLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium">{label}</span>
|
||||
<span className="ml-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={busy || size <= 0}
|
||||
onClick={onReset}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{resetLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user