Skip to content

Commit c25b204

Browse files
committed
fix(webui): simplify automation calendar and creation UI
1 parent 37e29c3 commit c25b204

21 files changed

Lines changed: 537 additions & 636 deletions

webui/src/components/settings/SettingsPage.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,6 @@ export function SettingsPage({
9797
automationsError,
9898
automationsFilter,
9999
automationsLoading,
100-
automationsQuery,
101-
automationsSort,
102100
beginModelPresetCreation,
103101
cancelModelPresetCreation,
104102
changeModelCallOrder,
@@ -195,8 +193,6 @@ export function SettingsPage({
195193
setAutomationPendingDelete,
196194
setAutomationPendingEdit,
197195
setAutomationsFilter,
198-
setAutomationsQuery,
199-
setAutomationsSort,
200196
setCliAppsError,
201197
setCliAppsMessage,
202198
setCustomMcpForm,
@@ -578,14 +574,10 @@ export function SettingsPage({
578574
settingsSnapshot={controller.settings}
579575
onStartChat={onStartAutomationChat}
580576
loading={automationsLoading}
581-
query={automationsQuery}
582577
filter={automationsFilter}
583-
sort={automationsSort}
584578
actionKey={automationAction}
585579
error={automationsError}
586-
onQueryChange={setAutomationsQuery}
587580
onFilterChange={setAutomationsFilter}
588-
onSortChange={setAutomationsSort}
589581
onAction={handleAutomationAction}
590582
onRequestEdit={(job) => {
591583
setAutomationDetailReturn(null);

webui/src/components/settings/system/AutomationCalendar.tsx

Lines changed: 121 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { ChevronDown, CircleAlert, Clock3, Loader2 } from "lucide-react";
2-
import { useLayoutEffect, useMemo, useRef, useState } from "react";
1+
import { CircleAlert, Loader2, X } from "lucide-react";
2+
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
3+
import type { ReactNode } from "react";
34

45
import { Button } from "@/components/ui/button";
56
import { formControlFocusClassName } from "@/components/ui/form-control";
7+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
68
import type { SessionAutomationJob } from "@/lib/types";
79
import { cn } from "@/lib/utils";
810

@@ -24,17 +26,16 @@ type CalendarCopy = {
2426
running: string;
2527
failed: string;
2628
more: (count: number) => string;
29+
close: string;
2730
noEntries: string;
28-
outsideMonth: string;
29-
paused: string;
30-
attention: string;
3131
completed: string;
3232
};
3333

3434
interface AutomationCalendarProps {
3535
jobs: SessionAutomationJob[];
3636
locale: string;
3737
copy: CalendarCopy;
38+
filters?: ReactNode;
3839
onInspect: (job: SessionAutomationJob, trigger: HTMLElement) => void;
3940
}
4041

@@ -173,7 +174,72 @@ function CalendarEntryRow({ entry, locale, copy, onInspect, compact = false }: {
173174
);
174175
}
175176

176-
export function AutomationCalendar({ jobs, locale, copy, onInspect }: AutomationCalendarProps) {
177+
function CalendarOverflowEntries({ entries, dayLabel, locale, copy, onInspect }: {
178+
entries: CalendarEntry[];
179+
dayLabel: string;
180+
locale: string;
181+
copy: CalendarCopy;
182+
onInspect: AutomationCalendarProps["onInspect"];
183+
}) {
184+
const [open, setOpen] = useState(false);
185+
const titleId = useId();
186+
const triggerRef = useRef<HTMLButtonElement | null>(null);
187+
const pendingSelection = useRef<SessionAutomationJob | null>(null);
188+
189+
return (
190+
<Popover open={open} onOpenChange={setOpen}>
191+
<PopoverTrigger asChild>
192+
<button
193+
ref={triggerRef}
194+
type="button"
195+
className={cn(
196+
"min-h-6 w-full rounded-compact pe-2 ps-[1.375rem] py-1 text-left text-[10px] text-muted-foreground transition-colors duration-150 hover:bg-foreground/[0.055] hover:text-foreground motion-reduce:transition-none",
197+
formControlFocusClassName,
198+
)}
199+
>
200+
{copy.more(entries.length - 3)}
201+
</button>
202+
</PopoverTrigger>
203+
<PopoverContent
204+
align="start"
205+
side="right"
206+
collisionPadding={16}
207+
aria-labelledby={titleId}
208+
className="w-80 max-w-[calc(100vw-2rem)] overscroll-contain p-2"
209+
onCloseAutoFocus={(event) => {
210+
const job = pendingSelection.current;
211+
pendingSelection.current = null;
212+
if (!job || !triggerRef.current?.isConnected) return;
213+
// Hand off after the day popover exits, retaining a mounted return target.
214+
event.preventDefault();
215+
onInspect(job, triggerRef.current);
216+
}}
217+
>
218+
<div className="mb-1 flex items-center gap-2 px-2 py-1">
219+
<h3 id={titleId} className="min-w-0 flex-1 text-[13px] font-semibold">{dayLabel}</h3>
220+
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label={copy.close} onClick={() => setOpen(false)}>
221+
<X className="h-4 w-4" aria-hidden />
222+
</Button>
223+
</div>
224+
{entries.map((entry) => (
225+
<CalendarEntryRow
226+
key={entry.id}
227+
entry={entry}
228+
locale={locale}
229+
copy={copy}
230+
onInspect={(job) => {
231+
pendingSelection.current = job;
232+
setOpen(false);
233+
}}
234+
compact
235+
/>
236+
))}
237+
</PopoverContent>
238+
</Popover>
239+
);
240+
}
241+
242+
export function AutomationCalendar({ jobs, locale, copy, filters, onInspect }: AutomationCalendarProps) {
177243
const [month, setMonth] = useState(() => startOfMonth(new Date()));
178244
const calendarRef = useRef<HTMLElement | null>(null);
179245
const [wideCalendar, setWideCalendar] = useState(true);
@@ -190,16 +256,12 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
190256
const key = dateKey(entry.startMs);
191257
entriesByDay.set(key, [...(entriesByDay.get(key) ?? []), entry]);
192258
});
193-
const visibleJobIds = new Set(visibleEntries.map((entry) => entry.job.id));
194-
const outsideJobs = jobs.filter((job) => !visibleJobIds.has(job.id));
195259
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
196260
const weekdayFormatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
197261
const dayFormatter = new Intl.DateTimeFormat(locale, { day: "numeric" });
198262
const agendaDateFormatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", weekday: "short" });
199263
const weekdayLabels = Array.from({ length: 7 }, (_, index) => weekdayFormatter.format(addDays(new Date(2026, 0, 5), index)));
200264

201-
const inspectOutsideJob = (job: SessionAutomationJob, target: HTMLElement) => onInspect(job, target);
202-
203265
useLayoutEffect(() => {
204266
const calendar = calendarRef.current;
205267
if (!calendar) return;
@@ -214,52 +276,50 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
214276
}, []);
215277

216278
return (
217-
<section ref={calendarRef} className="automation-calendar overflow-hidden rounded-panel border border-border/70 bg-[hsl(var(--settings-surface))]">
218-
<div className="flex min-h-14 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5 sm:px-6">
219-
<h2 className="min-w-0 flex-1 text-[15px] font-semibold text-foreground">{monthLabel}</h2>
220-
<div className="flex items-center gap-1">
221-
<Button
222-
type="button"
223-
variant="outline"
224-
size="sm"
225-
className="h-8 rounded-control px-3 text-[12px]"
226-
onClick={() => setMonth(startOfMonth(new Date()))}
227-
>
228-
{copy.today}
229-
</Button>
230-
<Button
231-
type="button"
232-
variant="ghost"
233-
size="icon"
234-
className="h-8 w-8 text-muted-foreground"
235-
aria-label={copy.previousMonth}
236-
onClick={() => setMonth((value) => addMonths(value, -1))}
237-
>
238-
<span className="text-lg leading-none" aria-hidden></span>
239-
</Button>
240-
<Button
241-
type="button"
242-
variant="ghost"
243-
size="icon"
244-
className="h-8 w-8 text-muted-foreground"
245-
aria-label={copy.nextMonth}
246-
onClick={() => setMonth((value) => addMonths(value, 1))}
247-
>
248-
<span className="text-lg leading-none" aria-hidden></span>
249-
</Button>
279+
<section ref={calendarRef} className="automation-calendar overflow-hidden rounded-panel bg-[hsl(var(--settings-surface))]">
280+
<div className="automation-calendar-header bg-foreground/[0.025]">
281+
<div className="automation-calendar-toolbar">
282+
<h2 className="min-w-0 text-[15px] font-semibold text-foreground">{monthLabel}</h2>
283+
{filters ? <div className="automation-calendar-filters min-w-0">{filters}</div> : null}
284+
<div className="automation-calendar-navigation flex items-center gap-1">
285+
<Button
286+
type="button"
287+
variant="outline"
288+
size="sm"
289+
className="h-8 rounded-control px-3 text-[12px]"
290+
onClick={() => setMonth(startOfMonth(new Date()))}
291+
>
292+
{copy.today}
293+
</Button>
294+
<Button
295+
type="button"
296+
variant="ghost"
297+
size="icon"
298+
className="h-8 w-8 text-muted-foreground"
299+
aria-label={copy.previousMonth}
300+
onClick={() => setMonth((value) => addMonths(value, -1))}
301+
>
302+
<span className="text-lg leading-none" aria-hidden></span>
303+
</Button>
304+
<Button
305+
type="button"
306+
variant="ghost"
307+
size="icon"
308+
className="h-8 w-8 text-muted-foreground"
309+
aria-label={copy.nextMonth}
310+
onClick={() => setMonth((value) => addMonths(value, 1))}
311+
>
312+
<span className="text-lg leading-none" aria-hidden></span>
313+
</Button>
314+
</div>
250315
</div>
251-
</div>
252-
253-
{!jobs.length ? (
254-
<div className="flex min-h-48 items-center justify-center px-6 text-center text-[13px] text-muted-foreground">
255-
{copy.noEntries}
256-
</div>
257-
) : null}
258316

259-
{jobs.length > 0 && wideCalendar ? <div className="automation-calendar-grid" aria-label={monthLabel}>
260-
<div className="automation-calendar-weekdays" aria-hidden>
317+
{wideCalendar ? <div className="automation-calendar-weekdays" aria-hidden>
261318
{weekdayLabels.map((label) => <div key={label}>{label}</div>)}
262-
</div>
319+
</div> : null}
320+
</div>
321+
322+
{wideCalendar ? <div className="automation-calendar-grid" aria-label={monthLabel}>
263323
<div className="automation-calendar-month">
264324
{days.map((day) => {
265325
const entries = entriesByDay.get(dateKey(day)) ?? [];
@@ -295,25 +355,13 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
295355
/>
296356
))}
297357
{entries.length > 3 ? (
298-
<details className="group/more">
299-
<summary className={cn(
300-
"cursor-pointer list-none rounded-control px-2 py-1 text-[10px] text-muted-foreground settings-hover [&::-webkit-details-marker]:hidden",
301-
formControlFocusClassName,
302-
)}>
303-
{copy.more(entries.length - 3)}
304-
</summary>
305-
<div className="mt-0.5 space-y-0.5">
306-
{entries.slice(3).map((entry) => (
307-
<CalendarEntryRow
308-
key={entry.id}
309-
entry={entry}
310-
locale={locale}
311-
copy={copy}
312-
onInspect={onInspect}
313-
/>
314-
))}
315-
</div>
316-
</details>
358+
<CalendarOverflowEntries
359+
entries={entries}
360+
dayLabel={dayLabel}
361+
locale={locale}
362+
copy={copy}
363+
onInspect={onInspect}
364+
/>
317365
) : null}
318366
</div>
319367
</div>
@@ -322,7 +370,7 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
322370
</div>
323371
</div> : null}
324372

325-
{jobs.length > 0 && !wideCalendar ? <div className="automation-calendar-agenda px-3 py-2 sm:px-4">
373+
{!wideCalendar ? <div className="automation-calendar-agenda px-3 py-2 sm:px-4">
326374
{visibleEntries.length ? (
327375
<ol>
328376
{days.filter((day) => entriesByDay.has(dateKey(day))).map((day) => (
@@ -363,47 +411,6 @@ export function AutomationCalendar({ jobs, locale, copy, onInspect }: Automation
363411
</div>
364412
) : null}
365413

366-
{outsideJobs.length ? (
367-
<details
368-
key={`${month.getTime()}:${visibleEntries.length ? "mixed" : "outside-only"}`}
369-
className="group"
370-
open={!visibleEntries.length || undefined}
371-
>
372-
<summary className={cn("automation-meta-row min-h-11 cursor-pointer list-none items-center text-[12px] text-muted-foreground settings-hover [&::-webkit-details-marker]:hidden", formControlFocusClassName)}>
373-
<Clock3 className="h-3.5 w-3.5 place-self-center" aria-hidden />
374-
<span className="flex min-w-0 items-center gap-2">
375-
<span className="truncate">{copy.outsideMonth}</span>
376-
<span className="tabular-nums text-muted-foreground/65">{outsideJobs.length}</span>
377-
</span>
378-
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180 motion-reduce:transition-none" aria-hidden />
379-
</summary>
380-
<ul className="px-4 pb-2 sm:px-6">
381-
{outsideJobs.map((job) => (
382-
<li key={job.id}>
383-
<button
384-
type="button"
385-
className={cn("grid min-h-10 w-full grid-cols-[1rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-control text-left text-[12px] settings-hover", formControlFocusClassName)}
386-
aria-haspopup="dialog"
387-
onClick={(event) => inspectOutsideJob(job, event.currentTarget)}
388-
>
389-
<span className="h-4 w-4" aria-hidden />
390-
<span className="truncate font-medium text-foreground">{job.name || job.id}</span>
391-
<span className="flex shrink-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
392-
{(job.state.last_status === "error" || job.state.last_error)
393-
? <span>{copy.attention}</span> : null}
394-
{!job.enabled ? <span>{copy.paused}</span> : null}
395-
{job.enabled && job.delete_after_run && job.state.last_status === "ok"
396-
? <span>{copy.completed}</span> : null}
397-
{job.enabled && !(job.delete_after_run && job.state.last_status === "ok")
398-
&& job.state.last_status !== "error" && !job.state.last_error
399-
? <span>{copy.outsideMonth}</span> : null}
400-
</span>
401-
</button>
402-
</li>
403-
))}
404-
</ul>
405-
</details>
406-
) : null}
407414
</section>
408415
);
409416
}

0 commit comments

Comments
 (0)