palmier 0.7.7 → 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 (106) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent.d.ts +3 -0
  3. package/dist/agents/agent.js +1 -1
  4. package/dist/agents/aider.d.ts +1 -0
  5. package/dist/agents/aider.js +1 -0
  6. package/dist/agents/claude.d.ts +1 -0
  7. package/dist/agents/claude.js +1 -0
  8. package/dist/agents/cline.d.ts +1 -0
  9. package/dist/agents/cline.js +1 -0
  10. package/dist/agents/codex.d.ts +1 -0
  11. package/dist/agents/codex.js +1 -0
  12. package/dist/agents/copilot.d.ts +1 -0
  13. package/dist/agents/copilot.js +1 -0
  14. package/dist/agents/cursor.d.ts +1 -0
  15. package/dist/agents/cursor.js +1 -0
  16. package/dist/agents/deepagents.d.ts +1 -0
  17. package/dist/agents/deepagents.js +1 -0
  18. package/dist/agents/droid.d.ts +1 -0
  19. package/dist/agents/droid.js +1 -0
  20. package/dist/agents/gemini.d.ts +1 -0
  21. package/dist/agents/gemini.js +1 -0
  22. package/dist/agents/goose.d.ts +1 -0
  23. package/dist/agents/goose.js +1 -0
  24. package/dist/agents/hermes.d.ts +1 -0
  25. package/dist/agents/hermes.js +1 -0
  26. package/dist/agents/kimi.d.ts +1 -0
  27. package/dist/agents/kimi.js +1 -0
  28. package/dist/agents/kiro.d.ts +1 -0
  29. package/dist/agents/kiro.js +1 -0
  30. package/dist/agents/openclaw.d.ts +1 -0
  31. package/dist/agents/openclaw.js +2 -2
  32. package/dist/agents/opencode.d.ts +1 -0
  33. package/dist/agents/opencode.js +1 -0
  34. package/dist/agents/qoder.d.ts +1 -0
  35. package/dist/agents/qoder.js +1 -0
  36. package/dist/agents/qwen.d.ts +1 -0
  37. package/dist/agents/qwen.js +1 -0
  38. package/dist/commands/pair.js +2 -2
  39. package/dist/mcp-tools.d.ts +2 -0
  40. package/dist/mcp-tools.js +20 -9
  41. package/dist/pending-requests.d.ts +30 -8
  42. package/dist/pending-requests.js +28 -15
  43. package/dist/platform/linux.js +11 -8
  44. package/dist/platform/windows.d.ts +5 -6
  45. package/dist/platform/windows.js +15 -12
  46. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  47. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  48. package/dist/pwa/assets/{web-CkWrlNwc.js → web-BpM3fNCn.js} +1 -1
  49. package/dist/pwa/assets/{web-lx34oBi7.js → web-CF-N8Di6.js} +1 -1
  50. package/dist/pwa/index.html +2 -2
  51. package/dist/pwa/service-worker.js +1 -1
  52. package/dist/rpc-handler.js +35 -24
  53. package/dist/task.js +1 -1
  54. package/dist/transports/http-transport.js +9 -8
  55. package/dist/types.d.ts +11 -6
  56. package/package.json +1 -1
  57. package/palmier-server/README.md +3 -3
  58. package/palmier-server/pwa/src/App.css +175 -28
  59. package/palmier-server/pwa/src/App.tsx +1 -0
  60. package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
  61. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  62. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  63. package/palmier-server/pwa/src/components/SessionComposer.tsx +147 -0
  64. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +79 -45
  65. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  66. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  67. package/palmier-server/pwa/src/components/TaskForm.tsx +275 -349
  68. package/palmier-server/pwa/src/components/TasksView.tsx +172 -0
  69. package/palmier-server/pwa/src/constants.ts +1 -1
  70. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  71. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  72. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  73. package/palmier-server/pwa/src/pages/Dashboard.tsx +343 -37
  74. package/palmier-server/pwa/src/types.ts +5 -14
  75. package/palmier-server/spec.md +39 -26
  76. package/src/agents/agent.ts +5 -1
  77. package/src/agents/aider.ts +1 -0
  78. package/src/agents/claude.ts +1 -0
  79. package/src/agents/cline.ts +1 -0
  80. package/src/agents/codex.ts +1 -0
  81. package/src/agents/copilot.ts +1 -0
  82. package/src/agents/cursor.ts +1 -0
  83. package/src/agents/deepagents.ts +1 -0
  84. package/src/agents/droid.ts +1 -0
  85. package/src/agents/gemini.ts +1 -0
  86. package/src/agents/goose.ts +1 -0
  87. package/src/agents/hermes.ts +1 -0
  88. package/src/agents/kimi.ts +1 -0
  89. package/src/agents/kiro.ts +1 -0
  90. package/src/agents/openclaw.ts +2 -2
  91. package/src/agents/opencode.ts +1 -0
  92. package/src/agents/qoder.ts +1 -0
  93. package/src/agents/qwen.ts +1 -0
  94. package/src/commands/pair.ts +2 -2
  95. package/src/mcp-tools.ts +22 -9
  96. package/src/pending-requests.ts +47 -15
  97. package/src/platform/linux.ts +10 -8
  98. package/src/platform/windows.ts +15 -12
  99. package/src/rpc-handler.ts +39 -26
  100. package/src/task.ts +1 -1
  101. package/src/transports/http-transport.ts +9 -8
  102. package/src/types.ts +10 -8
  103. package/test/pairing.test.ts +2 -2
  104. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  105. package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
  106. package/palmier-server/pwa/src/components/TaskListView.tsx +0 -431
@@ -0,0 +1,147 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useHostConnection } from "../contexts/HostConnectionContext";
3
+ import { setDraftMessage } from "../draftGuard";
4
+ import type { AgentInfo } from "../types";
5
+
6
+ interface SessionComposerProps {
7
+ agents: AgentInfo[];
8
+ onStarted(taskId: string, runId?: string): void;
9
+ }
10
+
11
+ function pickDefaultAgent(agents: AgentInfo[]): string {
12
+ const stored = localStorage.getItem("palmier:lastAgent");
13
+ const keys = agents.map((a) => a.key);
14
+ if (stored && keys.includes(stored)) return stored;
15
+ return agents[0]?.key ?? "";
16
+ }
17
+
18
+ export default function SessionComposer({ agents, onStarted }: SessionComposerProps) {
19
+ const { request } = useHostConnection();
20
+ const [prompt, setPrompt] = useState("");
21
+ const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
22
+ const [yoloMode, setYoloMode] = useState(false);
23
+ const [running, setRunning] = useState(false);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ // Keep agent selection valid as the agent list arrives/changes.
27
+ useEffect(() => {
28
+ if (!agents.length) return;
29
+ if (!agents.find((a) => a.key === agent)) {
30
+ setAgent(pickDefaultAgent(agents));
31
+ }
32
+ }, [agents, agent]);
33
+
34
+ // Draft guard: warns on navigation / reload when the input has content.
35
+ useEffect(() => {
36
+ const hasDraft = prompt.trim().length > 0;
37
+ setDraftMessage(hasDraft ? "Your session draft will be lost. Continue?" : null);
38
+ return () => setDraftMessage(null);
39
+ }, [prompt]);
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
+
49
+ const canRun = !!prompt.trim() && !!agent && !running;
50
+
51
+ function confirmYolo(): boolean {
52
+ if (!yoloMode) return true;
53
+ return confirm(
54
+ "Yolo mode is enabled. The agent will auto-approve all tool calls \u2014 it can read, write, delete files, run arbitrary commands, and access the network without asking for permission.\n\nAre you sure you want to continue?"
55
+ );
56
+ }
57
+
58
+ async function handleRun() {
59
+ if (!canRun || !confirmYolo()) return;
60
+ setRunning(true);
61
+ setError(null);
62
+ try {
63
+ const result = await request<{ task_id?: string; run_id?: string; error?: string }>(
64
+ "task.run_oneoff",
65
+ { user_prompt: prompt, agent, yolo_mode: yoloMode },
66
+ );
67
+ if (result.error) {
68
+ setError(result.error);
69
+ return;
70
+ }
71
+ localStorage.setItem("palmier:lastAgent", agent);
72
+ setPrompt("");
73
+ setDraftMessage(null);
74
+ if (result.task_id) onStarted(result.task_id, result.run_id);
75
+ } catch (err) {
76
+ setError(err instanceof Error ? err.message : String(err));
77
+ } finally {
78
+ setRunning(false);
79
+ }
80
+ }
81
+
82
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
83
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
84
+ e.preventDefault();
85
+ handleRun();
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div className="session-composer">
91
+ {error && <div className="form-error">{error}</div>}
92
+ <textarea
93
+ className="session-composer-textarea"
94
+ value={prompt}
95
+ onChange={(e) => setPrompt(e.target.value)}
96
+ onKeyDown={handleKeyDown}
97
+ placeholder="What can I do for you?"
98
+ rows={3}
99
+ disabled={running}
100
+ />
101
+ <div className="session-composer-controls">
102
+ <div className="agent-picker-section-inline">
103
+ <span className="agent-picker-label">Run with</span>
104
+ <select
105
+ className="form-select form-select-sm"
106
+ value={agent}
107
+ onChange={(e) => setAgent(e.target.value)}
108
+ disabled={running || !agents.length}
109
+ >
110
+ {agents.map((a) => (
111
+ <option key={a.key} value={a.key}>{a.label}</option>
112
+ ))}
113
+ </select>
114
+ </div>
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
+ )}
126
+ <button
127
+ className="btn btn-primary chat-send-btn"
128
+ onClick={handleRun}
129
+ disabled={!canRun}
130
+ aria-label="Run session"
131
+ title="Run session"
132
+ >
133
+ {running ? (
134
+ <span className="btn-spinner" />
135
+ ) : (
136
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
137
+ )}
138
+ </button>
139
+ </div>
140
+ {agentSupportsYolo && yoloMode && (
141
+ <p className="command-help-text">
142
+ The agent will auto-approve all tool calls without asking for permission.
143
+ </p>
144
+ )}
145
+ </div>
146
+ );
147
+ }
@@ -1,20 +1,25 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
3
  import { formatTime } from "../formatTime";
4
- import type { HistoryEntry } from "../types";
4
+ import { confirmLeaveDraft } from "../draftGuard";
5
+ import SessionComposer from "./SessionComposer";
6
+ import PullToRefreshIndicator from "./PullToRefreshIndicator";
7
+ import { usePullToRefresh } from "../hooks/usePullToRefresh";
8
+ import type { AgentInfo, HistoryEntry } from "../types";
5
9
 
6
- interface RunsViewProps {
10
+ interface SessionsViewProps {
7
11
  connected: boolean;
8
12
  hostId: string | null;
9
13
  request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
10
14
  subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
15
+ agents: AgentInfo[];
11
16
  filterTaskId?: string | null;
12
17
  onClearFilter?: () => void;
13
18
  }
14
19
 
15
20
  const PAGE_SIZE = 10;
16
21
 
17
- export default function RunsView({ connected, hostId, request, subscribeEvents, filterTaskId, onClearFilter }: RunsViewProps) {
22
+ export default function SessionsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: SessionsViewProps) {
18
23
  const [entries, setEntries] = useState<HistoryEntry[]>([]);
19
24
  const [total, setTotal] = useState(0);
20
25
  const [loading, setLoading] = useState(false);
@@ -73,6 +78,11 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
73
78
  }
74
79
  }, [connected, hostId, loadHistory, filterTaskId]);
75
80
 
81
+ const ptr = usePullToRefresh({
82
+ onRefresh: () => loadHistory(0, false),
83
+ enabled: connected,
84
+ });
85
+
76
86
  // Real-time: update entries on running-state events
77
87
  useEffect(() => {
78
88
  if (!connected || !hostId) return;
@@ -139,6 +149,32 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
139
149
  aborted: "Aborted",
140
150
  };
141
151
 
152
+ function handleCardClick(taskId: string, runId: string) {
153
+ if (!confirmLeaveDraft()) return;
154
+ navigate(`/runs/${taskId}/${encodeURIComponent(runId)}`);
155
+ }
156
+
157
+ const composer = !filterTaskId && (
158
+ <SessionComposer
159
+ agents={agents}
160
+ onStarted={(taskId, runId) => {
161
+ if (runId) navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
162
+ else navigate(`/runs/${encodeURIComponent(taskId)}`);
163
+ }}
164
+ />
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
+ );
177
+
142
178
  function stateColor(state?: string): string | undefined {
143
179
  if (state === "failed") return "var(--color-error)";
144
180
  if (state === "aborted") return "var(--color-warning, #d97706)";
@@ -148,53 +184,56 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
148
184
  // Loading skeleton
149
185
  if (loading && entries.length === 0 && connected) {
150
186
  return (
151
- <div className="task-list">
152
- {[0, 1, 2].map((i) => (
153
- <div key={i} className="task-card" style={{ pointerEvents: "none" }}>
154
- <div className="task-card-header">
155
- <div className="task-card-title-row">
156
- <div className="skeleton-line" style={{ width: `${70 + i * 10}%` }} />
187
+ <>
188
+ {refreshIndicator}
189
+ {composer}
190
+ <div className="task-list">
191
+ {[0, 1, 2].map((i) => (
192
+ <div key={i} className="task-card" style={{ pointerEvents: "none" }}>
193
+ <div className="task-card-header">
194
+ <div className="task-card-title-row">
195
+ <div className="skeleton-line" style={{ width: `${70 + i * 10}%` }} />
196
+ </div>
197
+ </div>
198
+ <div className="task-card-meta">
199
+ <div className="skeleton-line" style={{ width: "45%" }} />
157
200
  </div>
158
201
  </div>
159
- <div className="task-card-meta">
160
- <div className="skeleton-line" style={{ width: "45%" }} />
161
- </div>
162
- </div>
163
- ))}
164
- </div>
202
+ ))}
203
+ </div>
204
+ </>
165
205
  );
166
206
  }
167
207
 
168
208
  // Empty / disconnected states
169
209
  if (!connected || (loading && entries.length === 0)) {
170
210
  return (
171
- <div className="runs-view">
172
- <div className="empty-state">
173
- <p className="empty-state-text">Runs</p>
174
- <p className="empty-state-hint">Run history will appear here</p>
211
+ <>
212
+ {refreshIndicator}
213
+ {composer}
214
+ <div className="sessions-view">
215
+ <div className="empty-state">
216
+ <p className="empty-state-text">Sessions</p>
217
+ <p className="empty-state-hint">Your sessions will appear here</p>
218
+ </div>
175
219
  </div>
176
- </div>
220
+ </>
177
221
  );
178
222
  }
179
223
 
180
224
  if (entries.length === 0) {
181
225
  return (
182
226
  <>
183
- {filterTaskId && onClearFilter && (
184
- <div style={{ marginBottom: "var(--space-sm)" }}>
185
- <span className="runs-filter-chip">
186
- Filtered by task
187
- <button onClick={onClearFilter} aria-label="Clear filter">&times;</button>
188
- </span>
189
- </div>
190
- )}
191
- <div className="runs-view">
227
+ {refreshIndicator}
228
+ {composer}
229
+ {filterChip}
230
+ <div className="sessions-view">
192
231
  <div className="empty-state">
193
- <p className="empty-state-text">No runs yet</p>
232
+ <p className="empty-state-text">No sessions yet</p>
194
233
  <p className="empty-state-hint">
195
234
  {filterTaskId
196
235
  ? "This task hasn't been executed yet. Run it from the task menu or wait for its next trigger."
197
- : "Run history will appear here."}
236
+ : "Your sessions will appear here."}
198
237
  </p>
199
238
  </div>
200
239
  </div>
@@ -204,24 +243,19 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
204
243
 
205
244
  return (
206
245
  <>
207
- {filterTaskId && onClearFilter && (
208
- <div style={{ marginBottom: "var(--space-sm)" }}>
209
- <span className="runs-filter-chip">
210
- Filtered by task
211
- <button onClick={onClearFilter} aria-label="Clear filter">&times;</button>
212
- </span>
213
- </div>
214
- )}
246
+ {refreshIndicator}
247
+ {composer}
248
+ {filterChip}
215
249
  <div className="task-list">
216
250
  {entries.map((entry, i) => (
217
251
  <div
218
252
  key={`${entry.task_id}-${entry.run_id}-${i}`}
219
- className="runs-card"
220
- onClick={() => !entry.error && navigate(`/runs/${entry.task_id}/${encodeURIComponent(entry.run_id)}`)}
253
+ className="sessions-card"
254
+ onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
221
255
  >
222
- <div className="runs-card-body">
223
- <h3 className="runs-card-name">{entry.task_name || entry.task_id}</h3>
224
- <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">
225
259
  <span style={{ color: stateColor(entry.running_state) }}>
226
260
  {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
227
261
  </span>
@@ -236,7 +270,7 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
236
270
  )}
237
271
  </div>
238
272
  </div>
239
- <span className="runs-card-chevron">&#8250;</span>
273
+ <span className="sessions-card-chevron">&#8250;</span>
240
274
  </div>
241
275
  ))}
242
276
 
@@ -1,30 +1,37 @@
1
1
  import { useNavigate, useLocation } from "react-router-dom";
2
+ import { confirmLeaveDraft } from "../draftGuard";
2
3
 
3
4
  export default function TabBar() {
4
5
  const navigate = useNavigate();
5
6
  const location = useLocation();
6
- const isRuns = location.pathname.startsWith("/runs");
7
+ const isTasks = location.pathname.startsWith("/tasks");
8
+ const isSessions = !isTasks;
9
+
10
+ function go(path: string) {
11
+ if (!confirmLeaveDraft()) return;
12
+ navigate(path);
13
+ }
7
14
 
8
15
  return (
9
16
  <>
10
17
  <button
11
- className={`tab-btn ${!isRuns ? "tab-btn-active" : ""}`}
12
- onClick={() => navigate("/")}
18
+ className={`tab-btn ${isSessions ? "tab-btn-active" : ""}`}
19
+ onClick={() => go("/")}
13
20
  >
14
21
  <svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
15
- <rect x="2" y="2" width="12" height="12" rx="2" />
16
- <path d="M5.5 8L7 9.5L10.5 6" />
22
+ <path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
17
23
  </svg>
18
- Tasks
24
+ Sessions
19
25
  </button>
20
26
  <button
21
- className={`tab-btn ${isRuns ? "tab-btn-active" : ""}`}
22
- onClick={() => navigate("/runs")}
27
+ className={`tab-btn ${isTasks ? "tab-btn-active" : ""}`}
28
+ onClick={() => go("/tasks")}
23
29
  >
24
30
  <svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
25
- <path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
31
+ <rect x="2" y="2" width="12" height="12" rx="2" />
32
+ <path d="M5.5 8L7 9.5L10.5 6" />
26
33
  </svg>
27
- Runs
34
+ Tasks
28
35
  </button>
29
36
  </>
30
37
  );
@@ -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>