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.
Files changed (110) hide show
  1. package/.github/workflows/publish.yml +15 -2
  2. package/CLAUDE.md +2 -2
  3. package/DISCLAIMER.md +36 -0
  4. package/README.md +76 -87
  5. package/dist/agents/agent-instructions.md +1 -1
  6. package/dist/agents/agent.d.ts +2 -0
  7. package/dist/agents/agent.js +21 -0
  8. package/dist/agents/aider.d.ts +9 -0
  9. package/dist/agents/aider.js +32 -0
  10. package/dist/agents/cursor.d.ts +9 -0
  11. package/dist/agents/cursor.js +35 -0
  12. package/dist/agents/deepagents.d.ts +9 -0
  13. package/dist/agents/deepagents.js +35 -0
  14. package/dist/agents/droid.d.ts +9 -0
  15. package/dist/agents/droid.js +32 -0
  16. package/dist/agents/goose.d.ts +9 -0
  17. package/dist/agents/goose.js +32 -0
  18. package/dist/agents/opencode.d.ts +9 -0
  19. package/dist/agents/opencode.js +35 -0
  20. package/dist/agents/openhands.d.ts +9 -0
  21. package/dist/agents/openhands.js +35 -0
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +1 -1
  24. package/dist/commands/run.js +2 -2
  25. package/dist/pwa/apple-touch-icon.png +0 -0
  26. package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
  27. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  28. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  29. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  30. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  31. package/dist/pwa/favicon.ico +0 -0
  32. package/dist/pwa/index.html +17 -0
  33. package/dist/pwa/manifest.webmanifest +1 -0
  34. package/dist/pwa/pwa-192x192.png +0 -0
  35. package/dist/pwa/pwa-512x512.png +0 -0
  36. package/dist/pwa/registerSW.js +1 -0
  37. package/dist/pwa/service-worker.js +2 -0
  38. package/dist/rpc-handler.d.ts +4 -0
  39. package/dist/rpc-handler.js +5 -4
  40. package/dist/transports/http-transport.js +29 -41
  41. package/package.json +2 -2
  42. package/palmier-server/.github/workflows/ci.yml +21 -0
  43. package/palmier-server/.github/workflows/deploy.yml +38 -0
  44. package/palmier-server/CLAUDE.md +13 -0
  45. package/palmier-server/PRODUCTION.md +355 -0
  46. package/palmier-server/README.md +187 -0
  47. package/palmier-server/nats.conf +15 -0
  48. package/palmier-server/package.json +8 -0
  49. package/palmier-server/pnpm-lock.yaml +6597 -0
  50. package/palmier-server/pnpm-workspace.yaml +3 -0
  51. package/palmier-server/pwa/index.html +16 -0
  52. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  53. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  54. package/palmier-server/pwa/package.json +30 -0
  55. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  56. package/palmier-server/pwa/public/favicon.ico +0 -0
  57. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  58. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  59. package/palmier-server/pwa/src/App.css +2387 -0
  60. package/palmier-server/pwa/src/App.tsx +21 -0
  61. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  62. package/palmier-server/pwa/src/api.ts +61 -0
  63. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  64. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  65. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  66. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  67. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  68. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  69. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  70. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  71. package/palmier-server/pwa/src/constants.ts +2 -0
  72. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  73. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  74. package/palmier-server/pwa/src/formatTime.ts +10 -0
  75. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  76. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  77. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  78. package/palmier-server/pwa/src/main.tsx +14 -0
  79. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  80. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  81. package/palmier-server/pwa/src/service-worker.ts +139 -0
  82. package/palmier-server/pwa/src/types.ts +79 -0
  83. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  84. package/palmier-server/pwa/tsconfig.json +21 -0
  85. package/palmier-server/pwa/tsconfig.node.json +19 -0
  86. package/palmier-server/pwa/vite.config.ts +47 -0
  87. package/palmier-server/server/.env.example +16 -0
  88. package/palmier-server/server/package.json +33 -0
  89. package/palmier-server/server/src/db.ts +34 -0
  90. package/palmier-server/server/src/index.ts +219 -0
  91. package/palmier-server/server/src/nats.ts +25 -0
  92. package/palmier-server/server/src/push.ts +68 -0
  93. package/palmier-server/server/src/routes/hosts.ts +45 -0
  94. package/palmier-server/server/src/routes/push.ts +100 -0
  95. package/palmier-server/server/tsconfig.json +20 -0
  96. package/palmier-server/spec.md +415 -0
  97. package/src/agents/agent-instructions.md +1 -1
  98. package/src/agents/agent.ts +23 -0
  99. package/src/agents/aider.ts +37 -0
  100. package/src/agents/cursor.ts +38 -0
  101. package/src/agents/deepagents.ts +38 -0
  102. package/src/agents/droid.ts +37 -0
  103. package/src/agents/goose.ts +35 -0
  104. package/src/agents/opencode.ts +38 -0
  105. package/src/agents/openhands.ts +38 -0
  106. package/src/commands/pair.ts +1 -1
  107. package/src/commands/run.ts +2 -2
  108. package/src/rpc-handler.ts +5 -4
  109. package/src/transports/http-transport.ts +31 -43
  110. 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">&times;</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">&times;</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">&#8250;</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
+ }