Skip to content

Commit cf433bb

Browse files
authored
Merge pull request Th0rgal#539 from Th0rgal/feat/automations-hide-spent-wakeups
feat(automations): collapse completed wakeups into a history section
2 parents 6b6bafa + 8de0319 commit cf433bb

2 files changed

Lines changed: 102 additions & 18 deletions

File tree

dashboard/src/components/mission-automations-dialog.test.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,16 @@ describe("prepareVisibleAutomations", () => {
4848
overrides: Partial<{
4949
active: boolean;
5050
created_at: string;
51+
last_triggered_at: string | null;
52+
stop_policy: { type: string };
5153
command_source: { type: string };
5254
}> = {},
5355
) => ({
5456
id,
5557
active: overrides.active ?? true,
5658
created_at: overrides.created_at ?? "2026-05-21T00:00:00Z",
59+
last_triggered_at: overrides.last_triggered_at ?? null,
60+
stop_policy: overrides.stop_policy ?? { type: "never" },
5761
command_source: overrides.command_source ?? { type: "inline" },
5862
});
5963

@@ -63,7 +67,7 @@ describe("prepareVisibleAutomations", () => {
6367
make("running", { active: true, command_source: { type: "native_loop" } }),
6468
]);
6569

66-
expect(result.map((a) => a.id)).toEqual(["running"]);
70+
expect(result.live.map((a) => a.id)).toEqual(["running"]);
6771
});
6872

6973
it("preserves inactive non-native_loop rows (user-paused automations stay visible)", () => {
@@ -72,7 +76,7 @@ describe("prepareVisibleAutomations", () => {
7276
make("paused-library", { active: false, command_source: { type: "library" } }),
7377
]);
7478

75-
expect(result.map((a) => a.id).sort()).toEqual(
79+
expect(result.live.map((a) => a.id).sort()).toEqual(
7680
["paused-by-user", "paused-library"].sort(),
7781
);
7882
});
@@ -83,7 +87,7 @@ describe("prepareVisibleAutomations", () => {
8387
make("active-old", { active: true, created_at: "2026-05-20T00:00:00Z" }),
8488
]);
8589

86-
expect(result.map((a) => a.id)).toEqual(["active-old", "paused"]);
90+
expect(result.live.map((a) => a.id)).toEqual(["active-old", "paused"]);
8791
});
8892

8993
it("within the same active state, sorts newest first", () => {
@@ -93,7 +97,7 @@ describe("prepareVisibleAutomations", () => {
9397
make("middle", { active: true, created_at: "2026-05-21T00:00:00Z" }),
9498
]);
9599

96-
expect(result.map((a) => a.id)).toEqual(["newest", "middle", "oldest"]);
100+
expect(result.live.map((a) => a.id)).toEqual(["newest", "middle", "oldest"]);
97101
});
98102

99103
it("does not mutate the input array (sort + filter return a fresh list)", () => {
@@ -129,6 +133,27 @@ describe("prepareVisibleAutomations", () => {
129133
intervalDriver,
130134
]);
131135

132-
expect(result.map((a) => a.id)).toEqual(["interval-driver"]);
136+
expect(result.live.map((a) => a.id)).toEqual(["interval-driver"]);
137+
});
138+
139+
it("routes fired one-shot wakeups (inactive + last_triggered_at) to spent", () => {
140+
const result = prepareVisibleAutomations([
141+
make("live-driver", { active: true }),
142+
make("paused-never-fired", { active: false, last_triggered_at: null }),
143+
make("spent-wakeup", {
144+
active: false,
145+
last_triggered_at: "2026-05-22T10:00:00Z",
146+
stop_policy: { type: "after_first_fire" },
147+
}),
148+
make("paused-recurring", {
149+
active: false,
150+
last_triggered_at: "2026-05-22T10:00:00Z",
151+
stop_policy: { type: "never" },
152+
}),
153+
]);
154+
expect(result.live.map((a) => a.id).sort()).toEqual(
155+
["live-driver", "paused-never-fired", "paused-recurring"].sort(),
156+
);
157+
expect(result.spent.map((a) => a.id)).toEqual(["spent-wakeup"]);
133158
});
134159
});

dashboard/src/components/mission-automations-dialog.tsx

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -215,20 +215,42 @@ export function prepareVisibleAutomations<
215215
A extends {
216216
active: boolean;
217217
created_at?: string;
218+
last_triggered_at?: string | null;
219+
stop_policy?: { type: string } | null;
218220
command_source?: { type: string } | null;
219221
}
220-
>(automations: A[]): A[] {
221-
return automations
222-
.filter(
223-
(a) => !(a.command_source?.type === 'native_loop' && !a.active)
224-
)
225-
.slice()
226-
.sort((a, b) => {
227-
if (a.active !== b.active) return a.active ? -1 : 1;
228-
const aTime = a.created_at ? new Date(a.created_at).getTime() : 0;
229-
const bTime = b.created_at ? new Date(b.created_at).getTime() : 0;
230-
return bTime - aTime;
231-
});
222+
>(automations: A[]): { live: A[]; spent: A[] } {
223+
const sortByCreated = (a: A, b: A) => {
224+
if (a.active !== b.active) return a.active ? -1 : 1;
225+
const aTime = a.created_at ? new Date(a.created_at).getTime() : 0;
226+
const bTime = b.created_at ? new Date(b.created_at).getTime() : 0;
227+
return bTime - aTime;
228+
};
229+
const kept = automations.filter(
230+
(a) => !(a.command_source?.type === 'native_loop' && !a.active),
231+
);
232+
// "Spent" = an inactive automation that already fired at least once — almost
233+
// always a one-shot ScheduleWakeup the harness completed. These are history,
234+
// not actionable, and used to bury the single live driver under dozens of
235+
// dead rows. Anything still active, or inactive-but-never-fired (a paused or
236+
// failed-on-create automation worth seeing), stays in `live`.
237+
const live: A[] = [];
238+
const spent: A[] = [];
239+
for (const a of kept) {
240+
// Spent = a fired ONE-SHOT (stop_policy after_first_fire) that is now
241+
// inactive — i.e. a completed ScheduleWakeup. A user-paused recurring
242+
// automation (interval/webhook, different stop policy) is NOT spent even
243+
// though it has fired before; it stays in `live` so it remains editable.
244+
const isFiredOneShot =
245+
!a.active &&
246+
!!a.last_triggered_at &&
247+
a.stop_policy?.type === "after_first_fire";
248+
if (isFiredOneShot) spent.push(a);
249+
else live.push(a);
250+
}
251+
live.sort(sortByCreated);
252+
spent.sort(sortByCreated);
253+
return { live, spent };
232254
}
233255

234256
export function MissionAutomationsDialog({
@@ -250,6 +272,7 @@ export function MissionAutomationsDialog({
250272
const [hasLoaded, setHasLoaded] = useState(false);
251273
const [loadedMissionId, setLoadedMissionId] = useState<string | null>(null);
252274
const [error, setError] = useState<string | null>(null);
275+
const [showSpent, setShowSpent] = useState(false);
253276

254277
useEffect(() => {
255278
automationsRef.current = automations;
@@ -916,7 +939,9 @@ export function MissionAutomationsDialog({
916939

917940
const isMissionDataReady = !!missionId && loadedMissionId === missionId;
918941
const showLoadingPlaceholder = !!missionId && (!isMissionDataReady || (loading && !hasLoaded));
919-
const visibleAutomations = isMissionDataReady ? prepareVisibleAutomations(automations) : [];
942+
const { live: visibleAutomations, spent: spentAutomations } = isMissionDataReady
943+
? prepareVisibleAutomations(automations)
944+
: { live: [], spent: [] };
920945
const visibleError = isMissionDataReady ? error : null;
921946
const selectClass =
922947
'rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50 appearance-none cursor-pointer';
@@ -1422,6 +1447,7 @@ export function MissionAutomationsDialog({
14221447
{isMissionDataReady &&
14231448
!loading &&
14241449
visibleAutomations.length === 0 &&
1450+
spentAutomations.length === 0 &&
14251451
!visibleError && (
14261452
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-6 text-center text-sm text-white/40">
14271453
No automations yet. Create one above.
@@ -1703,6 +1729,39 @@ export function MissionAutomationsDialog({
17031729
);
17041730
})}
17051731
</div>
1732+
1733+
{spentAutomations.length > 0 && (
1734+
<div className="mt-3 border-t border-white/[0.06] pt-2">
1735+
<button
1736+
type="button"
1737+
onClick={() => setShowSpent((v) => !v)}
1738+
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-[11px] text-white/40 hover:bg-white/[0.04] hover:text-white/60"
1739+
>
1740+
<span>
1741+
{showSpent ? 'Hide' : 'Show'} {spentAutomations.length} completed
1742+
{' '}wakeup{spentAutomations.length === 1 ? '' : 's'} (history)
1743+
</span>
1744+
<span>{showSpent ? '▾' : '▸'}</span>
1745+
</button>
1746+
{showSpent && (
1747+
<div className="mt-1 space-y-1">
1748+
{spentAutomations.map((automation) => (
1749+
<div
1750+
key={automation.id}
1751+
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 text-[11px] text-white/35"
1752+
>
1753+
<span className="truncate">{getAutomationLabel(automation)}</span>
1754+
<span className="shrink-0 text-white/25">
1755+
{automation.last_triggered_at
1756+
? new Date(automation.last_triggered_at).toLocaleString()
1757+
: ''}
1758+
</span>
1759+
</div>
1760+
))}
1761+
</div>
1762+
)}
1763+
</div>
1764+
)}
17061765
</div>
17071766
</>
17081767
)}

0 commit comments

Comments
 (0)