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.
- package/README.md +1 -1
- package/dist/agents/agent.d.ts +3 -0
- package/dist/agents/agent.js +1 -1
- package/dist/agents/aider.d.ts +1 -0
- package/dist/agents/aider.js +1 -0
- package/dist/agents/claude.d.ts +1 -0
- package/dist/agents/claude.js +1 -0
- package/dist/agents/cline.d.ts +1 -0
- package/dist/agents/cline.js +1 -0
- package/dist/agents/codex.d.ts +1 -0
- package/dist/agents/codex.js +1 -0
- package/dist/agents/copilot.d.ts +1 -0
- package/dist/agents/copilot.js +1 -0
- package/dist/agents/cursor.d.ts +1 -0
- package/dist/agents/cursor.js +1 -0
- package/dist/agents/deepagents.d.ts +1 -0
- package/dist/agents/deepagents.js +1 -0
- package/dist/agents/droid.d.ts +1 -0
- package/dist/agents/droid.js +1 -0
- package/dist/agents/gemini.d.ts +1 -0
- package/dist/agents/gemini.js +1 -0
- package/dist/agents/goose.d.ts +1 -0
- package/dist/agents/goose.js +1 -0
- package/dist/agents/hermes.d.ts +1 -0
- package/dist/agents/hermes.js +1 -0
- package/dist/agents/kimi.d.ts +1 -0
- package/dist/agents/kimi.js +1 -0
- package/dist/agents/kiro.d.ts +1 -0
- package/dist/agents/kiro.js +1 -0
- package/dist/agents/openclaw.d.ts +1 -0
- package/dist/agents/openclaw.js +2 -2
- package/dist/agents/opencode.d.ts +1 -0
- package/dist/agents/opencode.js +1 -0
- package/dist/agents/qoder.d.ts +1 -0
- package/dist/agents/qoder.js +1 -0
- package/dist/agents/qwen.d.ts +1 -0
- package/dist/agents/qwen.js +1 -0
- package/dist/commands/pair.js +2 -2
- package/dist/mcp-tools.d.ts +2 -0
- package/dist/mcp-tools.js +20 -9
- package/dist/pending-requests.d.ts +30 -8
- package/dist/pending-requests.js +28 -15
- 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-CkWrlNwc.js → web-BpM3fNCn.js} +1 -1
- package/dist/pwa/assets/{web-lx34oBi7.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 +35 -24
- package/dist/task.js +1 -1
- package/dist/transports/http-transport.js +9 -8
- package/dist/types.d.ts +11 -6
- package/package.json +1 -1
- package/palmier-server/README.md +3 -3
- package/palmier-server/pwa/src/App.css +175 -28
- package/palmier-server/pwa/src/App.tsx +1 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
- 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 +147 -0
- package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +79 -45
- package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
- package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
- package/palmier-server/pwa/src/components/TaskForm.tsx +275 -349
- package/palmier-server/pwa/src/components/TasksView.tsx +172 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +343 -37
- package/palmier-server/pwa/src/types.ts +5 -14
- package/palmier-server/spec.md +39 -26
- package/src/agents/agent.ts +5 -1
- package/src/agents/aider.ts +1 -0
- package/src/agents/claude.ts +1 -0
- package/src/agents/cline.ts +1 -0
- package/src/agents/codex.ts +1 -0
- package/src/agents/copilot.ts +1 -0
- package/src/agents/cursor.ts +1 -0
- package/src/agents/deepagents.ts +1 -0
- package/src/agents/droid.ts +1 -0
- package/src/agents/gemini.ts +1 -0
- package/src/agents/goose.ts +1 -0
- package/src/agents/hermes.ts +1 -0
- package/src/agents/kimi.ts +1 -0
- package/src/agents/kiro.ts +1 -0
- package/src/agents/openclaw.ts +2 -2
- package/src/agents/opencode.ts +1 -0
- package/src/agents/qoder.ts +1 -0
- package/src/agents/qwen.ts +1 -0
- package/src/commands/pair.ts +2 -2
- package/src/mcp-tools.ts +22 -9
- package/src/pending-requests.ts +47 -15
- package/src/platform/linux.ts +10 -8
- package/src/platform/windows.ts +15 -12
- package/src/rpc-handler.ts +39 -26
- package/src/task.ts +1 -1
- package/src/transports/http-transport.ts +9 -8
- package/src/types.ts +10 -8
- package/test/pairing.test.ts +2 -2
- package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
- package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
- 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
|
|
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
|
|
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
|
|
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">×</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
|
-
|
|
152
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
220
|
+
</>
|
|
177
221
|
);
|
|
178
222
|
}
|
|
179
223
|
|
|
180
224
|
if (entries.length === 0) {
|
|
181
225
|
return (
|
|
182
226
|
<>
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
<button onClick={onClearFilter} aria-label="Clear filter">×</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
|
|
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
|
-
: "
|
|
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
|
-
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
Filtered by task
|
|
211
|
-
<button onClick={onClearFilter} aria-label="Clear filter">×</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="
|
|
220
|
-
onClick={() => !entry.error &&
|
|
253
|
+
className="sessions-card"
|
|
254
|
+
onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
|
|
221
255
|
>
|
|
222
|
-
<div className="
|
|
223
|
-
<h3 className="
|
|
224
|
-
<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">
|
|
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="
|
|
273
|
+
<span className="sessions-card-chevron">›</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
|
|
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 ${
|
|
12
|
-
onClick={() =>
|
|
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
|
-
<
|
|
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
|
-
|
|
24
|
+
Sessions
|
|
19
25
|
</button>
|
|
20
26
|
<button
|
|
21
|
-
className={`tab-btn ${
|
|
22
|
-
onClick={() =>
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
!
|
|
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>
|