mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
2c1a38a3cc
15 changed files with 257 additions and 317 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
41
apps/desktop/src/components/ui/field.tsx
Normal file
41
apps/desktop/src/components/ui/field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1560,6 +1560,8 @@ export const ja = defineLocale({
|
|||
},
|
||||
blueprints: {
|
||||
tab: 'ブレーンプリント',
|
||||
startFrom: '開始点',
|
||||
custom: 'カスタム',
|
||||
subtitle: 'すぐに使える自動化',
|
||||
dialogDesc: '詳細を入力してスケジュールします。',
|
||||
scheduleIt: 'スケジュールする',
|
||||
|
|
|
|||
|
|
@ -1399,6 +1399,8 @@ export interface Translations {
|
|||
}
|
||||
blueprints: {
|
||||
tab: string
|
||||
startFrom: string
|
||||
custom: string
|
||||
subtitle: string
|
||||
dialogDesc: string
|
||||
scheduleIt: string
|
||||
|
|
|
|||
|
|
@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
|
|||
},
|
||||
blueprints: {
|
||||
tab: '藍圖',
|
||||
startFrom: '從此開始',
|
||||
custom: '自訂',
|
||||
subtitle: '現成的自動化',
|
||||
dialogDesc: '填寫詳細資訊並進行排程。',
|
||||
scheduleIt: '安排工作',
|
||||
|
|
|
|||
|
|
@ -1879,6 +1879,8 @@ export const zh: Translations = {
|
|||
},
|
||||
blueprints: {
|
||||
tab: '蓝图',
|
||||
startFrom: '从此开始',
|
||||
custom: '自定义',
|
||||
subtitle: '现成的自动化',
|
||||
dialogDesc: '填写详细信息并进行排程。',
|
||||
scheduleIt: '安排任务',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue