mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
10043c6d0c
commit
991220747f
14 changed files with 761 additions and 376 deletions
|
|
@ -19,7 +19,7 @@ import {
|
|||
type SubagentStreamEntry
|
||||
} from '@/store/subagents'
|
||||
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
|
|
@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
|||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={t.agents.close}
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
<Panel closeLabel={t.agents.close} onClose={onClose}>
|
||||
{tree.length === 0 ? (
|
||||
<PanelEmpty description={t.agents.emptyDesc} icon="hubot" title={t.agents.emptyTitle} />
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={t.agents.subtitle} title={t.agents.title} />
|
||||
<SubagentTree tree={tree} />
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
363
apps/desktop/src/app/overlays/panel.tsx
Normal file
363
apps/desktop/src/app/overlays/panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)/2+1rem)]">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
|
|
|
|||
|
|
@ -1,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(() => {
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,7 @@ export const en: Translations = {
|
|||
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
|
||||
title: 'Profiles',
|
||||
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
||||
search: 'Search profiles...',
|
||||
loading: 'Loading profiles...',
|
||||
newProfile: 'New profile',
|
||||
allProfiles: 'All profiles',
|
||||
|
|
@ -1121,6 +1122,8 @@ export const en: Translations = {
|
|||
|
||||
cron: {
|
||||
close: 'Close cron',
|
||||
title: 'Scheduled jobs',
|
||||
count: count => `${count} ${count === 1 ? 'job' : 'jobs'}`,
|
||||
search: 'Search cron jobs...',
|
||||
loading: 'Loading cron jobs...',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,7 @@ export const ja = defineLocale({
|
|||
nameHint: '小文字、数字、ハイフン、アンダースコア。文字または数字で始める必要があります。',
|
||||
title: 'プロファイル',
|
||||
count: count => `${count} プロファイル`,
|
||||
search: 'プロファイルを検索...',
|
||||
loading: 'プロファイルを読み込み中...',
|
||||
newProfile: '新しいプロファイル',
|
||||
allProfiles: 'すべてのプロファイル',
|
||||
|
|
@ -1244,6 +1245,8 @@ export const ja = defineLocale({
|
|||
|
||||
cron: {
|
||||
close: 'Cron を閉じる',
|
||||
title: 'スケジュール済みジョブ',
|
||||
count: count => `${count} 件のジョブ`,
|
||||
search: 'Cron ジョブを検索...',
|
||||
loading: 'Cron ジョブを読み込み中...',
|
||||
states: {
|
||||
|
|
|
|||
|
|
@ -844,6 +844,7 @@ export interface Translations {
|
|||
nameHint: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
search: string
|
||||
loading: string
|
||||
newProfile: string
|
||||
allProfiles: string
|
||||
|
|
@ -916,6 +917,8 @@ export interface Translations {
|
|||
|
||||
cron: {
|
||||
close: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
search: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,16 @@
|
|||
--dt-accent-foreground: var(--ui-text-primary);
|
||||
--dt-border: var(--ui-stroke-secondary);
|
||||
--dt-input: var(--ui-stroke-primary);
|
||||
/* THE single knob for input-field borders: the resting alpha (% of the ring
|
||||
color). Hover doubles it; focus/open go full. 0% = invisible at rest. */
|
||||
--dt-input-border: 7%;
|
||||
/* Knob for input-field background fill: alpha (% of --dt-card) across all
|
||||
states. 100% = fully opaque, lower = translucent over the blurred chrome. */
|
||||
--dt-input-bg: 0%;
|
||||
/* Classic recessed "inset" — a crisp 1px inner shadow at the TOP edge.
|
||||
:root.dark bumps the alpha since a dark card swallows shadow. Removed on
|
||||
focus (the focus border carries the state). */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 10%, transparent);
|
||||
--dt-ring: var(--ui-stroke-primary);
|
||||
--dt-midground: var(--theme-midground);
|
||||
--dt-composer-ring: var(--ui-base);
|
||||
|
|
@ -405,6 +415,10 @@
|
|||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
|
||||
--composer-ring-strength: 1.3;
|
||||
--backdrop-invert-mul: 0;
|
||||
/* Dark mode: a dark card needs a stronger black inset to show the recess. */
|
||||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 38%, transparent);
|
||||
/* Dark needs a lighter resting border than light mode. */
|
||||
--dt-input-border: 4%;
|
||||
|
||||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||||
|
|
@ -686,7 +700,8 @@ button {
|
|||
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='dialog-content'] {
|
||||
[data-slot='dialog-content'],
|
||||
[data-slot='thread-timeline-popover'] {
|
||||
border-color: var(--ui-stroke-secondary);
|
||||
background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
|
@ -744,9 +759,8 @@ code {
|
|||
}
|
||||
|
||||
/* Arc-style multicolor action surface (static, not animated). Reusable on any
|
||||
Button via className. Unlayered so it beats Tailwind's bg-*/text-* variant
|
||||
utilities. */
|
||||
.btn-arc {
|
||||
Button via className. Unlayered so it beats Tailwind's bg-*/
|
||||
text-* variant utilities. */ .btn-arc {
|
||||
background-image: linear-gradient(110deg, #5b6cff 0%, #8b5cf6 28%, #d946ef 58%, #fb7185 82%, #fb923c 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
|
|
@ -757,30 +771,27 @@ code {
|
|||
}
|
||||
|
||||
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
|
||||
/* Border strength is driven by the single --dt-input-border alpha knob (× the
|
||||
theme's tuned ring color); hover doubles it and focus triples it. Only the
|
||||
border-color animates — animating the translucent background over the window's
|
||||
backdrop-blur flickers (badly on textareas), so the bg fill snaps instead.
|
||||
(:focus is declared after :hover so a focused+hovered field shows focus.) */
|
||||
.desktop-input-chrome {
|
||||
--ring-pct: 18%;
|
||||
--ring-fall: var(--dt-input);
|
||||
background: color-mix(in srgb, var(--dt-card) 68%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
|
||||
var(--ring-fall)
|
||||
);
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 200ms ease-out,
|
||||
border-color 200ms ease-out;
|
||||
background: color-mix(in srgb, var(--dt-card) var(--dt-input-bg), transparent);
|
||||
border-color: color-mix(in srgb, var(--dt-composer-ring) var(--dt-input-border), transparent);
|
||||
box-shadow: var(--dt-input-inset);
|
||||
transition: border-color 200ms ease-out;
|
||||
}
|
||||
|
||||
.desktop-input-chrome:hover {
|
||||
--ring-pct: 30%;
|
||||
background: color-mix(in srgb, var(--dt-card) 86%, transparent);
|
||||
border-color: color-mix(in srgb, var(--dt-composer-ring) calc(var(--dt-input-border) * 2), transparent);
|
||||
}
|
||||
|
||||
.desktop-input-chrome:focus {
|
||||
--ring-pct: 45%;
|
||||
--ring-fall: transparent;
|
||||
background: var(--dt-card);
|
||||
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
|
||||
open — Radix moves focus into the list, so the trigger itself loses :focus. */
|
||||
.desktop-input-chrome:focus,
|
||||
.desktop-input-chrome[data-state='open'] {
|
||||
border-color: var(--dt-composer-ring);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
|
@ -1514,8 +1525,12 @@ code {
|
|||
width: 5.5rem;
|
||||
height: 7rem;
|
||||
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
|
||||
background:
|
||||
radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%);
|
||||
background: radial-gradient(
|
||||
120% 90% at 32% 26%,
|
||||
color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%,
|
||||
#f4ecd8 46%,
|
||||
#e4d3ad 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
|
||||
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
|
||||
|
|
@ -1568,7 +1583,11 @@ code {
|
|||
height: 0.8rem;
|
||||
border-radius: 50%;
|
||||
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
|
||||
background: radial-gradient(circle, color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%, transparent 72%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%,
|
||||
transparent 72%
|
||||
);
|
||||
animation: pet-egg-shadow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue