Merge pull request #54558 from NousResearch/bb/overlay-panels

feat(desktop): shared overlay Panel primitive for cron/profiles/agents
This commit is contained in:
brooklyn! 2026-06-28 21:32:14 -05:00 committed by GitHub
commit 83f09f52f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 945 additions and 521 deletions

View file

@ -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>
)
}

View file

@ -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 => ({

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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}
/>
)
}

View file

@ -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

View file

@ -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()

View 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>
)
}

View file

@ -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>

View file

@ -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' ? (

View file

@ -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>
)}

View file

@ -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 }
}

View file

@ -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})` : ''

View file

@ -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 => (

View file

@ -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(() => {

View file

@ -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)}

View file

@ -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: {

View file

@ -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: {

View file

@ -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>

View file

@ -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: {

View file

@ -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: {

View file

@ -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;
}