palmier 0.7.8 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/mcp-tools.d.ts +2 -0
  3. package/dist/mcp-tools.js +4 -2
  4. package/dist/platform/linux.js +11 -8
  5. package/dist/platform/windows.d.ts +5 -6
  6. package/dist/platform/windows.js +15 -12
  7. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  8. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  9. package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
  10. package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
  11. package/dist/pwa/index.html +2 -2
  12. package/dist/pwa/service-worker.js +1 -1
  13. package/dist/rpc-handler.js +23 -8
  14. package/dist/task.js +1 -1
  15. package/dist/transports/http-transport.js +3 -5
  16. package/dist/types.d.ts +9 -6
  17. package/package.json +1 -1
  18. package/palmier-server/README.md +3 -3
  19. package/palmier-server/pwa/src/App.css +117 -36
  20. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  21. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  22. package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
  23. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
  24. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  25. package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
  26. package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
  27. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  28. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  29. package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
  30. package/palmier-server/pwa/src/types.ts +5 -9
  31. package/palmier-server/spec.md +23 -23
  32. package/src/mcp-tools.ts +6 -2
  33. package/src/platform/linux.ts +10 -8
  34. package/src/platform/windows.ts +15 -12
  35. package/src/rpc-handler.ts +26 -10
  36. package/src/task.ts +1 -1
  37. package/src/transports/http-transport.ts +3 -5
  38. package/src/types.ts +9 -7
  39. package/dist/pwa/assets/index-8cTctVnD.js +0 -120
  40. package/dist/pwa/assets/index-CSUkBBsQ.css +0 -1
@@ -0,0 +1,46 @@
1
+ interface Props {
2
+ pullDistance: number;
3
+ refreshing: boolean;
4
+ threshold: number;
5
+ }
6
+
7
+ /**
8
+ * Fixed-position indicator parked just above the viewport that slides down as
9
+ * the user pulls. Opacity ramps up quickly so the badge peeks in immediately;
10
+ * the arrow flips upward once the pull has crossed the release threshold.
11
+ */
12
+ export default function PullToRefreshIndicator({ pullDistance, refreshing, threshold }: Props) {
13
+ const visible = pullDistance > 0 || refreshing;
14
+ const ready = pullDistance >= threshold;
15
+ return (
16
+ <div
17
+ className="pull-to-refresh"
18
+ style={{
19
+ transform: `translateY(${pullDistance}px)`,
20
+ opacity: visible ? Math.min(1, pullDistance / 30) : 0,
21
+ }}
22
+ aria-hidden={!visible}
23
+ >
24
+ <div className="pull-to-refresh-badge">
25
+ {refreshing ? (
26
+ <div className="spinner" />
27
+ ) : (
28
+ <svg
29
+ width="18"
30
+ height="18"
31
+ viewBox="0 0 24 24"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ strokeWidth="2.25"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ style={{ transform: ready ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
38
+ >
39
+ <line x1="12" y1="5" x2="12" y2="19" />
40
+ <polyline points="19 12 12 19 5 12" />
41
+ </svg>
42
+ )}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -30,8 +30,33 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
30
30
  const [followupText, setFollowupText] = useState("");
31
31
  const [sendingFollowup, setSendingFollowup] = useState(false);
32
32
  const threadRef = useRef<HTMLDivElement>(null);
33
+ const followupInputRef = useRef<HTMLInputElement>(null);
34
+ // Tracks which runId we've already scrolled-and-focused for, so new messages
35
+ // during the session don't steal focus back to the input.
36
+ const initialFocusForRunId = useRef<string | null>(null);
37
+
38
+ // Resolve the "latest" sentinel to an actual run_id. `undefined` = still
39
+ // resolving, `null` = task has no runs yet (empty state), otherwise the id
40
+ // to load.
41
+ const [resolvedRunId, setResolvedRunId] = useState<string | null | undefined>(
42
+ runId === "latest" ? undefined : runId,
43
+ );
44
+ const isLatestEmpty = runId === "latest" && resolvedRunId === null;
45
+
46
+ useEffect(() => {
47
+ if (runId !== "latest") {
48
+ setResolvedRunId(runId);
49
+ return;
50
+ }
51
+ if (!connected) return;
52
+ setResolvedRunId(undefined);
53
+ request<{ entries?: Array<{ run_id: string }> }>("taskrun.list", { task_id: taskId, limit: 1 })
54
+ .then((result) => setResolvedRunId(result.entries?.[0]?.run_id ?? null))
55
+ .catch(() => setResolvedRunId(null));
56
+ }, [runId, taskId, connected, request]);
33
57
 
34
58
  async function fetchData() {
59
+ if (!resolvedRunId) return;
35
60
  try {
36
61
  const result = await request<{
37
62
  messages?: ConversationMessage[];
@@ -40,7 +65,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
40
65
  running_state?: string;
41
66
  agent?: string;
42
67
  error?: string;
43
- }>("task.result", { id: taskId, run_id: runId });
68
+ }>("task.result", { id: taskId, run_id: resolvedRunId });
44
69
 
45
70
  if (result.error) {
46
71
  console.error("No result:", result.error);
@@ -59,9 +84,10 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
59
84
  }
60
85
 
61
86
  async function openReport(file: string) {
87
+ if (!resolvedRunId) return;
62
88
  try {
63
89
  const result = await request<{ reports: Array<{ file: string; content?: string; data_url?: string }> }>(
64
- "task.reports", { id: taskId, run_id: runId, report_files: [file] },
90
+ "task.reports", { id: taskId, run_id: resolvedRunId, report_files: [file] },
65
91
  );
66
92
  const report = result.reports?.[0];
67
93
  if (report?.data_url) {
@@ -74,30 +100,28 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
74
100
  }
75
101
  }
76
102
 
77
- // Initial load
103
+ // Initial load once resolvedRunId becomes a concrete id.
78
104
  useEffect(() => {
79
- if (connected) {
80
- setLoading(true);
81
- fetchData();
82
- }
83
- }, [connected, taskId, runId]);
105
+ if (!connected || !resolvedRunId) return;
106
+ setLoading(true);
107
+ fetchData();
108
+ }, [connected, taskId, resolvedRunId]);
84
109
 
85
110
  // Live-update when the viewed task's state changes
86
111
  useEffect(() => {
87
- if (!connected || !hostId) return;
112
+ if (!connected || !hostId || !resolvedRunId) return;
88
113
  const unsubscribe = subscribeEvents(hostId, async (msg) => {
89
114
  try {
90
115
  const parsed = JSON.parse(new TextDecoder().decode(msg.data)) as { event_type?: string; run_id?: string };
91
116
  if (parsed.event_type !== "running-state" && parsed.event_type !== "result-updated") return;
92
117
  const eventTaskId = msg.subject.split(".").pop();
93
118
  if (eventTaskId !== taskId) return;
94
- // result-updated events are scoped to a specific result file
95
- if (parsed.event_type === "result-updated" && parsed.run_id && parsed.run_id !== runId) return;
119
+ if (parsed.event_type === "result-updated" && parsed.run_id && parsed.run_id !== resolvedRunId) return;
96
120
  fetchData();
97
121
  } catch { /* skip */ }
98
122
  });
99
123
  return unsubscribe;
100
- }, [connected, hostId, taskId, subscribeEvents, request]);
124
+ }, [connected, hostId, taskId, resolvedRunId, subscribeEvents, request]);
101
125
 
102
126
  // Auto-scroll to bottom when messages change
103
127
  useEffect(() => {
@@ -106,6 +130,19 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
106
130
  }
107
131
  }, [messages]);
108
132
 
133
+ // On first load of a run, scroll the window to the bottom so the follow-up
134
+ // input is visible, and focus the input if it's rendered (agent not running).
135
+ useEffect(() => {
136
+ if (loading || isLatestEmpty || !resolvedRunId) return;
137
+ if (initialFocusForRunId.current === resolvedRunId) return;
138
+ initialFocusForRunId.current = resolvedRunId;
139
+ requestAnimationFrame(() => {
140
+ if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
141
+ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "auto" });
142
+ if (!isAgentGenerating) followupInputRef.current?.focus();
143
+ });
144
+ }, [loading, isLatestEmpty, resolvedRunId, isAgentGenerating]);
145
+
109
146
  function typeLabel(type?: string): string | undefined {
110
147
  if (type === "input") return "User Input";
111
148
  if (type === "permission") return "Permission";
@@ -133,7 +170,12 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
133
170
  Back
134
171
  </button>
135
172
 
136
- {loading ? (
173
+ {isLatestEmpty ? (
174
+ <div className="empty-state">
175
+ <p className="empty-state-text">No runs yet</p>
176
+ <p className="empty-state-hint">This task hasn't been executed yet. Run it from the task menu or wait for its next trigger.</p>
177
+ </div>
178
+ ) : loading ? (
137
179
  <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", padding: "var(--space-sm) 0" }}>
138
180
  <div className="skeleton-line" style={{ width: "40%" }} />
139
181
  <div className="skeleton-line" style={{ width: "55%" }} />
@@ -223,7 +265,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
223
265
  onClick={async () => {
224
266
  setAborting(true);
225
267
  try {
226
- await request("task.stop_followup", { id: taskId, run_id: runId });
268
+ await request("task.stop_followup", { id: taskId, run_id: resolvedRunId });
227
269
  } catch (err) {
228
270
  console.error("Stop failed:", err);
229
271
  } finally {
@@ -243,7 +285,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
243
285
  if (!msg || sendingFollowup) return;
244
286
  setSendingFollowup(true);
245
287
  try {
246
- await request("task.followup", { id: taskId, run_id: runId, message: msg });
288
+ await request("task.followup", { id: taskId, run_id: resolvedRunId, message: msg });
247
289
  setFollowupText("");
248
290
  } catch (err) {
249
291
  console.error("Follow-up failed:", err);
@@ -253,6 +295,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
253
295
  }}
254
296
  >
255
297
  <input
298
+ ref={followupInputRef}
256
299
  className="chat-input"
257
300
  type="text"
258
301
  placeholder="Follow-up message"
@@ -38,6 +38,14 @@ export default function SessionComposer({ agents, onStarted }: SessionComposerPr
38
38
  return () => setDraftMessage(null);
39
39
  }, [prompt]);
40
40
 
41
+ const selectedAgent = agents.find((a) => a.key === agent);
42
+ const agentSupportsYolo = !!selectedAgent?.supportsYolo;
43
+
44
+ // Force-disable yolo when the selected agent doesn't support it.
45
+ useEffect(() => {
46
+ if (!agentSupportsYolo && yoloMode) setYoloMode(false);
47
+ }, [agentSupportsYolo, yoloMode]);
48
+
41
49
  const canRun = !!prompt.trim() && !!agent && !running;
42
50
 
43
51
  function confirmYolo(): boolean {
@@ -104,15 +112,17 @@ export default function SessionComposer({ agents, onStarted }: SessionComposerPr
104
112
  ))}
105
113
  </select>
106
114
  </div>
107
- <label className="session-composer-yolo">
108
- <input
109
- type="checkbox"
110
- checked={yoloMode}
111
- onChange={(e) => setYoloMode(e.target.checked)}
112
- disabled={running}
113
- />
114
- Yolo
115
- </label>
115
+ {agentSupportsYolo && (
116
+ <label className="session-composer-yolo">
117
+ <input
118
+ type="checkbox"
119
+ checked={yoloMode}
120
+ onChange={(e) => setYoloMode(e.target.checked)}
121
+ disabled={running}
122
+ />
123
+ Yolo
124
+ </label>
125
+ )}
116
126
  <button
117
127
  className="btn btn-primary chat-send-btn"
118
128
  onClick={handleRun}
@@ -127,7 +137,7 @@ export default function SessionComposer({ agents, onStarted }: SessionComposerPr
127
137
  )}
128
138
  </button>
129
139
  </div>
130
- {yoloMode && (
140
+ {agentSupportsYolo && yoloMode && (
131
141
  <p className="command-help-text">
132
142
  The agent will auto-approve all tool calls without asking for permission.
133
143
  </p>
@@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom";
3
3
  import { formatTime } from "../formatTime";
4
4
  import { confirmLeaveDraft } from "../draftGuard";
5
5
  import SessionComposer from "./SessionComposer";
6
+ import PullToRefreshIndicator from "./PullToRefreshIndicator";
7
+ import { usePullToRefresh } from "../hooks/usePullToRefresh";
6
8
  import type { AgentInfo, HistoryEntry } from "../types";
7
9
 
8
- interface RunsViewProps {
10
+ interface SessionsViewProps {
9
11
  connected: boolean;
10
12
  hostId: string | null;
11
13
  request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
@@ -17,7 +19,7 @@ interface RunsViewProps {
17
19
 
18
20
  const PAGE_SIZE = 10;
19
21
 
20
- export default function RunsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: RunsViewProps) {
22
+ export default function SessionsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: SessionsViewProps) {
21
23
  const [entries, setEntries] = useState<HistoryEntry[]>([]);
22
24
  const [total, setTotal] = useState(0);
23
25
  const [loading, setLoading] = useState(false);
@@ -76,6 +78,11 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
76
78
  }
77
79
  }, [connected, hostId, loadHistory, filterTaskId]);
78
80
 
81
+ const ptr = usePullToRefresh({
82
+ onRefresh: () => loadHistory(0, false),
83
+ enabled: connected,
84
+ });
85
+
79
86
  // Real-time: update entries on running-state events
80
87
  useEffect(() => {
81
88
  if (!connected || !hostId) return;
@@ -156,6 +163,17 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
156
163
  }}
157
164
  />
158
165
  );
166
+ const refreshIndicator = (
167
+ <PullToRefreshIndicator pullDistance={ptr.pullDistance} refreshing={ptr.refreshing} threshold={ptr.threshold} />
168
+ );
169
+ const filterChip = filterTaskId && onClearFilter && (
170
+ <div style={{ marginBottom: "var(--space-sm)" }}>
171
+ <span className="sessions-filter-chip">
172
+ Filtered by task
173
+ <button onClick={onClearFilter} aria-label="Clear filter">&times;</button>
174
+ </span>
175
+ </div>
176
+ );
159
177
 
160
178
  function stateColor(state?: string): string | undefined {
161
179
  if (state === "failed") return "var(--color-error)";
@@ -167,6 +185,7 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
167
185
  if (loading && entries.length === 0 && connected) {
168
186
  return (
169
187
  <>
188
+ {refreshIndicator}
170
189
  {composer}
171
190
  <div className="task-list">
172
191
  {[0, 1, 2].map((i) => (
@@ -190,8 +209,9 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
190
209
  if (!connected || (loading && entries.length === 0)) {
191
210
  return (
192
211
  <>
212
+ {refreshIndicator}
193
213
  {composer}
194
- <div className="runs-view">
214
+ <div className="sessions-view">
195
215
  <div className="empty-state">
196
216
  <p className="empty-state-text">Sessions</p>
197
217
  <p className="empty-state-hint">Your sessions will appear here</p>
@@ -204,16 +224,10 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
204
224
  if (entries.length === 0) {
205
225
  return (
206
226
  <>
227
+ {refreshIndicator}
207
228
  {composer}
208
- {filterTaskId && onClearFilter && (
209
- <div style={{ marginBottom: "var(--space-sm)" }}>
210
- <span className="runs-filter-chip">
211
- Filtered by task
212
- <button onClick={onClearFilter} aria-label="Clear filter">&times;</button>
213
- </span>
214
- </div>
215
- )}
216
- <div className="runs-view">
229
+ {filterChip}
230
+ <div className="sessions-view">
217
231
  <div className="empty-state">
218
232
  <p className="empty-state-text">No sessions yet</p>
219
233
  <p className="empty-state-hint">
@@ -229,25 +243,19 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
229
243
 
230
244
  return (
231
245
  <>
246
+ {refreshIndicator}
232
247
  {composer}
233
- {filterTaskId && onClearFilter && (
234
- <div style={{ marginBottom: "var(--space-sm)" }}>
235
- <span className="runs-filter-chip">
236
- Filtered by task
237
- <button onClick={onClearFilter} aria-label="Clear filter">&times;</button>
238
- </span>
239
- </div>
240
- )}
248
+ {filterChip}
241
249
  <div className="task-list">
242
250
  {entries.map((entry, i) => (
243
251
  <div
244
252
  key={`${entry.task_id}-${entry.run_id}-${i}`}
245
- className="runs-card"
253
+ className="sessions-card"
246
254
  onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
247
255
  >
248
- <div className="runs-card-body">
249
- <h3 className="runs-card-name">{entry.task_name || entry.task_id}</h3>
250
- <div className="runs-card-meta">
256
+ <div className="sessions-card-body">
257
+ <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
258
+ <div className="sessions-card-meta">
251
259
  <span style={{ color: stateColor(entry.running_state) }}>
252
260
  {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
253
261
  </span>
@@ -262,7 +270,7 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
262
270
  )}
263
271
  </div>
264
272
  </div>
265
- <span className="runs-card-chevron">&#8250;</span>
273
+ <span className="sessions-card-chevron">&#8250;</span>
266
274
  </div>
267
275
  ))}
268
276
 
@@ -23,8 +23,9 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
23
23
  const menuRef = useRef<HTMLDivElement>(null);
24
24
 
25
25
  const isRunning = lastEvent?.running_state === "started";
26
+ const scheduleActive = !!task.schedule_enabled && !!task.schedule_type && (task.schedule_values?.length ?? 0) > 0;
26
27
  const stateColor =
27
- !task.triggers_enabled || task.triggers.length === 0
28
+ !scheduleActive
28
29
  ? "var(--color-text-secondary)"
29
30
  : isRunning
30
31
  ? "var(--color-success)"
@@ -99,34 +100,14 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
99
100
  };
100
101
 
101
102
 
102
- function formatTriggersGrouped(triggers: { type: string; value: string }[]): string {
103
- if (triggers.length === 0) return "";
104
- if (triggers.length === 1) return formatSingleTrigger(triggers[0]);
105
-
106
- // Detect the shared schedule type
107
- const classified = triggers.map(classifyTrigger);
108
- const types = new Set(classified.map((c) => c.kind));
109
-
110
- // If all the same type, group them
111
- if (types.size === 1) {
112
- const kind = classified[0].kind;
113
- if (kind === "hourly") return "Every hour";
114
- const details = classified.map((c) => c.detail);
115
- return `${kind.charAt(0).toUpperCase() + kind.slice(1)}: ${details.join(", ")}`;
116
- }
117
-
118
- // Mixed types — fall back to listing each
119
- return triggers.map(formatSingleTrigger).join(", ");
120
- }
121
-
122
- function classifyTrigger(t: { type: string; value: string }): { kind: string; detail: string } {
123
- if (t.type === "once") {
124
- const d = new Date(t.value);
125
- const label = isNaN(d.getTime()) ? t.value : `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
126
- return { kind: "once", detail: label };
103
+ function classifyValue(scheduleType: "crons" | "specific_times", value: string): { kind: string; detail: string } {
104
+ if (scheduleType === "specific_times") {
105
+ const d = new Date(value);
106
+ const label = isNaN(d.getTime()) ? value : `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} at ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
107
+ return { kind: "specific_times", detail: label };
127
108
  }
128
- const parts = t.value.split(" ");
129
- if (parts.length !== 5) return { kind: "unknown", detail: t.value };
109
+ const parts = value.split(" ");
110
+ if (parts.length !== 5) return { kind: "unknown", detail: value };
130
111
  const [min, hour, dom, , dow] = parts;
131
112
  const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
132
113
  if (hour === "*") return { kind: "hourly", detail: "" };
@@ -138,14 +119,31 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
138
119
  return { kind: "daily", detail: time };
139
120
  }
140
121
 
141
- function formatSingleTrigger(t: { type: string; value: string }): string {
142
- const c = classifyTrigger(t);
122
+ function formatSingleValue(scheduleType: "crons" | "specific_times", value: string): string {
123
+ const c = classifyValue(scheduleType, value);
143
124
  if (c.kind === "hourly") return "Every hour";
144
- if (c.kind === "once") return `Once on ${c.detail}`;
125
+ if (c.kind === "specific_times") return `Once on ${c.detail}`;
145
126
  return `${c.kind.charAt(0).toUpperCase() + c.kind.slice(1)}: ${c.detail}`;
146
127
  }
147
128
 
148
- const triggersText = formatTriggersGrouped(task.triggers);
129
+ function formatScheduleGrouped(scheduleType: "crons" | "specific_times" | undefined, values: string[] | undefined): string {
130
+ if (!scheduleType || !values || values.length === 0) return "";
131
+ if (values.length === 1) return formatSingleValue(scheduleType, values[0]);
132
+
133
+ const classified = values.map((v) => classifyValue(scheduleType, v));
134
+ const kinds = new Set(classified.map((c) => c.kind));
135
+
136
+ if (kinds.size === 1) {
137
+ const kind = classified[0].kind;
138
+ if (kind === "hourly") return "Every hour";
139
+ const details = classified.map((c) => c.detail);
140
+ return `${kind.charAt(0).toUpperCase() + kind.slice(1)}: ${details.join(", ")}`;
141
+ }
142
+
143
+ return values.map((v) => formatSingleValue(scheduleType, v)).join(", ");
144
+ }
145
+
146
+ const scheduleText = formatScheduleGrouped(task.schedule_type, task.schedule_values);
149
147
 
150
148
  const actionItems = (
151
149
  <>
@@ -174,7 +172,7 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
174
172
 
175
173
  return (
176
174
  <>
177
- <div className="task-card" onClick={() => isRunning ? onViewRun(task.id, "latest") : onViewRun(task.id)}>
175
+ <div className="task-card" onClick={() => onViewRun(task.id, "latest")}>
178
176
  <div className="task-card-header">
179
177
  <div className="task-card-title-row">
180
178
  {isRunning ? (
@@ -216,9 +214,9 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
216
214
  {" "}{formatTime(lastEvent.time_stamp)}
217
215
  </span>
218
216
  )}
219
- {task.triggers.length > 0 && (
217
+ {task.schedule_type && (task.schedule_values?.length ?? 0) > 0 && (
220
218
  <span className="task-card-triggers">
221
- {task.triggers_enabled ? triggersText : "Triggers disabled"}
219
+ {task.schedule_enabled ? scheduleText : "Schedule disabled"}
222
220
  </span>
223
221
  )}
224
222
  </div>