palmier 0.6.0 → 0.6.2
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/.github/workflows/publish.yml +15 -2
- package/CLAUDE.md +2 -2
- package/DISCLAIMER.md +36 -0
- package/README.md +76 -87
- package/dist/agents/agent-instructions.md +1 -1
- package/dist/agents/agent.d.ts +2 -0
- package/dist/agents/agent.js +21 -0
- package/dist/agents/aider.d.ts +9 -0
- package/dist/agents/aider.js +32 -0
- package/dist/agents/cursor.d.ts +9 -0
- package/dist/agents/cursor.js +35 -0
- package/dist/agents/deepagents.d.ts +9 -0
- package/dist/agents/deepagents.js +35 -0
- package/dist/agents/droid.d.ts +9 -0
- package/dist/agents/droid.js +32 -0
- package/dist/agents/goose.d.ts +9 -0
- package/dist/agents/goose.js +32 -0
- package/dist/agents/opencode.d.ts +9 -0
- package/dist/agents/opencode.js +35 -0
- package/dist/agents/openhands.d.ts +9 -0
- package/dist/agents/openhands.js +35 -0
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/pwa/apple-touch-icon.png +0 -0
- package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
- package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/pwa/favicon.ico +0 -0
- package/dist/pwa/index.html +17 -0
- package/dist/pwa/manifest.webmanifest +1 -0
- package/dist/pwa/pwa-192x192.png +0 -0
- package/dist/pwa/pwa-512x512.png +0 -0
- package/dist/pwa/registerSW.js +1 -0
- package/dist/pwa/service-worker.js +2 -0
- package/dist/rpc-handler.d.ts +4 -0
- package/dist/rpc-handler.js +5 -4
- package/dist/transports/http-transport.js +29 -41
- package/package.json +2 -2
- package/palmier-server/.github/workflows/ci.yml +21 -0
- package/palmier-server/.github/workflows/deploy.yml +38 -0
- package/palmier-server/CLAUDE.md +13 -0
- package/palmier-server/PRODUCTION.md +355 -0
- package/palmier-server/README.md +187 -0
- package/palmier-server/nats.conf +15 -0
- package/palmier-server/package.json +8 -0
- package/palmier-server/pnpm-lock.yaml +6597 -0
- package/palmier-server/pnpm-workspace.yaml +3 -0
- package/palmier-server/pwa/index.html +16 -0
- package/palmier-server/pwa/logo/logo-prompt.md +28 -0
- package/palmier-server/pwa/logo/logo_20260330.png +0 -0
- package/palmier-server/pwa/package.json +30 -0
- package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
- package/palmier-server/pwa/public/favicon.ico +0 -0
- package/palmier-server/pwa/public/pwa-192x192.png +0 -0
- package/palmier-server/pwa/public/pwa-512x512.png +0 -0
- package/palmier-server/pwa/src/App.css +2387 -0
- package/palmier-server/pwa/src/App.tsx +21 -0
- package/palmier-server/pwa/src/agentLabels.ts +11 -0
- package/palmier-server/pwa/src/api.ts +61 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
- package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
- package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
- package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
- package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
- package/palmier-server/pwa/src/constants.ts +2 -0
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
- package/palmier-server/pwa/src/formatTime.ts +10 -0
- package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
- package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
- package/palmier-server/pwa/src/main.tsx +14 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
- package/palmier-server/pwa/src/service-worker.ts +139 -0
- package/palmier-server/pwa/src/types.ts +79 -0
- package/palmier-server/pwa/src/vite-env.d.ts +11 -0
- package/palmier-server/pwa/tsconfig.json +21 -0
- package/palmier-server/pwa/tsconfig.node.json +19 -0
- package/palmier-server/pwa/vite.config.ts +47 -0
- package/palmier-server/server/.env.example +16 -0
- package/palmier-server/server/package.json +33 -0
- package/palmier-server/server/src/db.ts +34 -0
- package/palmier-server/server/src/index.ts +219 -0
- package/palmier-server/server/src/nats.ts +25 -0
- package/palmier-server/server/src/push.ts +68 -0
- package/palmier-server/server/src/routes/hosts.ts +45 -0
- package/palmier-server/server/src/routes/push.ts +100 -0
- package/palmier-server/server/tsconfig.json +20 -0
- package/palmier-server/spec.md +415 -0
- package/src/agents/agent-instructions.md +1 -1
- package/src/agents/agent.ts +23 -0
- package/src/agents/aider.ts +37 -0
- package/src/agents/cursor.ts +38 -0
- package/src/agents/deepagents.ts +38 -0
- package/src/agents/droid.ts +37 -0
- package/src/agents/goose.ts +35 -0
- package/src/agents/opencode.ts +38 -0
- package/src/agents/openhands.ts +38 -0
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +2 -2
- package/src/rpc-handler.ts +5 -4
- package/src/transports/http-transport.ts +31 -43
- package/test/result-state.test.ts +110 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import Markdown from "react-markdown";
|
|
4
|
+
import remarkGfm from "remark-gfm";
|
|
5
|
+
import remarkBreaks from "remark-breaks";
|
|
6
|
+
import { getAgentLabel } from "../agentLabels";
|
|
7
|
+
import { formatTime } from "../formatTime";
|
|
8
|
+
import type { ConversationMessage } from "../types";
|
|
9
|
+
|
|
10
|
+
interface RunDetailViewProps {
|
|
11
|
+
connected: boolean;
|
|
12
|
+
hostId: string | null;
|
|
13
|
+
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
14
|
+
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
15
|
+
taskId: string;
|
|
16
|
+
runId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function RunDetailView({ connected, hostId, request, subscribeEvents, taskId, runId }: RunDetailViewProps) {
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
|
23
|
+
const [runState, setRunState] = useState<string | undefined>();
|
|
24
|
+
const [agent, setAgent] = useState<string | undefined>();
|
|
25
|
+
const isTaskRunning = runState === "started" || runState === "monitoring";
|
|
26
|
+
const isFollowupRunning = runState === "followup";
|
|
27
|
+
const isAgentGenerating = runState === "started" || runState === "followup";
|
|
28
|
+
const [reportDialog, setReportDialog] = useState<{ file: string; content?: string; data_url?: string } | null>(null);
|
|
29
|
+
const [aborting, setAborting] = useState(false);
|
|
30
|
+
const [followupText, setFollowupText] = useState("");
|
|
31
|
+
const [sendingFollowup, setSendingFollowup] = useState(false);
|
|
32
|
+
const threadRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
async function fetchData() {
|
|
35
|
+
try {
|
|
36
|
+
const result = await request<{
|
|
37
|
+
messages?: ConversationMessage[];
|
|
38
|
+
start_time?: number;
|
|
39
|
+
end_time?: number;
|
|
40
|
+
running_state?: string;
|
|
41
|
+
agent?: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
}>("task.result", { id: taskId, run_id: runId });
|
|
44
|
+
|
|
45
|
+
if (result.error) {
|
|
46
|
+
console.error("No result:", result.error);
|
|
47
|
+
navigate("/runs", { replace: true });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setMessages(result.messages ?? []);
|
|
51
|
+
setRunState(result.running_state);
|
|
52
|
+
setAgent(result.agent);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error("Failed to load result:", err);
|
|
55
|
+
navigate("/runs", { replace: true });
|
|
56
|
+
} finally {
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function openReport(file: string) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await request<{ reports: Array<{ file: string; content?: string; data_url?: string }> }>(
|
|
64
|
+
"task.reports", { id: taskId, run_id: runId, report_files: [file] },
|
|
65
|
+
);
|
|
66
|
+
const report = result.reports?.[0];
|
|
67
|
+
if (report?.data_url) {
|
|
68
|
+
setReportDialog({ file, data_url: report.data_url });
|
|
69
|
+
} else {
|
|
70
|
+
setReportDialog({ file, content: report?.content ?? "Report not found." });
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
setReportDialog({ file, content: "Failed to load report." });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Initial load
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (connected) {
|
|
80
|
+
setLoading(true);
|
|
81
|
+
fetchData();
|
|
82
|
+
}
|
|
83
|
+
}, [connected, taskId, runId]);
|
|
84
|
+
|
|
85
|
+
// Live-update when the viewed task's state changes
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!connected || !hostId) return;
|
|
88
|
+
const unsubscribe = subscribeEvents(hostId, async (msg) => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(new TextDecoder().decode(msg.data)) as { event_type?: string; run_id?: string };
|
|
91
|
+
if (parsed.event_type !== "running-state" && parsed.event_type !== "result-updated") return;
|
|
92
|
+
const eventTaskId = msg.subject.split(".").pop();
|
|
93
|
+
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;
|
|
96
|
+
fetchData();
|
|
97
|
+
} catch { /* skip */ }
|
|
98
|
+
});
|
|
99
|
+
return unsubscribe;
|
|
100
|
+
}, [connected, hostId, taskId, subscribeEvents, request]);
|
|
101
|
+
|
|
102
|
+
// Auto-scroll to bottom when messages change
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (threadRef.current) {
|
|
105
|
+
threadRef.current.scrollTop = threadRef.current.scrollHeight;
|
|
106
|
+
}
|
|
107
|
+
}, [messages]);
|
|
108
|
+
|
|
109
|
+
function typeLabel(type?: string): string | undefined {
|
|
110
|
+
if (type === "input") return "User Input";
|
|
111
|
+
if (type === "permission") return "Permission";
|
|
112
|
+
if (type === "confirmation") return "Confirmation";
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statusLabel(type?: string): string {
|
|
117
|
+
if (type === "started") return "Task started";
|
|
118
|
+
if (type === "finished") return "Task finished";
|
|
119
|
+
if (type === "failed") return "Task failed";
|
|
120
|
+
if (type === "aborted") return "Task aborted";
|
|
121
|
+
if (type === "confirmation") return "Task confirmed";
|
|
122
|
+
if (type === "stopped") return "Follow-up stopped";
|
|
123
|
+
return type ?? "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="run-detail">
|
|
128
|
+
<button className="run-detail-back" onClick={() => navigate(-1)}>
|
|
129
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
130
|
+
<path d="M15 18l-6-6 6-6" />
|
|
131
|
+
</svg>
|
|
132
|
+
Back
|
|
133
|
+
</button>
|
|
134
|
+
|
|
135
|
+
{loading ? (
|
|
136
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", padding: "var(--space-sm) 0" }}>
|
|
137
|
+
<div className="skeleton-line" style={{ width: "40%" }} />
|
|
138
|
+
<div className="skeleton-line" style={{ width: "55%" }} />
|
|
139
|
+
<div className="skeleton-line" style={{ width: "100%", height: "8rem", marginTop: "var(--space-sm)" }} />
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<div className="chat-thread" ref={threadRef}>
|
|
144
|
+
{messages.map((msg, i) => {
|
|
145
|
+
const isLastAssistant = isAgentGenerating && msg.role === "assistant" && !messages.slice(i + 1).some((m) => m.role === "assistant" || m.role === "user");
|
|
146
|
+
if (msg.role === "status" && msg.type === "monitoring") return null;
|
|
147
|
+
return msg.role === "status" ? (
|
|
148
|
+
<div key={i} className="chat-status">
|
|
149
|
+
{statusLabel(msg.type)}
|
|
150
|
+
{msg.time > 0 && <span className="chat-status-time">{formatTime(msg.time)}</span>}
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<div key={i} className={`chat-message chat-message--${msg.role}`}>
|
|
154
|
+
{msg.role === "assistant" && agent && <div className="chat-message-agent">{getAgentLabel(agent)}</div>}
|
|
155
|
+
<div className="chat-message-content">
|
|
156
|
+
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{msg.content}</Markdown>
|
|
157
|
+
{isLastAssistant && (
|
|
158
|
+
<div className="chat-typing-indicator">
|
|
159
|
+
<span /><span /><span />
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
{msg.attachments && msg.attachments.length > 0 && (
|
|
164
|
+
<div className="chat-message-attachments">
|
|
165
|
+
{msg.attachments.map((file) => (
|
|
166
|
+
<button key={file} className="chat-attachment-chip" onClick={() => openReport(file)}>
|
|
167
|
+
{file}
|
|
168
|
+
</button>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
<div className="chat-message-meta">
|
|
173
|
+
{typeLabel(msg.type) && <span className="chat-message-type">{typeLabel(msg.type)}</span>}
|
|
174
|
+
{msg.time > 0 && <span>{formatTime(msg.time)}</span>}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
{isAgentGenerating && (() => { const nonStatus = messages.filter((m) => m.role !== "status"); return nonStatus.length === 0 || nonStatus[nonStatus.length - 1].role !== "assistant"; })() && (
|
|
180
|
+
<div className="chat-message chat-message--assistant">
|
|
181
|
+
{agent && <div className="chat-message-agent">{getAgentLabel(agent)}</div>}
|
|
182
|
+
<div className="chat-typing-indicator">
|
|
183
|
+
<span /><span /><span />
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
{runState === "monitoring" && (
|
|
188
|
+
<div className="chat-monitoring-indicator">
|
|
189
|
+
<span className="chat-monitoring-dot" />
|
|
190
|
+
Monitoring command output
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
{isTaskRunning ? (
|
|
195
|
+
<div className="chat-abort-bar">
|
|
196
|
+
<button
|
|
197
|
+
className="btn btn-secondary chat-abort-btn"
|
|
198
|
+
disabled={aborting}
|
|
199
|
+
onClick={async () => {
|
|
200
|
+
if (!confirm("Abort this task?")) return;
|
|
201
|
+
setAborting(true);
|
|
202
|
+
try {
|
|
203
|
+
await request("task.abort", { id: taskId });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error("Abort failed:", err);
|
|
206
|
+
} finally {
|
|
207
|
+
setAborting(false);
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{aborting ? "Aborting..." : "Abort Task"}
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
) : isFollowupRunning ? (
|
|
215
|
+
<div className="chat-input-bar">
|
|
216
|
+
<button
|
|
217
|
+
className="btn btn-secondary chat-stop-btn"
|
|
218
|
+
disabled={aborting}
|
|
219
|
+
onClick={async () => {
|
|
220
|
+
setAborting(true);
|
|
221
|
+
try {
|
|
222
|
+
await request("task.stop_followup", { id: taskId, run_id: runId });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error("Stop failed:", err);
|
|
225
|
+
} finally {
|
|
226
|
+
setAborting(false);
|
|
227
|
+
}
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
) : (
|
|
234
|
+
<form
|
|
235
|
+
className="chat-input-bar"
|
|
236
|
+
onSubmit={async (e) => {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
const msg = followupText.trim();
|
|
239
|
+
if (!msg || sendingFollowup) return;
|
|
240
|
+
setSendingFollowup(true);
|
|
241
|
+
try {
|
|
242
|
+
await request("task.followup", { id: taskId, run_id: runId, message: msg });
|
|
243
|
+
setFollowupText("");
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error("Follow-up failed:", err);
|
|
246
|
+
} finally {
|
|
247
|
+
setSendingFollowup(false);
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<input
|
|
252
|
+
className="chat-input"
|
|
253
|
+
type="text"
|
|
254
|
+
placeholder="Follow-up message"
|
|
255
|
+
value={followupText}
|
|
256
|
+
onChange={(e) => setFollowupText(e.target.value)}
|
|
257
|
+
disabled={sendingFollowup}
|
|
258
|
+
/>
|
|
259
|
+
<button
|
|
260
|
+
className="btn btn-primary chat-send-btn"
|
|
261
|
+
type="submit"
|
|
262
|
+
disabled={!followupText.trim() || sendingFollowup}
|
|
263
|
+
>
|
|
264
|
+
<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>
|
|
265
|
+
</button>
|
|
266
|
+
</form>
|
|
267
|
+
)}
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
270
|
+
{reportDialog && (
|
|
271
|
+
<div className="report-dialog-overlay" onClick={() => setReportDialog(null)}>
|
|
272
|
+
<div className="report-dialog" onClick={(e) => e.stopPropagation()}>
|
|
273
|
+
<div className="report-dialog-header">
|
|
274
|
+
<span className="report-dialog-title">{reportDialog.file}</span>
|
|
275
|
+
<button className="report-dialog-close" onClick={() => setReportDialog(null)} aria-label="Close">
|
|
276
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
277
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
278
|
+
</svg>
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="report-dialog-body">
|
|
282
|
+
{reportDialog.data_url ? (
|
|
283
|
+
<img src={reportDialog.data_url} alt={reportDialog.file} style={{ maxWidth: "100%", height: "auto" }} />
|
|
284
|
+
) : (
|
|
285
|
+
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={{ a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}>{reportDialog.content ?? ""}</Markdown>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { formatTime } from "../formatTime";
|
|
4
|
+
import type { HistoryEntry } from "../types";
|
|
5
|
+
|
|
6
|
+
interface RunsViewProps {
|
|
7
|
+
connected: boolean;
|
|
8
|
+
hostId: string | null;
|
|
9
|
+
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
10
|
+
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
11
|
+
filterTaskId?: string | null;
|
|
12
|
+
onClearFilter?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PAGE_SIZE = 10;
|
|
16
|
+
|
|
17
|
+
export default function RunsView({ connected, hostId, request, subscribeEvents, filterTaskId, onClearFilter }: RunsViewProps) {
|
|
18
|
+
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
|
19
|
+
const [total, setTotal] = useState(0);
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
|
|
24
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
// Build RPC params with optional task_id filter
|
|
27
|
+
const rpcParams = useCallback((offset: number) => {
|
|
28
|
+
const params: Record<string, unknown> = { offset, limit: PAGE_SIZE };
|
|
29
|
+
if (filterTaskId) params.task_id = filterTaskId;
|
|
30
|
+
return params;
|
|
31
|
+
}, [filterTaskId]);
|
|
32
|
+
|
|
33
|
+
// Fetch a page of history
|
|
34
|
+
const loadHistory = useCallback(async (offset: number, append: boolean) => {
|
|
35
|
+
if (!connected) return;
|
|
36
|
+
if (append) setLoadingMore(true);
|
|
37
|
+
else setLoading(true);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await request<{ entries?: HistoryEntry[]; total?: number }>(
|
|
41
|
+
"taskrun.list", rpcParams(offset),
|
|
42
|
+
);
|
|
43
|
+
const newEntries = result.entries ?? [];
|
|
44
|
+
setTotal(result.total ?? 0);
|
|
45
|
+
if (append) {
|
|
46
|
+
setEntries((prev) => {
|
|
47
|
+
const existing = new Set(prev.map((e) => `${e.task_id}:${e.run_id}`));
|
|
48
|
+
const deduped = newEntries.filter((e) => !existing.has(`${e.task_id}:${e.run_id}`));
|
|
49
|
+
return [...prev, ...deduped];
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
setEntries(newEntries);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (!(err instanceof Error && err.message === "Not connected")) {
|
|
56
|
+
console.error("Failed to load runs:", err);
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
setLoadingMore(false);
|
|
61
|
+
}
|
|
62
|
+
}, [connected, request, rpcParams]);
|
|
63
|
+
|
|
64
|
+
// Initial load + reload when filter changes
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (connected) {
|
|
67
|
+
setEntries([]);
|
|
68
|
+
setTotal(0);
|
|
69
|
+
loadHistory(0, false);
|
|
70
|
+
} else {
|
|
71
|
+
setEntries([]);
|
|
72
|
+
setTotal(0);
|
|
73
|
+
}
|
|
74
|
+
}, [connected, hostId, loadHistory, filterTaskId]);
|
|
75
|
+
|
|
76
|
+
// Real-time: update entries on running-state events
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!connected || !hostId) return;
|
|
79
|
+
const unsubscribe = subscribeEvents(hostId, async (msg) => {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(new TextDecoder().decode(msg.data)) as { event_type?: string; running_state?: string };
|
|
82
|
+
if (
|
|
83
|
+
parsed.event_type === "running-state" &&
|
|
84
|
+
parsed.running_state && ["monitoring", "started", "finished", "failed", "aborted"].includes(parsed.running_state)
|
|
85
|
+
) {
|
|
86
|
+
const result = await request<{ entries?: HistoryEntry[]; total?: number }>(
|
|
87
|
+
"taskrun.list", rpcParams(0),
|
|
88
|
+
);
|
|
89
|
+
const freshEntries = result.entries ?? [];
|
|
90
|
+
setTotal(result.total ?? 0);
|
|
91
|
+
setEntries((prev) => {
|
|
92
|
+
const freshMap = new Map(freshEntries.map((e) => [`${e.task_id}:${e.run_id}`, e]));
|
|
93
|
+
const updated = prev.map((e) => {
|
|
94
|
+
const key = `${e.task_id}:${e.run_id}`;
|
|
95
|
+
return freshMap.get(key) ?? e;
|
|
96
|
+
});
|
|
97
|
+
const existingKeys = new Set(updated.map((e) => `${e.task_id}:${e.run_id}`));
|
|
98
|
+
const newOnes = freshEntries.filter((e) => !existingKeys.has(`${e.task_id}:${e.run_id}`));
|
|
99
|
+
return [...newOnes, ...updated];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} catch { /* skip */ }
|
|
103
|
+
});
|
|
104
|
+
return unsubscribe;
|
|
105
|
+
}, [connected, hostId, subscribeEvents, request]);
|
|
106
|
+
|
|
107
|
+
// Infinite scroll via IntersectionObserver
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const sentinel = sentinelRef.current;
|
|
110
|
+
if (!sentinel) return;
|
|
111
|
+
|
|
112
|
+
const observer = new IntersectionObserver(
|
|
113
|
+
(observerEntries) => {
|
|
114
|
+
if (observerEntries[0].isIntersecting && !loadingMore && entries.length < total) {
|
|
115
|
+
loadHistory(entries.length, true);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{ threshold: 0.1 },
|
|
119
|
+
);
|
|
120
|
+
observer.observe(sentinel);
|
|
121
|
+
return () => observer.disconnect();
|
|
122
|
+
}, [entries.length, total, loadingMore, loadHistory]);
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
function formatDuration(start?: number, end?: number): string {
|
|
126
|
+
if (!start || !end) return "";
|
|
127
|
+
const seconds = Math.round((end - start) / 1000);
|
|
128
|
+
if (seconds < 60) return `${seconds}s`;
|
|
129
|
+
const minutes = Math.floor(seconds / 60);
|
|
130
|
+
const remainingSeconds = seconds % 60;
|
|
131
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stateLabel: Record<string, string> = {
|
|
135
|
+
monitoring: "Monitoring",
|
|
136
|
+
started: "Running",
|
|
137
|
+
finished: "Finished",
|
|
138
|
+
failed: "Failed",
|
|
139
|
+
aborted: "Aborted",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function stateColor(state?: string): string | undefined {
|
|
143
|
+
if (state === "failed") return "var(--color-error)";
|
|
144
|
+
if (state === "aborted") return "var(--color-warning, #d97706)";
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Loading skeleton
|
|
149
|
+
if (loading && entries.length === 0 && connected) {
|
|
150
|
+
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}%` }} />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="task-card-meta">
|
|
160
|
+
<div className="skeleton-line" style={{ width: "45%" }} />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Empty / disconnected states
|
|
169
|
+
if (!connected || (loading && entries.length === 0)) {
|
|
170
|
+
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>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (entries.length === 0) {
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
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">×</button>
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
<div className="runs-view">
|
|
192
|
+
<div className="empty-state">
|
|
193
|
+
<p className="empty-state-text">No runs yet</p>
|
|
194
|
+
<p className="empty-state-hint">
|
|
195
|
+
{filterTaskId
|
|
196
|
+
? "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."}
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<>
|
|
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">×</button>
|
|
212
|
+
</span>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
<div className="task-list">
|
|
216
|
+
{entries.map((entry, i) => (
|
|
217
|
+
<div
|
|
218
|
+
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)}`)}
|
|
221
|
+
>
|
|
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">
|
|
225
|
+
<span style={{ color: stateColor(entry.running_state) }}>
|
|
226
|
+
{stateLabel[entry.running_state ?? ""] ?? entry.running_state}
|
|
227
|
+
</span>
|
|
228
|
+
{entry.end_time && <span>{formatTime(entry.end_time)}</span>}
|
|
229
|
+
{entry.start_time && entry.end_time && (
|
|
230
|
+
<span style={{ color: "var(--color-muted)" }}>
|
|
231
|
+
{formatDuration(entry.start_time, entry.end_time)}
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
{entry.error && (
|
|
235
|
+
<span style={{ color: "var(--color-muted)" }}>{entry.error}</span>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
<span className="runs-card-chevron">›</span>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
|
|
243
|
+
{/* Sentinel for infinite scroll */}
|
|
244
|
+
<div ref={sentinelRef} style={{ height: 1 }} />
|
|
245
|
+
|
|
246
|
+
{loadingMore && (
|
|
247
|
+
<div className="loading-state" style={{ padding: "var(--space-md)" }}>
|
|
248
|
+
<div className="spinner" />
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useNavigate, useLocation } from "react-router-dom";
|
|
2
|
+
|
|
3
|
+
export default function TabBar() {
|
|
4
|
+
const navigate = useNavigate();
|
|
5
|
+
const location = useLocation();
|
|
6
|
+
const isRuns = location.pathname.startsWith("/runs");
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<button
|
|
11
|
+
className={`tab-btn ${!isRuns ? "tab-btn-active" : ""}`}
|
|
12
|
+
onClick={() => navigate("/")}
|
|
13
|
+
>
|
|
14
|
+
<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" />
|
|
17
|
+
</svg>
|
|
18
|
+
Tasks
|
|
19
|
+
</button>
|
|
20
|
+
<button
|
|
21
|
+
className={`tab-btn ${isRuns ? "tab-btn-active" : ""}`}
|
|
22
|
+
onClick={() => navigate("/runs")}
|
|
23
|
+
>
|
|
24
|
+
<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" />
|
|
26
|
+
</svg>
|
|
27
|
+
Runs
|
|
28
|
+
</button>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}
|