Merge pull request #71094 from NousResearch/bb/desktop-ui-consistency

refactor(desktop): UI-consistency follow-up for the Webhooks & Cron Blueprints panes
This commit is contained in:
brooklyn! 2026-07-24 19:27:26 -05:00 committed by GitHub
commit 2c1a38a3cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 257 additions and 317 deletions

View file

@ -1,16 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAutomationBlueprints } from '@/hermes'
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
import { useI18n } from '@/i18n'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
// The blueprint catalog is shared with the dashboard, so its deliver slot
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
@ -99,66 +89,3 @@ export function BlueprintSlotControl({
/>
)
}
// A clickable blueprint card — mirrors the app's other selectable cards
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
// no inline expand form or divider.
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
return (
<button
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
onClick={onSetUp}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
{blueprint.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map(tag => (
<PanelPill key={tag}>{tag}</PanelPill>
))}
</div>
)}
</div>
</button>
)
}
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
// the blueprint's typed slots; submitting POSTs to
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
// job via the same create_job path as a hand-written cron.
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
const { t } = useI18n()
const c = t.cron
const blueprints = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints
})
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
if (blueprints.isLoading) {
return <PageLoader label={c.blueprints.loading} />
}
if (blueprints.isError) {
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
}
if (cards.length === 0) {
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
}
return (
<PanelDetail>
{cards.map(blueprint => (
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
))}
</PanelDetail>
)
}

View file

@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon, codiconIcon } from '@/components/ui/codicon'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@ -14,8 +14,8 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Select,
SelectContent,
@ -32,6 +32,7 @@ import {
type CronDeliveryTarget,
type CronJob,
deleteCronJob,
getAutomationBlueprints,
getCronDeliveryTargets,
getCronJobRuns,
getCronJobs,
@ -70,27 +71,20 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
BlueprintSlotControl,
blueprintSlotHelp,
BlueprintsPanel,
cleanBlueprintFieldError,
initialBlueprintValues
} from './blueprints'
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
// gallery (parameterized templates that instantiate a real job). The active tab
// is pure view state — it lives here, not in a store.
type CronTab = 'blueprints' | 'jobs'
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
// the model picker carries this sentinel and is mapped back to '' on save.
const MODEL_DEFAULT_VALUE = '__default__'
// "Start from" default: the manual editor (blank cron). Any other value is a
// blueprint key. Blueprint keys never collide with this sentinel.
const CUSTOM_TEMPLATE = 'custom'
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
@ -305,7 +299,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [tab, setTab] = useState<CronTab>('jobs')
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
@ -455,12 +448,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// Blueprint instantiation is a distinct backend path (fills typed slots, then
// creates the job) so it can't share the raw-cron onSave contract. Merge the
// created job into $cronJobs like every other create path.
async function handleBlueprintCreate(
blueprint: AutomationBlueprint,
values: Record<string, string>,
profile: string
) {
// created job into $cronJobs like every other create path. A blueprint writes a
// real per-profile job, and "all" is not a writable target — collapse it to
// 'default', matching the manual create path in handleEditorSave.
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
updateCronJobs(rows => {
@ -472,42 +464,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })
}
const tabToggle = (
<SegmentedControl
onChange={setTab}
options={[
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
]}
value={tab}
/>
)
return (
<Panel closeLabel={c.close} onClose={onClose}>
<PanelHeader
actions={tabToggle}
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
title={c.title}
/>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
{tab === 'blueprints' ? (
// A blueprint instantiates a real per-profile job, and "all" is not a
// writable target — collapse it to 'default', matching the create path
// in handleEditorSave. A user scoped to all profiles gets the job in
// 'default'. The gallery is a single scroll column, so it renders
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
// master/detail row.
<BlueprintsPanel
onSetUp={blueprint =>
setEditor({
blueprint,
mode: 'blueprint',
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
})
}
/>
) : loading && jobs.length === 0 ? (
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
<PanelEmpty
@ -663,7 +624,7 @@ function CronJobDetail({
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
{isPaused ? c.resumeTitle : c.pauseTitle}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
{c.triggerNow}
</PanelAction>
</div>
@ -847,7 +808,7 @@ function CronEditorDialog({
onSave
}: {
editor: EditorState
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
@ -855,8 +816,6 @@ function CronEditorDialog({
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const isBlueprint = editor.mode === 'blueprint'
const blueprint = isBlueprint ? editor.blueprint : null
const initial = isEdit ? editor.job : null
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
@ -868,14 +827,33 @@ function CronEditorDialog({
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
// raw cron fields; the backend renders the prompt + schedule from them.
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
// cron fields; the backend renders the prompt + schedule from them.
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
// Create mode can start from a ready-made blueprint instead of a blank cron.
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
// blueprint key that swaps the form for that blueprint's typed slots.
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
// meaningless when editing an existing job, so skip the fetch there.
const blueprintsQuery = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints,
enabled: open && !isEdit
})
const blueprintList = blueprintsQuery.data ?? []
const blueprint =
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
const isBlueprint = blueprint !== null
// Same catalog the chat model picker uses: configured providers and their
// actually-available models only. Script-only + blueprint dialogs never pick a
// actually-available models only. Script-only + blueprint forms never pick a
// model here, so skip the fetch entirely for them.
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
@ -903,10 +881,18 @@ function CronEditorDialog({
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setSlotValues({})
setTemplateChoice(CUSTOM_TEMPLATE)
setError(null)
setSaving(false)
}, [blueprint, initial, open])
}, [initial, open])
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
// picked from "Start from" (and reset them when switching back to Custom).
useEffect(() => {
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setError(null)
}, [blueprint])
const selectedScheduleOption =
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
@ -988,7 +974,7 @@ function CronEditorDialog({
async function handleBlueprintSubmit(event: React.FormEvent) {
event.preventDefault()
if (!isBlueprint) {
if (!blueprint) {
return
}
@ -996,7 +982,7 @@ function CronEditorDialog({
setError(null)
try {
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
await onBlueprintCreate(blueprint, slotValues)
} catch (err) {
// 422 carries the slot-level validation message; surface it inline.
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
@ -1009,12 +995,29 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
{!isEdit && blueprintList.length > 0 && (
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
<Select onValueChange={setTemplateChoice} value={templateChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
{blueprintList.map(item => (
<SelectItem key={item.key} value={item.key}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
</Field>
)}
{isBlueprint && blueprint ? (
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
{blueprint.fields.map(field => {
@ -1192,39 +1195,7 @@ function CronEditorDialog({
)
}
function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
)
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState =
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
| { job: CronJob; mode: 'edit' }
| { mode: 'closed' }
| { mode: 'create' }
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
interface EditorValues {
deliver: string

View file

@ -4,7 +4,9 @@ import { TabDropdown } from '@/components/ui/tab-dropdown'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants'
import { PAGE_MAX_W } from '../layout-constants'
import { OVERLAY_TOP_CLEARANCE } from './overlay-view'
// The wide rail and the narrow dropdown swap at exactly the width where
// OverlaySplitLayout drops to a single column, so the rail never stacks.
@ -57,10 +59,12 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// 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)]',
// The left links sit beside (not under) the floating close button, so
// they ride up via the shorter shared OVERLAY_TOP_CLEARANCE (same line
// as a Panel header) instead of main's taller X-clearance. The bg still
// fills from the card's top edge, so there's no 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',
OVERLAY_TOP_CLEARANCE,
className
)}
>
@ -73,11 +77,15 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
// drop the tall top pad to a normal gap below it.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
// Main sits UNDER the floating close button (top-right), so it keeps the
// taller top pad to clear the X — unlike the sidebar / Panel header,
// which sit to its left and ride up via OVERLAY_TOP_CLEARANCE. All four
// paddings are 1/3 tighter than the raw values (×2/3): the wide/narrow
// top clearance, the bottom gutter, and the horizontal clamp gutter
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
// shared page gutter). Narrow top drops toward the OverlayNav bar.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
PAGE_MAX_W,
PAGE_INSET_X,
className
)}
>

View file

@ -9,6 +9,14 @@ import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
// Shared top clearance for overlay content that sits *beside* the floating
// close button (which is absolute at `0.1875rem + titlebar/2`, -translate-y-1/2,
// so it costs no layout space): a Panel's header and the split layout's left
// sidebar links. They ride up next to the X on the same line across every
// overlay (settings, system, agents, cron, …) — change it here, not per-surface.
// Main content sits *under* the X (top-right) and keeps its own taller pad.
export const OVERLAY_TOP_CLEARANCE = 'pt-[calc(var(--titlebar-height)/2-0.4375rem)]'
interface OverlayViewProps {
children: ReactNode
onClose: () => void

View file

@ -9,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { translateNow } from '@/i18n'
import { cn } from '@/lib/utils'
import { OverlayView } from './overlay-view'
import { OVERLAY_TOP_CLEARANCE, 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,
@ -47,13 +47,9 @@ export function Panel({
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
)}
// Header title rides up next to the floating close button — see
// OVERLAY_TOP_CLEARANCE, the shared clearance every overlay column uses.
contentClassName={cn('flex h-full min-h-0 flex-col px-4 pb-4 sm:px-5', OVERLAY_TOP_CLEARANCE, contentClassName)}
onClose={onClose}
rootClassName={cn('flex h-full w-full flex-col', className)}
>
@ -373,25 +369,31 @@ export function PanelAddButton({
)
}
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
// Visible action for a detail header (cron pause/resume/trigger, …). Ghost by
// default; `primary` promotes the header's main action to a filled button.
export function PanelAction({
children,
disabled,
icon,
onClick
onClick,
primary
}: {
children: ReactNode
disabled?: boolean
icon: string
onClick: () => void
primary?: boolean
}) {
return (
<Button
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
className={cn(
'gap-1.5',
!primary && 'text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
variant={primary ? 'default' : 'ghost'}
>
<Codicon name={icon} size="0.875rem" />
{children}

View file

@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@ -100,10 +100,7 @@ export function CreateProfileDialog({
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Field htmlFor="new-profile-name" label={p.nameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@ -112,15 +109,10 @@ export function CreateProfileDialog({
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Field htmlFor="new-profile-clone-from" label={p.cloneFrom}>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
@ -137,13 +129,10 @@ export function CreateProfileDialog({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<FieldHint>{p.cloneFromDesc}</FieldHint>
</Field>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
</label>
<Field htmlFor="new-profile-soul" label="SOUL.md" optional optionalLabel={p.soulOptional}>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
@ -151,7 +140,7 @@ export function CreateProfileDialog({
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</div>
</Field>
{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">

View file

@ -10,11 +10,11 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName } from './create-profile-dialog'
@ -93,11 +93,8 @@ export function RenameProfileDialog({
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
<Input
aria-invalid={invalid}
autoFocus
@ -105,10 +102,8 @@ export function RenameProfileDialog({
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
{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">

View file

@ -288,7 +288,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
<OverlayMain className="px-0 pb-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (

View file

@ -15,8 +15,10 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
createWebhook,
@ -50,7 +52,7 @@ import {
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import { ListRow, ToggleRow } from '../settings/primitives'
import { ListRow } from '../settings/primitives'
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
@ -435,100 +437,90 @@ export function WebhooksView({ onClose }: WebhooksViewProps) {
</DialogFooter>
</div>
) : (
<div className="grid gap-1">
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
}
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
wide
/>
</div>
<ListRow
action={
<Textarea
className="min-h-[80px]"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
void handleCreate()
}}
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-name" label={w.fieldName}>
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
wide
/>
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
}
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
}
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
wide
/>
</Field>
<Field htmlFor="webhook-description" label={w.fieldDescription}>
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
</Field>
</div>
<div className="grid grid-cols-2 items-start gap-4">
<ListRow
action={
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
}
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
wide
<Field htmlFor="webhook-prompt" label={w.fieldPrompt}>
<Textarea
className="min-h-24"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
/>
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-events" label={w.fieldEvents}>
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
</Field>
<Field htmlFor="webhook-skills" label={w.fieldSkills}>
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
</Field>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-deliver" label={w.fieldDeliver}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="webhook-deliver-only" label={w.fieldDeliverOnly}>
<div className="flex h-9 items-center">
<Switch checked={deliverOnly} id="webhook-deliver-only" onCheckedChange={setDeliverOnly} />
</div>
</Field>
</div>
<DialogFooter>
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
<Button disabled={creating} size="sm" type="submit">
{creating ? w.creating : w.create}
</Button>
</DialogFooter>
</div>
</form>
)}
</DialogContent>
</Dialog>
@ -565,9 +557,6 @@ function WebhookDetail({ sub }: { sub: WebhookRoute }) {
<header className="space-y-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
</PanelPill>
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
</div>

View file

@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
// Shared form-field primitive for dialog forms: a label stacked above its
// control, with an optional inline "(optional)" tag. Pair with FieldHint for
// help text below the control. This is the single field language for every form
// dialog (cron, webhooks, profiles, …) — don't hand-roll label+control stacks
// or reach for the settings-surface ListRow inside a dialog. Stack Fields in a
// `grid gap-4` form; pair two across with `grid items-start gap-4 sm:grid-cols-2`.
export function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: ReactNode
htmlFor?: string
label: ReactNode
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && optionalLabel && (
<span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>
)}
</label>
{children}
</div>
)
}
export function FieldHint({ children, error }: { children: ReactNode; error?: boolean }) {
return (
<p className={cn('text-[0.66rem] leading-4', error ? 'text-destructive' : 'text-muted-foreground')}>{children}</p>
)
}

View file

@ -1688,6 +1688,8 @@ export const en: Translations = {
},
blueprints: {
tab: 'Blueprints',
startFrom: 'Start from',
custom: 'Custom',
subtitle: 'Ready-made automations',
dialogDesc: 'Fill in the details and schedule it.',
scheduleIt: 'Schedule it',

View file

@ -1560,6 +1560,8 @@ export const ja = defineLocale({
},
blueprints: {
tab: 'ブレーンプリント',
startFrom: '開始点',
custom: 'カスタム',
subtitle: 'すぐに使える自動化',
dialogDesc: '詳細を入力してスケジュールします。',
scheduleIt: 'スケジュールする',

View file

@ -1399,6 +1399,8 @@ export interface Translations {
}
blueprints: {
tab: string
startFrom: string
custom: string
subtitle: string
dialogDesc: string
scheduleIt: string

View file

@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
},
blueprints: {
tab: '藍圖',
startFrom: '從此開始',
custom: '自訂',
subtitle: '現成的自動化',
dialogDesc: '填寫詳細資訊並進行排程。',
scheduleIt: '安排工作',

View file

@ -1879,6 +1879,8 @@ export const zh: Translations = {
},
blueprints: {
tab: '蓝图',
startFrom: '从此开始',
custom: '自定义',
subtitle: '现成的自动化',
dialogDesc: '填写详细信息并进行排程。',
scheduleIt: '安排任务',