Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/components/automations-detail-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,13 @@ assert.match(cronPanel, /runStatusIcon\(r\.status\)/, "run rows encode status by
}

console.log("automations-detail-inputs.test.ts: ok");

// ── Rituals UI/UX debug pass (cave-v1x6) ─────────────────────────────────────
// Run timestamps format identically in the list rows and the detail panel
// (relativeTimeSigned), and the weekly cadence echo lists days in week order,
// not click order. Status/validation colors come from theme tokens.
assert.match(cronPanel, /import \{ relativeTimeSigned \} from "@\/lib\/relative-time"/, "cron panel reuses the shared relative-time helper");
assert.match(cronPanel, /return iso \? relativeTimeSigned\(iso\) : "—";/, "cron panel relTime matches schedule-list's formatting");
assert.match(cronPanel, /RRULE_DAY_ORDER\.filter\(\(d\) => scheduleDays\.includes\(d\)\)\.map\(\(d\) => RRULE_DAY_LABEL\[d\]\)/, "weekly cadence echo is week-ordered");
assert.doesNotMatch(cronPanel, /oklch\(0\.7[0-9]? 0\.1/, "cron panel active/status colors use tokens, not hardcoded oklch");

19 changes: 18 additions & 1 deletion src/components/automations-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,9 @@ assert.match(source, /role="img" aria-label="Paused"/, "status dots carry access
assert.match(source, /<section aria-labelledby=\{headingId\}/, "list sections are labelled landmarks with real headings");
assert.match(source, /<div[\s\S]{0,120}role="tabpanel"[\s\S]{0,120}id=\{`automations-panel-\$\{activeTab\}`\}[\s\S]{0,120}aria-labelledby=\{`automations-tab-\$\{activeTab\}`\}[\s\S]{0,180}aria-label=\{activeTab === "overview" \? "Rituals overview" : activeTab === "calendar" \? "Rituals calendar" : "Rituals crons"\}/, "the active Rituals panel is a tabpanel with the destination-specific label");
assert.match(source, /onClose=\{\(\) => \{ setCreateOpen\(false\); setTemplateInitialValues\(undefined\); \}\}/, "the create dialog closes through one reset path");
assert.match(source, /window\.setTimeout\(\(\) => newBtnRef\.current\?\.focus\(\), 0\)/, "deletes hand focus somewhere stable instead of dropping it on <body>");
assert.match(source, /window\.setTimeout\(\(\) => \(newBtnRef\.current \?\? newCronBtnRef\.current\)\?\.focus\(\), 0\)/, "deletes hand focus to whichever header action is mounted instead of dropping it on <body>");
assert.match(source, /const deleteCodex = useCallback\(\(auto: CodexAutomation\) => \{\s*setSelectedCodex\(null\);\s*focusHeaderAction\(\)/, "cron deletes restore focus via the shared helper (newBtnRef is unmounted on the crons tab)");
assert.match(source, /ref=\{newCronBtnRef\}/, "the crons-tab New button carries the focus-restore ref");

// ── 2026-07-03 audit batch C ──────────────────────────────────────────────────
// The Activity tab opens this panel for agent/response items too — those are
Expand Down Expand Up @@ -343,3 +345,18 @@ assert.match(source, /const hasRunningRun = automationRuns\.some\(\(r\) => r\.st
assert.match(source, /\}, \[selectedCodex\?\.id, hasRunningRun, refreshRuns\]\);/, "the poll effect does not depend on the runs array identity");
assert.match(source, /setAutomationRuns\(\(prev\) => \(arrayContentEqual\(prev, runs\) \? prev : runs\)\)/, "unchanged run polls keep the array identity");
assert.doesNotMatch(source, /void refreshRuns\(id\);\s*\n\s*void refreshLastRuns\(\);/, "the hot poll loop no longer fans out per-automation requests");

// ── Rituals UI/UX debug pass (cave-v1x6) ─────────────────────────────────────
// Overview search must never dead-end: the input renders whenever search is
// open (items.length gating made both the input AND the toggle icon vanish
// when the list was empty), and the Log pane shows activity time (firedAt ??
// updatedAt), not a future fireAt, so its timestamps read monotonically.
assert.match(source, /activeTab === "overview" && searchOpen && initialLoadDone \? \(/, "overview search input is not gated on items.length");
assert.match(source, /timeMode="log"/, "the Log pane renders rows in log time mode");
assert.match(source, /timeMode = "agenda"/, "RitualItemRow defaults to agenda time");
assert.match(source, /item\.firedAt \?\? item\.updatedAt \?\? item\.createdAt\s*:/, "log mode shows last-activity time (same key ritualLogItems sorts by)");

// Status dots + schedule-mode toggle derive from theme tokens, not hardcoded
// white — rgba(255,255,255,…) was invisible-on-light-themes.
assert.doesNotMatch(source, /rgba\(255,\s*255,\s*255/, "no hardcoded white rgba left in the Rituals surface");

22 changes: 14 additions & 8 deletions src/components/automations-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,14 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
// (pause/resume/run/create/save/restore) was silent.
const { announce } = useAnnouncer();
// Focus lands here after a delete unmounts the detail panel that held it —
// otherwise it falls to <body> and keyboard users lose their place.
// otherwise it falls to <body> and keyboard users lose their place. The
// overview "New" button and the crons "New cron" button are mounted on
// different tabs, so deletes focus whichever header action exists.
const newBtnRef = useRef<HTMLButtonElement | null>(null);
const newCronBtnRef = useRef<HTMLButtonElement | null>(null);
const focusHeaderAction = useCallback(() => {
window.setTimeout(() => (newBtnRef.current ?? newCronBtnRef.current)?.focus(), 0);
}, []);
const manageBtnRef = useRef<HTMLButtonElement | null>(null);
const overviewSwipeStartRef = useRef<number | null>(null);
const [storedActiveTab, setStoredActiveTab] = useSurfacePreference(surfacePreferenceSpecs.schedules.activeTab);
Expand Down Expand Up @@ -405,7 +411,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
if (prev?.id === id) {
// The detail panel (which held focus) unmounts — hand focus somewhere
// stable instead of letting it fall to <body>.
window.setTimeout(() => newBtnRef.current?.focus(), 0);
focusHeaderAction();
return null;
}
return prev;
Expand All @@ -419,7 +425,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
setError(err instanceof Error ? err.message : "delete failed");
} finally { await reloadAfterMutation(); }
});
}, [items, scheduleDelete, reloadAfterMutation]);
}, [items, scheduleDelete, reloadAfterMutation, focusHeaderAction]);

// Confirm before firing — crons and flows already do, and the identical Run
// buttons on the All tab must not behave differently per type.
Expand Down Expand Up @@ -578,7 +584,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi

const deleteCodex = useCallback((auto: CodexAutomation) => {
setSelectedCodex(null);
window.setTimeout(() => newBtnRef.current?.focus(), 0); // panel held focus
focusHeaderAction(); // panel held focus
scheduleDelete([auto.id], `automation “${auto.name}”`, async () => {
setCodexAutos((prev) => prev.filter((a) => a.id !== auto.id));
try {
Expand All @@ -589,7 +595,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
setError(err instanceof Error ? err.message : "codex delete failed");
} finally { await reloadAfterMutation(); }
});
}, [scheduleDelete, reloadAfterMutation]);
}, [scheduleDelete, reloadAfterMutation, focusHeaderAction]);

const runCodexNow = useCallback(async (auto: CodexAutomation) => {
if (!(await confirm({ title: `Run “${auto.name}” now?`, body: "This executes the agent immediately.", confirmLabel: "Run now" }))) return;
Expand Down Expand Up @@ -858,7 +864,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
</p>
)}
<div className="surface-compact-actions">
{activeTab === "overview" && searchOpen && initialLoadDone && items.length > 0 ? (
{activeTab === "overview" && searchOpen && initialLoadDone ? (
<div className="surface-compact-search">
<SearchInput
value={query}
Expand Down Expand Up @@ -970,7 +976,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
</Button>
) : null}
{activeTab === "crons" ? (
<Button size="sm" className="automation-create-chat-btn" leadingIcon="ph:plus" onClick={() => setCreateOpen(true)}>
<Button ref={newCronBtnRef} size="sm" className="automation-create-chat-btn" leadingIcon="ph:plus" onClick={() => setCreateOpen(true)}>
New cron
</Button>
) : null}
Expand Down Expand Up @@ -1177,7 +1183,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi
{overviewPane === "log" ? (
<div className="rituals-overview__log">
{ritualLog.length > 0 ? ritualLog.map((item) => (
<RitualItemRow key={item.id} item={item} familiarLabel={familiarLabel}
<RitualItemRow key={item.id} item={item} familiarLabel={familiarLabel} timeMode="log"
onSelect={(next) => { setSelectedItem(next); setSelectedCodex(null); }} />
)) : <p className="rituals-overview__empty">No quiet activity yet.</p>}
</div>
Expand Down
22 changes: 10 additions & 12 deletions src/components/automations/cron-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { formatTimestamp, readDateTimePrefs } from "@/lib/datetime-format";
import { Icon } from "@/lib/icon";
import { RRULE_DAY_LABEL, RRULE_DAY_ORDER, buildCodexRrule, parseCodexRrule, composeAutomationPrompt, splitAutomationPrompt } from "@/lib/codex-automation-form";
import { commaInput, listInput, parseListInput } from "@/lib/automations/list-input";
import { relativeTimeSigned } from "@/lib/relative-time";
import { runStatusColor, runStatusIcon } from "@/lib/automations/run-status";
import { CronDetailSection, CronSummaryTile, FieldLabel } from "@/components/automations/cron-detail-primitives";

Expand All @@ -26,13 +27,10 @@ const selectClass = `${fieldBaseClass} h-8 px-2 text-[length:var(--text-sm)]`;
const textareaClass = `${fieldBaseClass} resize-y px-2 py-2 text-[length:var(--text-sm)] leading-relaxed`;
const monoTextareaClass = `${textareaClass} font-mono text-[length:var(--text-xs)]`;

// Same relative formatting the list rows use (schedule-list.tsx), so a run
// never reads "3d ago" in the list but "Jul 20, 2:15 PM" in this panel.
function relTime(iso: string | undefined | null): string {
if (!iso) return "—";
const now = Date.now();
const then = new Date(iso).getTime();
const minutes = Math.round((then - now) / 60_000);
if (Math.abs(minutes) < 60) return `${Math.abs(minutes)}m ${minutes < 0 ? "ago" : "from now"}`;
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(iso));
return iso ? relativeTimeSigned(iso) : "—";
}

export function CodexDetailPanel({
Expand Down Expand Up @@ -260,7 +258,7 @@ export function CodexDetailPanel({
aria-pressed={scheduleMode === mode}
className="rounded-[var(--radius-control)] px-2 py-1 text-[length:var(--text-xs)]"
style={{
background: scheduleMode === mode ? "rgba(255,255,255,0.08)" : "transparent",
background: scheduleMode === mode ? "color-mix(in oklch, var(--foreground) 8%, transparent)" : "transparent",
color: scheduleMode === mode ? "var(--text-primary)" : "var(--text-muted)",
}}
>
Expand Down Expand Up @@ -319,10 +317,10 @@ export function CodexDetailPanel({
<p className="mt-2 text-[length:var(--text-xs)] [color:var(--text-muted)]!">
{scheduleMode === "daily"
? `Runs every day at ${scheduleTime}`
: `Runs weekly on ${scheduleDays.map((d) => RRULE_DAY_LABEL[d]).join(", ")} at ${scheduleTime}`}
: `Runs weekly on ${RRULE_DAY_ORDER.filter((d) => scheduleDays.includes(d)).map((d) => RRULE_DAY_LABEL[d]).join(", ")} at ${scheduleTime}`}
</p>
) : (
<p className="mt-2 break-all font-mono text-[length:var(--text-2xs)]" style={{ color: invalidSchedule ? "oklch(0.7 0.16 35)" : "var(--text-muted)" }}>
<p className="mt-2 break-all font-mono text-[length:var(--text-2xs)]" style={{ color: invalidSchedule ? "var(--color-warning)" : "var(--text-muted)" }}>
{nextRrule || "RRULE required"}
</p>
)}
Expand Down Expand Up @@ -444,10 +442,10 @@ export function CodexDetailPanel({
style={{
borderColor: isActive ? "color-mix(in oklch, var(--accent-presence) 45%, transparent)" : "var(--border-hairline)",
background: isActive ? "color-mix(in oklch, var(--accent-presence) 14%, transparent)" : "var(--bg-base)",
color: isActive ? "oklch(0.75 0.1 150)" : "var(--text-muted)",
color: isActive ? "var(--color-success)" : "var(--text-muted)",
}}
>
<span aria-hidden className="h-1.5 w-1.5 rounded-full" style={{ background: isActive ? "oklch(0.75 0.1 150)" : "var(--text-muted)" }} />
<span aria-hidden className="h-1.5 w-1.5 rounded-full" style={{ background: isActive ? "var(--color-success)" : "var(--text-muted)" }} />
{isActive ? "Active" : "Paused"}
</span>
<Button
Expand Down Expand Up @@ -510,7 +508,7 @@ export function CodexDetailPanel({
<div className="cron-detail-actions border-t px-5 py-4 [border-color:var(--border-hairline)]!">
<div className={`space-y-3${expanded ? " mx-auto w-full max-w-xl" : ""}`}>
{saveBlockedReason ? (
<p className="text-[length:var(--text-xs)] [color:oklch(0.7_0.16_35)]!" role="alert">
<p className="text-[length:var(--text-xs)] [color:var(--color-warning)]!" role="alert">
{saveBlockedReason}
</p>
) : null}
Expand Down
2 changes: 1 addition & 1 deletion src/components/automations/cron-detail-primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export function CronDetailSection({ title, description, className, children }: {
}

export function CronSummaryTile({ label, value, tone = "default" }: { label: string; value: ReactNode; tone?: "default" | "active" | "paused" | "danger" }) {
const color = tone === "active" ? "oklch(0.75 0.1 150)" : tone === "danger" ? "var(--color-danger)" : tone === "paused" ? "var(--text-muted)" : "var(--text-primary)";
const color = tone === "active" ? "var(--color-success)" : tone === "danger" ? "var(--color-danger)" : tone === "paused" ? "var(--text-muted)" : "var(--text-primary)";
return <div className="rounded-[var(--radius-control)] border px-3 py-2 [border-color:var(--border-hairline)]! [background:var(--bg-base)]!"><p className="text-[length:var(--text-2xs)] font-semibold uppercase tracking-widest [color:var(--text-muted)]!">{label}</p><div className="mt-1 min-w-0 truncate text-[length:var(--text-sm)] font-medium" style={{ color }}>{value}</div></div>;
}
6 changes: 3 additions & 3 deletions src/components/automations/reminder-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export function DetailPanel({
<FieldLabel>{isDailySummary ? "Sent" : isReminder ? "Last run" : "Received"}</FieldLabel>
<p
className="text-[length:var(--text-sm)]"
style={{ color: item.firedAt ? "oklch(0.75 0.1 150)" : "var(--text-muted)" }}
style={{ color: item.firedAt ? "var(--color-success)" : "var(--text-muted)" }}
title={item.firedAt ? formatTimestamp(item.firedAt, readDateTimePrefs()) : undefined}
>
{item.firedAt ? relTime(item.firedAt) : isReminder ? "Never" : "—"}
Expand Down Expand Up @@ -378,7 +378,7 @@ export function DetailPanel({
</Button>
)}
<Button variant="ghost" size="xs" onClick={() => removeItem(item.id)} disabled={busy}
className="rounded-[var(--radius-control)] p-0.5 text-[length:var(--text-xs)] text-[oklch(0.65_0.18_20)] transition-colors disabled:opacity-40 hover:underline">
className="rounded-[var(--radius-control)] p-0.5 text-[length:var(--text-xs)] text-[var(--color-danger)] transition-colors disabled:opacity-40 hover:underline">
Delete
</Button>
</div>
Expand Down Expand Up @@ -435,7 +435,7 @@ export function DetailPanel({
fullWidth
disabled={busy}
onClick={() => removeItem(item.id)}
className="rounded-[var(--radius-control)] py-2 text-[length:var(--text-sm)] font-medium transition-colors hover:bg-red-900/20 disabled:opacity-40 [color:oklch(0.65_0.18_20)]!"
className="rounded-[var(--radius-control)] py-2 text-[length:var(--text-sm)] font-medium transition-colors hover:bg-[color-mix(in_oklch,var(--color-danger)_12%,transparent)] disabled:opacity-40 [color:var(--color-danger)]!"
>
Delete
</Button>
Expand Down
9 changes: 7 additions & 2 deletions src/components/automations/ritual-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,22 @@ function RitualActions({ children }: { children: ReactNode }) {
return <span className="flex shrink-0 items-center gap-0.5 pl-1">{children}</span>;
}

export function RitualItemRow({ item, familiarLabel, onSelect, quiet = false }: { item: InboxItem; familiarLabel: (familiarId?: string | null) => string | null; onSelect: (item: InboxItem) => void; quiet?: boolean }) {
export function RitualItemRow({ item, familiarLabel, onSelect, quiet = false, timeMode = "agenda" }: { item: InboxItem; familiarLabel: (familiarId?: string | null) => string | null; onSelect: (item: InboxItem) => void; quiet?: boolean; timeMode?: "agenda" | "log" }) {
const familiar = familiarLabel(item.familiarId);
const date = ritualItemDate(item);
// The Log pane sorts by last activity (firedAt ?? updatedAt) — show that
// same timestamp, not a future fireAt, so its times read monotonically.
const shownIso = timeMode === "log"
? item.firedAt ?? item.updatedAt ?? item.createdAt
: date ? date.toISOString() : item.updatedAt;
return (
<button type="button" className={`rituals-overview__row focus-ring-inset${quiet ? " rituals-overview__row--quiet" : ""}`} onClick={() => onSelect(item)}>
<StatusIcon item={item} />
<span className="rituals-overview__row-title">{item.title}</span>
<span className="rituals-overview__kind">{inboxKindLabel(item.kind)}</span>
{familiar ? <span className="rituals-overview__meta">{familiar}</span> : null}
<span className="rituals-overview__spacer" />
<span className="rituals-overview__meta">{date ? relativeTime(date.toISOString()) : relativeTime(item.updatedAt)}</span>
<span className="rituals-overview__meta">{relativeTime(shownIso)}</span>
</button>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/automations/schedule-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function AutomationScheduleRow({
{isActive ? (
<span role="img" aria-label="Active" className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full [background:var(--accent-presence)]!" />
) : (
<span role="img" aria-label="Paused" className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border [border-color:rgba(255,255,255,0.18)]! [color:rgba(255,255,255,0.35)]!">
<span role="img" aria-label="Paused" className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border [border-color:color-mix(in_oklch,var(--foreground)_18%,transparent)]! [color:color-mix(in_oklch,var(--foreground)_35%,transparent)]!">
<Icon name="ph:minus" width={8} />
</span>
)}
Expand Down
Loading
Loading