mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
Merge pull request #54558 from NousResearch/bb/overlay-panels
feat(desktop): shared overlay Panel primitive for cron/profiles/agents
This commit is contained in:
commit
83f09f52f9
23 changed files with 945 additions and 521 deletions
|
|
@ -19,7 +19,7 @@ import {
|
|||
type SubagentStreamEntry
|
||||
} from '@/store/subagents'
|
||||
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
|
|
@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
|||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={t.agents.close}
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
<Panel closeLabel={t.agents.close} onClose={onClose}>
|
||||
{tree.length === 0 ? (
|
||||
<PanelEmpty description={t.agents.emptyDesc} icon="hubot" title={t.agents.emptyTitle} />
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={t.agents.subtitle} title={t.agents.title} />
|
||||
<SubagentTree tree={tree} />
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway,
|
|||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
BookmarkFilled,
|
||||
Download,
|
||||
MessageCircle,
|
||||
Trash2
|
||||
} from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
|
|
@ -263,7 +272,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
||||
icon={value === 'sessions' ? MessageCircle : value === 'system' ? Activity : BarChart3}
|
||||
key={value}
|
||||
label={cc.sections[value]}
|
||||
onClick={() => setSection(value)}
|
||||
|
|
@ -361,7 +370,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
/>
|
||||
) : (
|
||||
<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">
|
||||
<div>
|
||||
{status ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
@ -406,7 +415,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex min-h-0 flex-col pt-2">
|
||||
<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}
|
||||
|
|
@ -503,7 +512,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
|||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 border-b border-(--ui-stroke-tertiary) pb-5 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
|
||||
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
|
||||
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
|
||||
<UsageStat
|
||||
|
|
@ -563,7 +572,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
|||
)}
|
||||
</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">
|
||||
<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 => ({
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
|
|
@ -30,14 +29,28 @@ import {
|
|||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import {
|
||||
Panel,
|
||||
PanelAction,
|
||||
PanelAddButton,
|
||||
PanelBlock,
|
||||
PanelBody,
|
||||
PanelDetail,
|
||||
PanelEmpty,
|
||||
PanelHeader,
|
||||
PanelList,
|
||||
PanelListRow,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
type PanelPillTone,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
|
@ -56,7 +69,7 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
|||
{ value: 'custom' }
|
||||
]
|
||||
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
const STATE_TONE: Record<string, PanelPillTone> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
|
|
@ -66,13 +79,6 @@ const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
|||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
|
|
@ -321,7 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
|
||||
pendingScrollRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
document.querySelector(`[data-panel-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}, [selectedJob])
|
||||
|
||||
|
|
@ -406,60 +412,66 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<Panel closeLabel={c.close} onClose={onClose}>
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : totalCount === 0 ? (
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
{c.newCron}
|
||||
</Button>
|
||||
}
|
||||
description={c.emptyDescNew}
|
||||
icon="watch"
|
||||
title={c.emptyTitleNew}
|
||||
/>
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
{totalCount > 0 && (
|
||||
<SearchField
|
||||
aria-label={c.search}
|
||||
containerClassName="mb-1 w-full px-2"
|
||||
onChange={setQuery}
|
||||
placeholder={c.search}
|
||||
value={query}
|
||||
/>
|
||||
)}
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
<>
|
||||
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchLabel={c.search}
|
||||
searchPlaceholder={c.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
|
||||
)}
|
||||
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
</PanelList>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onDelete={() => setPendingDelete(selectedJob)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Clock className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PanelEmpty description={c.emptyDescSearch} icon="search" />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</PanelBody>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
|
@ -488,42 +500,32 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</OverlayView>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobListRow({
|
||||
active,
|
||||
c,
|
||||
job,
|
||||
menu,
|
||||
onSelect
|
||||
}: {
|
||||
active: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
menu?: React.ReactNode
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
data-cron-row={job.id}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
</span>
|
||||
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
|
||||
</button>
|
||||
<PanelListRow
|
||||
active={active}
|
||||
dotClassName={STATE_DOT[state] ?? 'bg-muted-foreground'}
|
||||
menu={menu}
|
||||
onSelect={onSelect}
|
||||
rowKey={job.id}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -531,8 +533,6 @@ function CronJobDetail({
|
|||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenSession,
|
||||
onPauseResume,
|
||||
onTrigger
|
||||
|
|
@ -540,8 +540,6 @@ function CronJobDetail({
|
|||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
|
|
@ -552,69 +550,49 @@ function CronJobDetail({
|
|||
const prompt = jobPrompt(job)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
{c.triggerNow}
|
||||
</Button>
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
{c.edit}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
|
||||
{job.last_error && (
|
||||
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
<PanelDetail>
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{jobTitle(job)}</h3>
|
||||
<PanelPill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</PanelPill>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</PanelAction>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
|
||||
{c.triggerNow}
|
||||
</PanelAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelMeta
|
||||
rows={[
|
||||
{ label: c.frequencyLabel, value: jobScheduleDisplay(job) },
|
||||
{ label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) },
|
||||
{ label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) },
|
||||
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver }
|
||||
]}
|
||||
/>
|
||||
|
||||
{job.last_error ? (
|
||||
<div className="flex items-start gap-1.5 rounded bg-destructive/10 p-2 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="min-w-0 break-words">{job.last_error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{prompt ? (
|
||||
<section className="space-y-1.5">
|
||||
<PanelSectionLabel>{c.promptLabel}</PanelSectionLabel>
|
||||
<PanelBlock>{prompt}</PanelBlock>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
</PanelDetail>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -685,10 +663,10 @@ function CronJobRuns({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<PanelSectionLabel className="mb-1.5">
|
||||
{c.runHistory}
|
||||
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
|
||||
</div>
|
||||
</PanelSectionLabel>
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
|
|
@ -699,13 +677,13 @@ function CronJobRuns({
|
|||
<div className="flex flex-col gap-px">
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors duration-100 hover:bg-(--ui-row-hover-background) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
key={run.id}
|
||||
onClick={() => onOpenSession?.(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
|
||||
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -716,16 +694,6 @@ function CronJobRuns({
|
|||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CronEditorDialog({
|
||||
editor,
|
||||
onClose,
|
||||
|
|
|
|||
|
|
@ -581,7 +581,7 @@ export function DesktopController() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
|
||||
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
|
||||
|
||||
const updateActiveSessionRuntimeInfo = useCallback(
|
||||
(info: { branch?: string; cwd?: string }) => {
|
||||
|
|
@ -1061,7 +1061,6 @@ export function DesktopController() {
|
|||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
openAgents,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,11 @@
|
|||
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const overlayCardClass =
|
||||
'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]'
|
||||
|
||||
interface OverlayCardProps extends ComponentProps<'div'> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
tone?: 'default' | 'danger' | 'subtle'
|
||||
}
|
||||
|
||||
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
|
||||
return (
|
||||
<div className={cn(overlayCardClass, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayActionButton({
|
||||
children,
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import type { RefObject } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
containerClassName?: string
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
loading?: boolean
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// Borderless underline search — matches the tools/skills page (PageSearchShell).
|
||||
export function OverlaySearchInput({
|
||||
containerClassName,
|
||||
inputRef,
|
||||
loading = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
value
|
||||
}: OverlaySearchInputProps) {
|
||||
return (
|
||||
<SearchField
|
||||
containerClassName={containerClassName}
|
||||
inputRef={inputRef}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -50,9 +48,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
// pt clears the floating titlebar/header; the bg itself fills from the
|
||||
// card's top edge so there's no surface-colored gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
// pt clears the in-card close button (the OverlayView now insets the
|
||||
// whole card below the OS titlebar); the bg fills from the card's top
|
||||
// edge so there's no surface-colored gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -65,7 +64,7 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
|
||||
PAGE_INSET_X,
|
||||
className
|
||||
)}
|
||||
|
|
@ -75,31 +74,6 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
|
||||
// The text variant underlines on hover, which also strokes the icon glyph — so
|
||||
// we keep the button itself underline-free and underline only the label span.
|
||||
export function OverlayNewButton({
|
||||
icon = 'add',
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
icon?: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} />
|
||||
<span className="underline-offset-4 group-hover:underline">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ export function OverlayView({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[0.125rem] sm:p-6"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/22 backdrop-blur-[0.125rem]',
|
||||
// Equidistant inset on every side. The top value is driven by the
|
||||
// titlebar height so the card clears the OS traffic-lights vertically;
|
||||
// since the card top already sits below them, the left needs no extra
|
||||
// inset — keeping all sides equal so the card is ~full-width at any size.
|
||||
'p-[calc(var(--titlebar-height)+0.625rem)]',
|
||||
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
|
||||
)}
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
|
|
|
|||
377
apps/desktop/src/app/overlays/panel.tsx
Normal file
377
apps/desktop/src/app/overlays/panel.tsx
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { OverlayView } from './overlay-view'
|
||||
|
||||
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
|
||||
// straight from the trace / agents overlay so every non-settings overlay (cron,
|
||||
// profiles, …) speaks the same visual language: tight type scale, muted
|
||||
// opacities, NO container borders (rows separate via the row-hover/active bg
|
||||
// vars + gaps, exactly like the trace waterfall labels).
|
||||
//
|
||||
// Compose it as:
|
||||
// <Panel onClose>
|
||||
// <PanelHeader title subtitle actions={…} />
|
||||
// <PanelBody> // master/detail row
|
||||
// <PanelList>…</PanelList>
|
||||
// <PanelDetail>…</PanelDetail>
|
||||
// </PanelBody>
|
||||
// </Panel>
|
||||
//
|
||||
// Single-column views drop their content straight after the header.
|
||||
|
||||
interface PanelProps {
|
||||
children: ReactNode
|
||||
// Root layout override (the card already fills the equidistant inset).
|
||||
className?: string
|
||||
closeLabel?: string
|
||||
contentClassName?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
children,
|
||||
className,
|
||||
closeLabel = translateNow('common.close'),
|
||||
contentClassName,
|
||||
onClose
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={closeLabel}
|
||||
// Top pad aligns the header title's center with the floating close button
|
||||
// (which sits at 0.1875rem + titlebar/2, -translate-y-1/2). The X is
|
||||
// absolute so it costs no layout space — the header rides up next to it.
|
||||
contentClassName={cn(
|
||||
'flex h-full min-h-0 flex-col px-4 pb-4 pt-[calc(var(--titlebar-height)/2-0.4375rem)] sm:px-5',
|
||||
contentClassName
|
||||
)}
|
||||
onClose={onClose}
|
||||
rootClassName={cn('flex h-full w-full flex-col', className)}
|
||||
>
|
||||
{children}
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelHeaderProps {
|
||||
// Right-aligned controls (search, "+ New", segmented control, …).
|
||||
actions?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
|
||||
return (
|
||||
<header className="mb-3 flex shrink-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-1.5">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelBody({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex min-h-0 flex-1 gap-5 overflow-hidden', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface PanelListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
// Pass an onSearchChange to bake a full-bleed filter field in above the items
|
||||
// (pinned; the rows scroll under it). Controlled via searchValue.
|
||||
onSearchChange?: (value: string) => void
|
||||
searchLabel?: string
|
||||
searchPlaceholder?: string
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
// Left master list. Dense + borderless, like the trace waterfall's label tree:
|
||||
// single-line rows that touch, separated from the detail only by the body gap.
|
||||
// An optional search field pins to the top, full-bleed, above the scroll.
|
||||
export function PanelList({
|
||||
children,
|
||||
className,
|
||||
onSearchChange,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
searchValue
|
||||
}: PanelListProps) {
|
||||
return (
|
||||
<div className={cn('flex w-52 shrink-0 flex-col', className)}>
|
||||
{onSearchChange ? (
|
||||
<SearchField
|
||||
aria-label={searchLabel ?? searchPlaceholder ?? ''}
|
||||
containerClassName="mb-1 w-full shrink-0"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder ?? ''}
|
||||
value={searchValue ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelListRowProps {
|
||||
active: boolean
|
||||
// Leading status dot color class (e.g. 'bg-emerald-500'); omit for none.
|
||||
dotClassName?: string
|
||||
// Leading codicon glyph name (used when there's no lead/dot).
|
||||
icon?: string
|
||||
// Custom leading element (colored swatch, avatar, …). Wins over dot/icon.
|
||||
lead?: ReactNode
|
||||
// Trailing per-row kebab menu (pass a <PanelRowMenu/>). Reveals on hover/focus.
|
||||
menu?: ReactNode
|
||||
// Short always-visible trailing meta (a tag/time, like the trace label's duration).
|
||||
meta?: ReactNode
|
||||
onSelect: () => void
|
||||
rowKey?: string
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
// A row is a container (not a <button>) so it can host both the select target
|
||||
// and a kebab menu without nesting interactive elements. Hover/active bg lives
|
||||
// on the wrapper so the whole row highlights as one.
|
||||
export function PanelListRow({
|
||||
active,
|
||||
dotClassName,
|
||||
icon,
|
||||
lead,
|
||||
menu,
|
||||
meta,
|
||||
onSelect,
|
||||
rowKey,
|
||||
title
|
||||
}: PanelListRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row relative flex h-7 w-full items-center rounded-md text-[0.78rem] transition-colors duration-100 ease-out',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
data-panel-row={rowKey}
|
||||
>
|
||||
<button
|
||||
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-md pl-2 pr-1 text-left"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{lead ??
|
||||
(dotClassName ? (
|
||||
<span aria-hidden="true" className={cn('size-1.5 shrink-0 rounded-full', dotClassName)} />
|
||||
) : icon ? (
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name={icon} size="0.85rem" />
|
||||
) : null)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">{title}</span>
|
||||
</button>
|
||||
{meta ? <span className="shrink-0 pr-2 text-[0.62rem] tabular-nums text-muted-foreground/45">{meta}</span> : null}
|
||||
{menu ? <div className="shrink-0 pr-1">{menu}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PanelMenuItem {
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
tone?: 'danger' | 'default'
|
||||
}
|
||||
|
||||
// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern
|
||||
// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until
|
||||
// the row is hovered/focused (or the menu is open). Returns null with no items
|
||||
// (e.g. the default profile, which can't be renamed/deleted).
|
||||
export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuItem[]; label?: string }) {
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" sideOffset={6}>
|
||||
{items.map(item => (
|
||||
<DropdownMenuItem
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
onSelect={item.onSelect}
|
||||
variant={item.tone === 'danger' ? 'destructive' : undefined}
|
||||
>
|
||||
{item.icon ? <Codicon name={item.icon} size="0.875rem" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Scrolling detail region. Fills the column (no right rail here, unlike the
|
||||
// trace inspector), so the content stretches the full available width.
|
||||
export function PanelDetail({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}>
|
||||
<div className="space-y-4 pb-6 pl-1 pr-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelEmptyProps {
|
||||
action?: ReactNode
|
||||
description?: ReactNode
|
||||
// Codicon glyph name (e.g. 'hubot', 'warning', 'loading~spin').
|
||||
icon?: string
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export function PanelEmpty({ action, description, icon = 'inbox', title }: PanelEmptyProps) {
|
||||
return (
|
||||
<div className="grid flex-1 place-items-center px-6 py-10 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-muted-foreground/50" name={icon} size="1.25rem" />
|
||||
{title ? <p className="text-sm font-medium text-foreground/90">{title}</p> : null}
|
||||
{description ? (
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground/70">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelSectionLabel({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/50', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inspector-style key/value grid (mirrors the trace span inspector's <dl>).
|
||||
export interface PanelMetaRow {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
export function PanelMeta({ className, rows }: { className?: string; rows: PanelMetaRow[] }) {
|
||||
return (
|
||||
<dl className={cn('grid grid-cols-[5rem_1fr] gap-x-2 gap-y-1 text-[0.7rem]', className)}>
|
||||
{rows.map((row, i) => (
|
||||
<div className="contents" key={typeof row.label === 'string' ? row.label : i}>
|
||||
<dt className="truncate text-muted-foreground/55">{row.label}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground/85">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
// Monospace content block (job prompt, etc.) — mirrors the inspector's
|
||||
// input/output <pre> blocks: subtle bg, no border.
|
||||
export function PanelBlock({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-foreground/5 p-2.5 text-[0.68rem] leading-relaxed text-foreground/80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export type PanelPillTone = 'bad' | 'good' | 'muted' | 'warn'
|
||||
|
||||
const PILL_TONE: Record<PanelPillTone, string> = {
|
||||
bad: 'bg-destructive/10 text-destructive',
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-foreground/10 text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||
}
|
||||
|
||||
export function PanelPill({ children, tone = 'muted' }: { children: ReactNode; tone?: PanelPillTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.62rem] font-medium capitalize',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-describing centered "+" that sits as the LAST item in a PanelList. The
|
||||
// label rides aria/title only — no visible text.
|
||||
export function PanelAddButton({
|
||||
icon = 'add',
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
icon?: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
|
||||
export function PanelAction({
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
onClick
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
icon: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -18,21 +20,34 @@ import {
|
|||
createProfile,
|
||||
deleteProfile,
|
||||
getProfiles,
|
||||
getProfileSetupCommand,
|
||||
getProfileSoul,
|
||||
type ProfileInfo,
|
||||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { AlertTriangle, Save } from '@/lib/icons'
|
||||
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { slug } from '@/lib/sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profileColors } from '@/store/profile'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import {
|
||||
Panel,
|
||||
PanelAddButton,
|
||||
PanelBody,
|
||||
PanelDetail,
|
||||
PanelEmpty,
|
||||
PanelHeader,
|
||||
PanelList,
|
||||
PanelListRow,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
|
|
@ -49,7 +64,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
const p = t.profiles
|
||||
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
|
||||
const [selectedName, setSelectedName] = useState<null | string>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
|
|
@ -83,6 +100,18 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const visibleProfiles = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!profiles || !q) {
|
||||
return profiles ?? []
|
||||
}
|
||||
|
||||
return profiles.filter(
|
||||
profile => profile.name.toLowerCase().includes(q) || (profile.model ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
}, [profiles, query])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
const trimmed = name.trim()
|
||||
|
|
@ -140,46 +169,79 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
}, [p, pendingDelete, refresh])
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={p.close} onClose={onClose}>
|
||||
<Panel closeLabel={p.close} onClose={onClose}>
|
||||
{!profiles ? (
|
||||
<PageLoader label={p.loading} />
|
||||
) : profiles.length === 0 ? (
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button onClick={() => setCreateOpen(true)} size="sm">
|
||||
{p.newProfile}
|
||||
</Button>
|
||||
}
|
||||
description={p.createDesc}
|
||||
icon="organization"
|
||||
title={p.noProfiles}
|
||||
/>
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
<>
|
||||
<PanelHeader subtitle={p.count(profiles.length)} title={p.title} />
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchLabel={p.search}
|
||||
searchPlaceholder={p.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visibleProfiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={
|
||||
profile.is_default
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) },
|
||||
{
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
onSelect: () => setPendingDelete(profile),
|
||||
tone: 'danger'
|
||||
}
|
||||
]
|
||||
}
|
||||
/>
|
||||
}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
<PanelAddButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
</PanelList>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
<ProfileDetail key={selected.name} profile={selected} />
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Users className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{p.selectPrompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PanelEmpty description={p.selectPrompt} icon="account" />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</PanelBody>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RenameProfileDialog
|
||||
currentName={pendingRename?.name ?? ''}
|
||||
onClose={() => setPendingRename(null)}
|
||||
onRename={async newName => {
|
||||
if (pendingRename) {
|
||||
await handleRename(pendingRename.name, newName)
|
||||
setPendingRename(null)
|
||||
}
|
||||
}}
|
||||
open={pendingRename !== null}
|
||||
/>
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
|
|
@ -213,150 +275,106 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</OverlayView>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{profile.is_default && <span className="text-[0.6rem] text-primary">{p.default}</span>}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-muted-foreground">
|
||||
{p.skills(profile.skill_count)}
|
||||
{profile.has_env ? ` · ${p.env}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileDetail({
|
||||
onDelete,
|
||||
onRename,
|
||||
function ProfileRow({
|
||||
active,
|
||||
menu,
|
||||
onSelect,
|
||||
profile
|
||||
}: {
|
||||
onDelete: () => void
|
||||
onRename: (newName: string) => Promise<void>
|
||||
active: boolean
|
||||
menu?: React.ReactNode
|
||||
onSelect: () => void
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [copying, setCopying] = useState(false)
|
||||
|
||||
const handleCopySetup = useCallback(async () => {
|
||||
setCopying(true)
|
||||
|
||||
try {
|
||||
const { command } = await getProfileSetupCommand(profile.name)
|
||||
await navigator.clipboard.writeText(command)
|
||||
notify({ kind: 'success', title: p.setupCopied, message: command })
|
||||
} catch (err) {
|
||||
notifyError(err, p.failedCopy)
|
||||
} finally {
|
||||
setCopying(false)
|
||||
}
|
||||
}, [p, profile.name])
|
||||
const colors = useStore($profileColors)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
{p.defaultBadge}
|
||||
</span>
|
||||
)}
|
||||
{profile.has_env && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
|
||||
.env
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!profile.is_default && (
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
|
||||
<Pencil />
|
||||
{p.rename}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
|
||||
<Terminal />
|
||||
{copying ? p.copying : p.copySetup}
|
||||
</Button>
|
||||
{!profile.is_default && (
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
{t.common.delete}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<DetailRow label={p.modelLabel}>
|
||||
{profile.model ? (
|
||||
<>
|
||||
<span className="font-mono">{profile.model}</span>
|
||||
{profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{p.notSet}</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label={p.skillsLabel}>{profile.skill_count}</DetailRow>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<SoulEditor profileName={profile.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RenameProfileDialog
|
||||
currentName={profile.name}
|
||||
onClose={() => setRenameOpen(false)}
|
||||
onRename={async newName => {
|
||||
await onRename(newName)
|
||||
setRenameOpen(false)
|
||||
}}
|
||||
open={renameOpen}
|
||||
/>
|
||||
</div>
|
||||
<PanelListRow
|
||||
active={active}
|
||||
lead={
|
||||
<ProfileGlyph
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
isDefault={profile.is_default}
|
||||
name={profile.name}
|
||||
/>
|
||||
}
|
||||
menu={menu}
|
||||
onSelect={onSelect}
|
||||
rowKey={profile.name}
|
||||
title={profile.name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
// Leading glyph for a profile row, mirroring the sidebar rail: the default
|
||||
// profile gets the `home` icon; named profiles get a soft color-tinted square
|
||||
// with their initial in the profile's color.
|
||||
function ProfileGlyph({ color, isDefault, name }: { color: null | string; isDefault: boolean; name: string }) {
|
||||
if (isDefault) {
|
||||
return <Codicon className="shrink-0 text-muted-foreground/70" name="home" size="0.9rem" />
|
||||
}
|
||||
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
||||
const initial =
|
||||
name
|
||||
.replace(/[^a-z0-9]/gi, '')
|
||||
.charAt(0)
|
||||
.toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm text-foreground">{children}</dd>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
<header className="space-y-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{profile.name}</h3>
|
||||
{profile.is_default && <PanelPill tone="good">{p.defaultBadge}</PanelPill>}
|
||||
{profile.has_env && <PanelPill tone="muted">.env</PanelPill>}
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-[0.66rem] text-muted-foreground/55" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PanelMeta
|
||||
rows={[
|
||||
{
|
||||
label: p.modelLabel,
|
||||
value: profile.model ? (
|
||||
<span className="font-mono">
|
||||
{profile.model}
|
||||
{profile.provider ? <span className="text-muted-foreground/55"> · {profile.provider}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/55">{p.notSet}</span>
|
||||
)
|
||||
},
|
||||
{ label: p.skillsLabel, value: profile.skill_count }
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<SoulEditor profileName={profile.name} />
|
||||
</PanelDetail>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -419,7 +437,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
|||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">SOUL.md</h4>
|
||||
<PanelSectionLabel className="text-[0.7rem] tracking-[0.14em]">SOUL.md</PanelSectionLabel>
|
||||
<p className="text-xs text-muted-foreground">{p.soulDesc}</p>
|
||||
</div>
|
||||
{dirty && <span className="text-[0.65rem] text-muted-foreground">{p.unsavedChanges}</span>}
|
||||
|
|
@ -429,7 +447,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
|||
<PageLoader className="min-h-44" label={p.loadingSoul} />
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-72 font-mono text-xs leading-5"
|
||||
className="min-h-48 font-mono text-xs leading-5"
|
||||
onChange={event => setContent(event.target.value)}
|
||||
placeholder={isEmpty ? p.emptySoul : undefined}
|
||||
value={content}
|
||||
|
|
@ -437,7 +455,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
|||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div className="flex items-start gap-2 rounded bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)/2+1rem)]">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
|
|
|
|||
|
|
@ -1,20 +1,68 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getLogs } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
|
||||
import { LayoutDashboard, RefreshCw } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
interface GatewayMenuPanelProps {
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
logLines: readonly string[]
|
||||
onClose: () => void
|
||||
onOpenSystem: () => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
}
|
||||
|
||||
const LOG_TAIL = 120
|
||||
const LOG_VISIBLE = 40
|
||||
const LOG_POLL_MS = 3_000
|
||||
|
||||
// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything
|
||||
// useful — strip it so the tail reads as real gateway activity at a glance.
|
||||
const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i
|
||||
|
||||
// Live tail while the popover is mounted (i.e. open): poll on a tight cadence
|
||||
// and stop on unmount, instead of a global always-on status poll.
|
||||
function useGatewayLogTail(): string[] {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getLogs({ file: 'gui', lines: LOG_TAIL })
|
||||
.then(res => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setLines(
|
||||
res.lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !LOG_NOISE_RE.test(line))
|
||||
.slice(-LOG_VISIBLE)
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
void load()
|
||||
const timer = window.setInterval(load, LOG_POLL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const PLATFORM_TONE: Record<string, StatusTone> = {
|
||||
connected: 'good',
|
||||
connecting: 'warn',
|
||||
|
|
@ -35,12 +83,27 @@ const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replac
|
|||
export function GatewayMenuPanel({
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
logLines,
|
||||
onClose,
|
||||
onOpenSystem,
|
||||
statusSnapshot
|
||||
}: GatewayMenuPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.gatewayMenu
|
||||
|
||||
// Both jumps open the system panel, which owns the full view — so dismiss the
|
||||
// little status popover on the way out.
|
||||
const openSystem = () => {
|
||||
onClose()
|
||||
onOpenSystem()
|
||||
}
|
||||
|
||||
// Shared restart helper: never rejects and surfaces progress in the statusbar
|
||||
// gateway indicator, so just fire and close.
|
||||
const restart = () => {
|
||||
onClose()
|
||||
void runGatewayRestart()
|
||||
}
|
||||
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const gatewayConnecting = gatewayState === 'connecting'
|
||||
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
||||
|
|
@ -60,30 +123,50 @@ export function GatewayMenuPanel({
|
|||
: copy.disconnected
|
||||
|
||||
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
|
||||
const recentLogs = logLines.slice(-5)
|
||||
const recentLogs = useGatewayLogTail()
|
||||
|
||||
// Keep the tail pinned to the latest line as it streams.
|
||||
const logScrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = logScrollRef.current
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [recentLogs])
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{inferenceReady ? (
|
||||
<Activity className="size-3.5 text-primary" />
|
||||
) : (
|
||||
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
|
||||
)}
|
||||
<span className="font-medium">{copy.gateway}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-1 text-[0.7rem] leading-none">
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<StatusDot tone={gatewayOpen ? 'good' : gatewayConnecting ? 'warn' : 'bad'} />
|
||||
{connectionLabel}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
|
||||
{inferenceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tip label={t.commandCenter.restartGateway}>
|
||||
<Button
|
||||
aria-label={t.commandCenter.restartGateway}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={restart}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={copy.openSystem}>
|
||||
<Button
|
||||
aria-label={copy.openSystem}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
onClick={openSystem}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<LayoutDashboard />
|
||||
|
|
@ -92,32 +175,29 @@ export function GatewayMenuPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div>{copy.connection(connectionLabel)}</div>
|
||||
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
|
||||
</div>
|
||||
{inferenceStatus?.reason && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="line-clamp-3">{inferenceStatus.reason}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
<div className="border-t border-border/50 px-3 py-2">
|
||||
<SectionLabel>{copy.recentActivity}</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{recentLogs.map((line, index) => (
|
||||
<Tip key={`${index}:${line}`} label={line.trim()}>
|
||||
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
</Tip>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{copy.viewAllLogs}
|
||||
</Button>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SectionLabel>{copy.recentActivity}</SectionLabel>
|
||||
<Button
|
||||
className="-mr-2 h-auto py-0 font-medium leading-none text-muted-foreground"
|
||||
onClick={openSystem}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{copy.viewAllLogs}
|
||||
</Button>
|
||||
</div>
|
||||
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
|
||||
{recentLogs.map(trimLogLine).join('\n')}
|
||||
</LogView>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getLogs, getStatus } from '@/hermes'
|
||||
import { getStatus } from '@/hermes'
|
||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
const LOG_TAIL = 12
|
||||
|
||||
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
||||
export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
|
||||
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
|
||||
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
|
||||
const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -19,9 +17,8 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
|
|||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [next, logs, inference] = await Promise.all([
|
||||
const [next, inference] = await Promise.all([
|
||||
getStatus(),
|
||||
getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })),
|
||||
gatewayState === 'open'
|
||||
? evaluateRuntimeReadiness(requestGateway).catch(error => ({
|
||||
checksDisagree: false,
|
||||
|
|
@ -37,7 +34,6 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
|
|||
}
|
||||
|
||||
setStatusSnapshot(next)
|
||||
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
|
||||
setInferenceStatus(inference)
|
||||
} catch {
|
||||
// Keep last snapshot through transient gateway flaps.
|
||||
|
|
@ -53,5 +49,5 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
|
|||
}
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
return { gatewayLogLines, inferenceStatus, statusSnapshot }
|
||||
return { inferenceStatus, statusSnapshot }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ interface StatusbarItemsOptions {
|
|||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
openAgents: () => void
|
||||
|
|
@ -60,7 +59,6 @@ export function useStatusbarItems({
|
|||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
openAgents,
|
||||
|
|
@ -131,16 +129,16 @@ export function useStatusbarItems({
|
|||
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (
|
||||
() => (close: () => void) => (
|
||||
<GatewayMenuPanel
|
||||
gatewayState={gatewayState}
|
||||
inferenceStatus={inferenceStatus}
|
||||
logLines={gatewayLogLines}
|
||||
onClose={close}
|
||||
onOpenSystem={() => openCommandCenterSection('system')}
|
||||
statusSnapshot={statusSnapshot}
|
||||
/>
|
||||
),
|
||||
[gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
|
||||
[gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
|
||||
)
|
||||
|
||||
// The indicator must speak the same scope as the Spawn-tree panel it opens:
|
||||
|
|
@ -235,6 +233,7 @@ export function useStatusbarItems({
|
|||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
|
||||
const behindHint =
|
||||
!applying && behind > 0 ? ` (+${behind})` : !applying && updateAvailable ? ` (${copy.update})` : ''
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
|
@ -34,7 +34,8 @@ export interface StatusbarItem {
|
|||
href?: string
|
||||
menuAlign?: 'center' | 'end' | 'start'
|
||||
menuClassName?: string
|
||||
menuContent?: ReactNode
|
||||
// A render fn receives a `close()` to dismiss the popover from inside the content.
|
||||
menuContent?: ((close: () => void) => ReactNode) | ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
onSelect?: (modifiers: StatusbarSelectModifiers) => void
|
||||
title?: string
|
||||
|
|
@ -88,6 +89,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
}
|
||||
|
||||
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{item.icon}
|
||||
|
|
@ -99,7 +102,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
return (
|
||||
<Tip label={item.title}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
|
||||
{content}
|
||||
|
|
@ -112,7 +115,9 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
|||
sideOffset={8}
|
||||
>
|
||||
{item.menuContent
|
||||
? item.menuContent
|
||||
? typeof item.menuContent === 'function'
|
||||
? item.menuContent(() => setMenuOpen(false))
|
||||
: item.menuContent
|
||||
: (item.menuItems ?? [])
|
||||
.filter(menuItem => !menuItem.hidden)
|
||||
.map(menuItem => (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { composerPanelCard } from '@/components/chat/composer-dock'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -19,13 +18,11 @@ const HOVER_CLOSE_MS = 140
|
|||
const ROW_CLASS =
|
||||
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
|
||||
|
||||
const POPOVER_SHELL = cn(
|
||||
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
|
||||
composerPanelCard,
|
||||
// Solid fill — composerPanelCard is deliberately translucent; without this,
|
||||
// directive chips in the transcript bleed through and look like popover overflow.
|
||||
'bg-(--composer-fill)'
|
||||
)
|
||||
// Surface (border-color/bg/shadow/blur) comes from the shared
|
||||
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
|
||||
// the dropdown/select/dialog menus. We only own layout + the border/radius here.
|
||||
const POPOVER_SHELL =
|
||||
'absolute right-full top-1/2 z-50 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg border p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none'
|
||||
|
||||
function userPromptText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
|
|
@ -149,6 +146,8 @@ export const ThreadTimeline: FC = () => {
|
|||
const tickRefs = useRef<(HTMLSpanElement | null)[]>([])
|
||||
const rowRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
// Hover sync: light the tick + its popover row, and scroll that row into view
|
||||
// when the list overflows so the hovered prompt is always visible.
|
||||
const paint = useCallback((index: number, on: boolean) => {
|
||||
const tick = tickRefs.current[index]
|
||||
|
||||
|
|
@ -156,7 +155,12 @@ export const ThreadTimeline: FC = () => {
|
|||
tick.style.opacity = on ? '1' : ''
|
||||
}
|
||||
|
||||
rowRefs.current[index]?.classList.toggle('bg-(--ui-row-hover-background)', on)
|
||||
const row = rowRefs.current[index]
|
||||
row?.classList.toggle('bg-(--ui-row-hover-background)', on)
|
||||
|
||||
if (on) {
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const keepOpen = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ export function SearchField({
|
|||
className={cn(
|
||||
// `field-sizing: content` grows the input to fit the placeholder/typed
|
||||
// text, capped by the container's max-width — no awkward empty space.
|
||||
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
|
||||
// text-xs matches the form controls (Input/Select via controlVariants).
|
||||
'h-7 max-w-full bg-transparent text-xs text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,7 @@ export const en: Translations = {
|
|||
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
|
||||
title: 'Profiles',
|
||||
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
||||
search: 'Search profiles...',
|
||||
loading: 'Loading profiles...',
|
||||
newProfile: 'New profile',
|
||||
allProfiles: 'All profiles',
|
||||
|
|
@ -1121,6 +1122,8 @@ export const en: Translations = {
|
|||
|
||||
cron: {
|
||||
close: 'Close cron',
|
||||
title: 'Scheduled jobs',
|
||||
count: count => `${count} ${count === 1 ? 'job' : 'jobs'}`,
|
||||
search: 'Search cron jobs...',
|
||||
loading: 'Loading cron jobs...',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,7 @@ export const ja = defineLocale({
|
|||
nameHint: '小文字、数字、ハイフン、アンダースコア。文字または数字で始める必要があります。',
|
||||
title: 'プロファイル',
|
||||
count: count => `${count} プロファイル`,
|
||||
search: 'プロファイルを検索...',
|
||||
loading: 'プロファイルを読み込み中...',
|
||||
newProfile: '新しいプロファイル',
|
||||
allProfiles: 'すべてのプロファイル',
|
||||
|
|
@ -1244,6 +1245,8 @@ export const ja = defineLocale({
|
|||
|
||||
cron: {
|
||||
close: 'Cron を閉じる',
|
||||
title: 'スケジュール済みジョブ',
|
||||
count: count => `${count} 件のジョブ`,
|
||||
search: 'Cron ジョブを検索...',
|
||||
loading: 'Cron ジョブを読み込み中...',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -844,6 +844,7 @@ export interface Translations {
|
|||
nameHint: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
search: string
|
||||
loading: string
|
||||
newProfile: string
|
||||
allProfiles: string
|
||||
|
|
@ -916,6 +917,8 @@ export interface Translations {
|
|||
|
||||
cron: {
|
||||
close: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
search: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
|
|
|
|||
|
|
@ -279,7 +279,8 @@ export const zhHant = defineLocale({
|
|||
translucencyTitle: '視窗透明',
|
||||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
embedsDesc: '豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
embedsDesc:
|
||||
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
embedsAsk: '詢問',
|
||||
embedsAlways: '一律',
|
||||
embedsOff: '關閉',
|
||||
|
|
@ -1126,6 +1127,7 @@ export const zhHant = defineLocale({
|
|||
nameHint: '小寫字母、數字、連字號和底線。必須以字母或數字開頭。',
|
||||
title: '設定檔',
|
||||
count: count => `${count} 個設定檔`,
|
||||
search: '搜尋設定檔…',
|
||||
loading: '正在載入設定檔…',
|
||||
newProfile: '新增設定檔',
|
||||
allProfiles: '全部設定檔',
|
||||
|
|
@ -1198,6 +1200,8 @@ export const zhHant = defineLocale({
|
|||
|
||||
cron: {
|
||||
close: '關閉排程',
|
||||
title: '排程工作',
|
||||
count: count => `${count} 個工作`,
|
||||
search: '搜尋排程工作…',
|
||||
loading: '正在載入排程工作…',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -370,7 +370,8 @@ export const zh: Translations = {
|
|||
translucencyTitle: '窗口透明',
|
||||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
embedsTitle: '内嵌预览',
|
||||
embedsDesc: '富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
embedsDesc:
|
||||
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
embedsAsk: '询问',
|
||||
embedsAlways: '总是',
|
||||
embedsOff: '关闭',
|
||||
|
|
@ -1233,6 +1234,7 @@ export const zh: Translations = {
|
|||
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
|
||||
title: '配置档案',
|
||||
count: count => `${count} 个配置档案`,
|
||||
search: '搜索配置档案…',
|
||||
loading: '正在加载配置档案…',
|
||||
newProfile: '新建配置档案',
|
||||
allProfiles: '全部配置档案',
|
||||
|
|
@ -1305,6 +1307,8 @@ export const zh: Translations = {
|
|||
|
||||
cron: {
|
||||
close: '关闭定时任务',
|
||||
title: '定时任务',
|
||||
count: count => `${count} 个任务`,
|
||||
search: '搜索定时任务…',
|
||||
loading: '正在加载定时任务…',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,16 @@
|
|||
--dt-accent-foreground: var(--ui-text-primary);
|
||||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
/* THE single knob for input-field borders: the resting alpha (% of the ring
|
||||
color). Hover doubles it; focus/open go full. 0% = invisible at rest. */
|
||||
--dt-input-border: 7%;
|
||||
/* Knob for input-field background fill: alpha (% of --dt-card) across all
|
||||
states. 100% = fully opaque, lower = translucent over the blurred chrome. */
|
||||
--dt-input-bg: 0%;
|
||||
/* Classic recessed "inset" — a crisp 1px inner shadow at the TOP edge.
|
||||
:root.dark bumps the alpha since a dark card swallows shadow. Removed on
|
||||
focus (the focus border carries the state). */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 10%, transparent);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-midground: var(--theme-midground);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
|
|
@ -405,6 +415,10 @@
|
|||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
|
||||
--composer-ring-strength: 1.3;
|
||||
--backdrop-invert-mul: 0;
|
||||
/* Dark mode: a dark card needs a stronger black inset to show the recess. */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 38%, transparent);
|
||||
/* Dark needs a lighter resting border than light mode. */
|
||||
--dt-input-border: 4%;
|
||||
|
||||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
|
|
@ -686,7 +700,8 @@ button {
|
|||
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='dialog-content'] {
|
||||
[data-slot='dialog-content'],
|
||||
[data-slot='thread-timeline-popover'] {
|
||||
border-color: var(--ui-stroke-secondary);
|
||||
background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
|
@ -744,9 +759,8 @@ code {
|
|||
}
|
||||
|
||||
/* Arc-style multicolor action surface (static, not animated). Reusable on any
|
||||
Button via className. Unlayered so it beats Tailwind's bg-*/text-* variant
|
||||
utilities. */
|
||||
.btn-arc {
|
||||
Button via className. Unlayered so it beats Tailwind's bg-*/
|
||||
text-* variant utilities. */ .btn-arc {
|
||||
background-image: linear-gradient(110deg, #5b6cff 0%, #8b5cf6 28%, #d946ef 58%, #fb7185 82%, #fb923c 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
|
|
@ -757,30 +771,27 @@ code {
|
|||
}
|
||||
|
||||
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
|
||||
/* Border strength is driven by the single --dt-input-border alpha knob (× the
|
||||
theme's tuned ring color); hover doubles it and focus triples it. Only the
|
||||
border-color animates — animating the translucent background over the window's
|
||||
backdrop-blur flickers (badly on textareas), so the bg fill snaps instead.
|
||||
(:focus is declared after :hover so a focused+hovered field shows focus.) */
|
||||
.desktop-input-chrome {
|
||||
--ring-pct: 18%;
|
||||
--ring-fall: var(--dt-input);
|
||||
background: color-mix(in srgb, var(--dt-card) 68%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
|
||||
var(--ring-fall)
|
||||
);
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 200ms ease-out,
|
||||
border-color 200ms ease-out;
|
||||
background: color-mix(in srgb, var(--dt-card) var(--dt-input-bg), transparent);
|
||||
border-color: color-mix(in srgb, var(--dt-composer-ring) var(--dt-input-border), transparent);
|
||||
box-shadow: var(--dt-input-inset);
|
||||
transition: border-color 200ms ease-out;
|
||||
}
|
||||
|
||||
.desktop-input-chrome:hover {
|
||||
--ring-pct: 30%;
|
||||
background: color-mix(in srgb, var(--dt-card) 86%, transparent);
|
||||
border-color: color-mix(in srgb, var(--dt-composer-ring) calc(var(--dt-input-border) * 2), transparent);
|
||||
}
|
||||
|
||||
.desktop-input-chrome:focus {
|
||||
--ring-pct: 45%;
|
||||
--ring-fall: transparent;
|
||||
background: var(--dt-card);
|
||||
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
|
||||
open — Radix moves focus into the list, so the trigger itself loses :focus. */
|
||||
.desktop-input-chrome:focus,
|
||||
.desktop-input-chrome[data-state='open'] {
|
||||
border-color: var(--dt-composer-ring);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
|
@ -1514,8 +1525,12 @@ code {
|
|||
width: 5.5rem;
|
||||
height: 7rem;
|
||||
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
|
||||
background:
|
||||
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
|
||||
background: radial-gradient(
|
||||
120% 90% at 32% 26%,
|
||||
color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%,
|
||||
#f4ecd8 46%,
|
||||
#e4d3ad 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
|
||||
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
|
||||
|
|
@ -1568,7 +1583,11 @@ code {
|
|||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
|
||||
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%,
|
||||
transparent 72%
|
||||
);
|
||||
animation: pet-egg-shadow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue