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.
- package/README.md +1 -1
- package/dist/mcp-tools.d.ts +2 -0
- package/dist/mcp-tools.js +4 -2
- package/dist/platform/linux.js +11 -8
- package/dist/platform/windows.d.ts +5 -6
- package/dist/platform/windows.js +15 -12
- package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
- package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
- package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
- package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +23 -8
- package/dist/task.js +1 -1
- package/dist/transports/http-transport.js +3 -5
- package/dist/types.d.ts +9 -6
- package/package.json +1 -1
- package/palmier-server/README.md +3 -3
- package/palmier-server/pwa/src/App.css +117 -36
- package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
- package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
- package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
- package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
- package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
- package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
- package/palmier-server/pwa/src/types.ts +5 -9
- package/palmier-server/spec.md +23 -23
- package/src/mcp-tools.ts +6 -2
- package/src/platform/linux.ts +10 -8
- package/src/platform/windows.ts +15 -12
- package/src/rpc-handler.ts +26 -10
- package/src/task.ts +1 -1
- package/src/transports/http-transport.ts +3 -5
- package/src/types.ts +9 -7
- package/dist/pwa/assets/index-8cTctVnD.js +0 -120
- 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:
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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:
|
|
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:
|
|
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
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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">×</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="
|
|
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
|
-
{
|
|
209
|
-
|
|
210
|
-
<span className="runs-filter-chip">
|
|
211
|
-
Filtered by task
|
|
212
|
-
<button onClick={onClearFilter} aria-label="Clear filter">×</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
|
-
{
|
|
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">×</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="
|
|
253
|
+
className="sessions-card"
|
|
246
254
|
onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
|
|
247
255
|
>
|
|
248
|
-
<div className="
|
|
249
|
-
<h3 className="
|
|
250
|
-
<div className="
|
|
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="
|
|
273
|
+
<span className="sessions-card-chevron">›</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
|
-
!
|
|
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
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
129
|
-
if (parts.length !== 5) return { kind: "unknown", detail:
|
|
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
|
|
142
|
-
const c =
|
|
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 === "
|
|
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
|
-
|
|
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={() =>
|
|
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.
|
|
217
|
+
{task.schedule_type && (task.schedule_values?.length ?? 0) > 0 && (
|
|
220
218
|
<span className="task-card-triggers">
|
|
221
|
-
{task.
|
|
219
|
+
{task.schedule_enabled ? scheduleText : "Schedule disabled"}
|
|
222
220
|
</span>
|
|
223
221
|
)}
|
|
224
222
|
</div>
|