palmier 0.7.8 → 0.8.0

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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/run.js +55 -0
  3. package/dist/commands/serve.js +22 -2
  4. package/dist/event-queues.d.ts +36 -0
  5. package/dist/event-queues.js +53 -0
  6. package/dist/mcp-tools.d.ts +2 -0
  7. package/dist/mcp-tools.js +4 -2
  8. package/dist/platform/linux.js +11 -8
  9. package/dist/platform/windows.d.ts +5 -6
  10. package/dist/platform/windows.js +19 -13
  11. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  12. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  13. package/dist/pwa/assets/{web-BNr628AV.js → web-BpM3fNCn.js} +1 -1
  14. package/dist/pwa/assets/{web-DyQPewAi.js → web-CF-N8Di6.js} +1 -1
  15. package/dist/pwa/index.html +2 -2
  16. package/dist/pwa/service-worker.js +1 -1
  17. package/dist/rpc-handler.js +25 -9
  18. package/dist/task.js +1 -1
  19. package/dist/transports/http-transport.js +18 -5
  20. package/dist/types.d.ts +10 -6
  21. package/package.json +1 -1
  22. package/palmier-server/README.md +3 -3
  23. package/palmier-server/pwa/src/App.css +117 -36
  24. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  25. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  26. package/palmier-server/pwa/src/components/SessionComposer.tsx +20 -10
  27. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +33 -25
  28. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  29. package/palmier-server/pwa/src/components/TaskForm.tsx +274 -293
  30. package/palmier-server/pwa/src/components/{TaskListView.tsx → TasksView.tsx} +20 -13
  31. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  32. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  33. package/palmier-server/pwa/src/pages/Dashboard.tsx +9 -26
  34. package/palmier-server/pwa/src/types.ts +5 -9
  35. package/palmier-server/spec.md +23 -23
  36. package/src/commands/run.ts +61 -0
  37. package/src/commands/serve.ts +22 -2
  38. package/src/event-queues.ts +56 -0
  39. package/src/mcp-tools.ts +6 -2
  40. package/src/platform/linux.ts +10 -8
  41. package/src/platform/windows.ts +19 -13
  42. package/src/rpc-handler.ts +28 -11
  43. package/src/task.ts +1 -1
  44. package/src/transports/http-transport.ts +17 -5
  45. package/src/types.ts +10 -7
  46. package/dist/pwa/assets/index-8cTctVnD.js +0 -120
  47. package/dist/pwa/assets/index-CSUkBBsQ.css +0 -1
@@ -67,6 +67,34 @@ body {
67
67
  min-height: 100dvh;
68
68
  -webkit-font-smoothing: antialiased;
69
69
  -moz-osx-font-smoothing: grayscale;
70
+ overscroll-behavior-y: contain;
71
+ }
72
+
73
+ .pull-to-refresh {
74
+ position: fixed;
75
+ top: -48px;
76
+ left: 0;
77
+ right: 0;
78
+ height: 48px;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ pointer-events: none;
83
+ z-index: 40;
84
+ transition: opacity 0.15s;
85
+ }
86
+
87
+ .pull-to-refresh-badge {
88
+ width: 40px;
89
+ height: 40px;
90
+ border-radius: 50%;
91
+ background: var(--color-surface);
92
+ border: 1px solid var(--color-border);
93
+ box-shadow: var(--shadow-md);
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ color: var(--color-text-secondary);
70
98
  }
71
99
 
72
100
  #root {
@@ -621,10 +649,6 @@ body {
621
649
  padding-left: var(--space-xs);
622
650
  }
623
651
 
624
- .session-composer-controls .agent-picker-section-inline {
625
- margin-left: 0;
626
- }
627
-
628
652
  .session-composer-yolo {
629
653
  display: inline-flex;
630
654
  align-items: center;
@@ -645,23 +669,39 @@ body {
645
669
  }
646
670
 
647
671
  /* ===== Task List ===== */
648
- .new-task-input-card {
649
- background: var(--color-surface);
650
- border-radius: var(--radius-md);
651
- border: 1px solid var(--color-border);
652
- box-shadow: var(--shadow-sm);
653
- padding: var(--space-md);
654
- margin-bottom: var(--space-sm);
672
+ .fab {
673
+ position: fixed;
674
+ right: var(--space-lg);
675
+ bottom: calc(var(--space-lg) + env(safe-area-inset-bottom, 0px));
676
+ width: 56px;
677
+ height: 56px;
678
+ border-radius: 50%;
679
+ border: none;
680
+ background: var(--color-primary);
681
+ color: #fff;
682
+ display: inline-flex;
683
+ align-items: center;
684
+ justify-content: center;
655
685
  cursor: pointer;
686
+ box-shadow: var(--shadow-lg);
687
+ transition: background var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
688
+ z-index: 50;
689
+ -webkit-tap-highlight-color: transparent;
656
690
  }
657
691
 
658
- .new-task-input-card:hover {
659
- border-color: var(--color-primary);
692
+ .fab:hover {
693
+ background: var(--color-primary-hover);
694
+ box-shadow: var(--shadow-xl);
695
+ transform: translateY(-1px);
660
696
  }
661
697
 
662
- .new-task-placeholder {
663
- font-size: 0.875rem;
664
- color: var(--color-muted);
698
+ .fab:active {
699
+ transform: translateY(0);
700
+ }
701
+
702
+ .fab:focus-visible {
703
+ outline: 2px solid var(--color-primary);
704
+ outline-offset: 3px;
665
705
  }
666
706
 
667
707
  .section-label {
@@ -677,7 +717,6 @@ body {
677
717
  display: flex;
678
718
  align-items: center;
679
719
  gap: var(--space-xs);
680
- margin-left: auto;
681
720
  }
682
721
 
683
722
  .agent-picker-section-inline .agent-picker-label {
@@ -1162,15 +1201,57 @@ body {
1162
1201
  font-weight: 600;
1163
1202
  }
1164
1203
 
1165
- .triggers-section-body {
1204
+ .granted-permissions-row {
1205
+ flex-basis: 100%;
1206
+ }
1207
+
1208
+ .granted-permissions-row .btn-link {
1209
+ padding: 0;
1210
+ }
1211
+
1212
+ .schedule-section {
1166
1213
  display: flex;
1167
1214
  flex-direction: column;
1168
- gap: 2px;
1215
+ gap: var(--space-sm);
1169
1216
  }
1170
1217
 
1171
- .triggers-section-body.disabled {
1172
- opacity: 0.4;
1173
- pointer-events: none;
1218
+ .schedule-section-title {
1219
+ font-size: 0.75rem;
1220
+ font-weight: 600;
1221
+ text-transform: uppercase;
1222
+ letter-spacing: 0.05em;
1223
+ color: var(--color-muted);
1224
+ margin: 0;
1225
+ }
1226
+
1227
+ .schedule-reactive {
1228
+ display: flex;
1229
+ flex-direction: column;
1230
+ gap: var(--space-xs);
1231
+ }
1232
+
1233
+ .yolo-inline {
1234
+ display: inline-flex;
1235
+ align-items: center;
1236
+ gap: 4px;
1237
+ font-size: 0.8rem;
1238
+ color: var(--color-text-secondary);
1239
+ cursor: pointer;
1240
+ user-select: none;
1241
+ white-space: nowrap;
1242
+ }
1243
+
1244
+ .yolo-inline input {
1245
+ margin: 0;
1246
+ cursor: pointer;
1247
+ }
1248
+
1249
+ .yolo-warning {
1250
+ flex-basis: 100%;
1251
+ margin: 0;
1252
+ font-size: 0.75rem;
1253
+ color: var(--color-text-secondary);
1254
+ line-height: 1.4;
1174
1255
  }
1175
1256
 
1176
1257
  .trigger-row-card {
@@ -1218,7 +1299,7 @@ body {
1218
1299
  width: auto;
1219
1300
  }
1220
1301
 
1221
- .triggers-section-body > .form-select,
1302
+ .schedule-section > .form-select,
1222
1303
  .trigger-row-card .form-select,
1223
1304
  .trigger-row-card .form-input {
1224
1305
  margin-bottom: 0;
@@ -1229,7 +1310,7 @@ body {
1229
1310
  min-width: 0;
1230
1311
  }
1231
1312
 
1232
- .triggers-section-body > .form-select {
1313
+ .schedule-section > .form-select {
1233
1314
  width: 100%;
1234
1315
  }
1235
1316
 
@@ -1921,9 +2002,9 @@ body {
1921
2002
  border-bottom-color: var(--color-primary);
1922
2003
  }
1923
2004
 
1924
- /* ===== Runs view ===== */
2005
+ /* ===== Sessions view ===== */
1925
2006
 
1926
- .runs-view {
2007
+ .sessions-view {
1927
2008
  flex: 1;
1928
2009
  display: flex;
1929
2010
  flex-direction: column;
@@ -1931,7 +2012,7 @@ body {
1931
2012
  justify-content: center;
1932
2013
  }
1933
2014
 
1934
- .runs-card {
2015
+ .sessions-card {
1935
2016
  background: var(--color-surface);
1936
2017
  border-radius: var(--radius-md);
1937
2018
  border: 1px solid var(--color-border);
@@ -1945,12 +2026,12 @@ body {
1945
2026
  -webkit-tap-highlight-color: transparent;
1946
2027
  }
1947
2028
 
1948
- .runs-card:hover {
2029
+ .sessions-card:hover {
1949
2030
  box-shadow: var(--shadow-md);
1950
2031
  border-color: #CBD5E1;
1951
2032
  }
1952
2033
 
1953
- .runs-card-body {
2034
+ .sessions-card-body {
1954
2035
  flex: 1;
1955
2036
  min-width: 0;
1956
2037
  display: flex;
@@ -1958,7 +2039,7 @@ body {
1958
2039
  gap: var(--space-xs);
1959
2040
  }
1960
2041
 
1961
- .runs-card-name {
2042
+ .sessions-card-name {
1962
2043
  font-size: 0.9375rem;
1963
2044
  font-weight: 600;
1964
2045
  letter-spacing: -0.01em;
@@ -1968,7 +2049,7 @@ body {
1968
2049
  text-overflow: ellipsis;
1969
2050
  }
1970
2051
 
1971
- .runs-card-meta {
2052
+ .sessions-card-meta {
1972
2053
  display: flex;
1973
2054
  flex-wrap: wrap;
1974
2055
  gap: var(--space-sm);
@@ -1976,7 +2057,7 @@ body {
1976
2057
  color: var(--color-text-secondary);
1977
2058
  }
1978
2059
 
1979
- .runs-card-chevron {
2060
+ .sessions-card-chevron {
1980
2061
  flex-shrink: 0;
1981
2062
  align-self: center;
1982
2063
  color: var(--color-text-secondary);
@@ -1986,11 +2067,11 @@ body {
1986
2067
  transition: opacity var(--transition-base);
1987
2068
  }
1988
2069
 
1989
- .runs-card:hover .runs-card-chevron {
2070
+ .sessions-card:hover .sessions-card-chevron {
1990
2071
  opacity: 0.8;
1991
2072
  }
1992
2073
 
1993
- .runs-filter-chip {
2074
+ .sessions-filter-chip {
1994
2075
  display: inline-flex;
1995
2076
  align-items: center;
1996
2077
  gap: var(--space-xs);
@@ -2002,7 +2083,7 @@ body {
2002
2083
  border-radius: 999px;
2003
2084
  }
2004
2085
 
2005
- .runs-filter-chip button {
2086
+ .sessions-filter-chip button {
2006
2087
  display: inline-flex;
2007
2088
  align-items: center;
2008
2089
  justify-content: center;
@@ -2015,7 +2096,7 @@ body {
2015
2096
  line-height: 1;
2016
2097
  }
2017
2098
 
2018
- .runs-filter-chip button:hover {
2099
+ .sessions-filter-chip button:hover {
2019
2100
  color: var(--color-text);
2020
2101
  }
2021
2102
 
@@ -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: runId });
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: runId, report_files: [file] },
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
- setLoading(true);
81
- fetchData();
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
- // 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;
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
- {loading ? (
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: runId });
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: runId, message: msg });
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
- <label className="session-composer-yolo">
108
- <input
109
- type="checkbox"
110
- checked={yoloMode}
111
- onChange={(e) => setYoloMode(e.target.checked)}
112
- disabled={running}
113
- />
114
- Yolo
115
- </label>
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 RunsViewProps {
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 RunsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: RunsViewProps) {
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">&times;</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="runs-view">
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
- {filterTaskId && onClearFilter && (
209
- <div style={{ marginBottom: "var(--space-sm)" }}>
210
- <span className="runs-filter-chip">
211
- Filtered by task
212
- <button onClick={onClearFilter} aria-label="Clear filter">&times;</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
- {filterTaskId && onClearFilter && (
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">&times;</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="runs-card"
253
+ className="sessions-card"
246
254
  onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
247
255
  >
248
- <div className="runs-card-body">
249
- <h3 className="runs-card-name">{entry.task_name || entry.task_id}</h3>
250
- <div className="runs-card-meta">
256
+ <div className="sessions-card-body">
257
+ <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
258
+ <div className="sessions-card-meta">
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="runs-card-chevron">&#8250;</span>
273
+ <span className="sessions-card-chevron">&#8250;</span>
266
274
  </div>
267
275
  ))}
268
276