feat(desktop): unify non-settings overlays under a shared Panel primitive

Extract the agents/trace overlay chrome into overlays/panel.tsx and adopt it
across the Cron, Profiles, and Agents overlays so they share one layout
(centered card, header, master/detail list with built-in search, kebab row
actions, big "+" footer, empty state) instead of three ad-hoc split layouts.

Also in this pass:
- OverlayView insets equidistantly on every side (was top/left-only, which
  left a large left gutter on narrow windows).
- Form-control chrome: input border/background/recessed-inset are now
  per-mode theme-var knobs (--dt-input-border/-bg/-inset) — resting borders
  blend in, strengthen on hover, and go solid on focus / while a Select is open.
- Thread-timeline popover reuses the shared dropdown surface (1:1 with the
  kebab menus) and scrolls the hovered prompt into view.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 20:56:52 -05:00
parent 10043c6d0c
commit 991220747f
14 changed files with 761 additions and 376 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

@ -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,72 +550,53 @@ 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>
)
}
function formatRunTime(seconds?: null | number): string {
if (!seconds) {
return '—'
@ -685,10 +664,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 +678,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 +695,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

@ -50,9 +50,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 +66,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
)}

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,363 @@
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,152 +275,98 @@ 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 colors = useStore($profileColors)
return (
<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}
/>
)
}
// 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 (
<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
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])
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} />
<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>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</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>
)
}
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
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>
)
}
function SoulEditor({ profileName }: { profileName: string }) {
const { t } = useI18n()
@ -419,7 +427,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 +437,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 +445,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,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

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

@ -1126,6 +1126,7 @@ export const zhHant = defineLocale({
nameHint: '小寫字母、數字、連字號和底線。必須以字母或數字開頭。',
title: '設定檔',
count: count => `${count} 個設定檔`,
search: '搜尋設定檔…',
loading: '正在載入設定檔…',
newProfile: '新增設定檔',
allProfiles: '全部設定檔',
@ -1198,6 +1199,8 @@ export const zhHant = defineLocale({
cron: {
close: '關閉排程',
title: '排程工作',
count: count => `${count} 個工作`,
search: '搜尋排程工作…',
loading: '正在載入排程工作…',
states: {

View file

@ -1233,6 +1233,7 @@ export const zh: Translations = {
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
title: '配置档案',
count: count => `${count} 个配置档案`,
search: '搜索配置档案…',
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
allProfiles: '全部配置档案',
@ -1305,6 +1306,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;
}