palmier 0.8.1 → 0.8.4

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 (133) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +16 -14
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/commands/info.d.ts +0 -3
  17. package/dist/commands/info.js +0 -5
  18. package/dist/commands/init.d.ts +0 -3
  19. package/dist/commands/init.js +2 -11
  20. package/dist/commands/pair.d.ts +1 -4
  21. package/dist/commands/pair.js +3 -12
  22. package/dist/commands/restart.d.ts +0 -3
  23. package/dist/commands/restart.js +0 -3
  24. package/dist/commands/run.d.ts +1 -14
  25. package/dist/commands/run.js +18 -61
  26. package/dist/commands/serve.d.ts +0 -3
  27. package/dist/commands/serve.js +29 -27
  28. package/dist/config.d.ts +0 -8
  29. package/dist/config.js +0 -8
  30. package/dist/device-capabilities.d.ts +1 -1
  31. package/dist/event-queues.d.ts +6 -21
  32. package/dist/event-queues.js +6 -21
  33. package/dist/events.d.ts +0 -6
  34. package/dist/events.js +1 -9
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-handler.js +1 -2
  37. package/dist/mcp-tools.d.ts +0 -3
  38. package/dist/mcp-tools.js +12 -16
  39. package/dist/nats-client.d.ts +0 -3
  40. package/dist/nats-client.js +1 -4
  41. package/dist/pending-requests.d.ts +4 -18
  42. package/dist/pending-requests.js +4 -18
  43. package/dist/platform/index.d.ts +1 -4
  44. package/dist/platform/index.js +8 -7
  45. package/dist/platform/linux.d.ts +3 -9
  46. package/dist/platform/linux.js +9 -20
  47. package/dist/platform/macos.d.ts +32 -0
  48. package/dist/platform/macos.js +287 -0
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  53. package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
  54. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
  55. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +14 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +7 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +325 -22
  73. package/palmier-server/pwa/src/App.tsx +2 -0
  74. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  78. package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
  79. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  80. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  81. package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
  82. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  83. package/palmier-server/pwa/src/constants.ts +1 -1
  84. package/palmier-server/pwa/src/native/Device.ts +18 -2
  85. package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
  86. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  87. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  88. package/palmier-server/server/src/index.ts +7 -7
  89. package/palmier-server/server/src/routes/device.ts +4 -4
  90. package/palmier-server/spec.md +38 -7
  91. package/src/agents/agent.ts +0 -4
  92. package/src/agents/claude.ts +1 -1
  93. package/src/agents/codex.ts +2 -2
  94. package/src/agents/cursor.ts +1 -1
  95. package/src/agents/deepagents.ts +1 -1
  96. package/src/agents/gemini.ts +3 -2
  97. package/src/agents/goose.ts +1 -1
  98. package/src/agents/hermes.ts +1 -1
  99. package/src/agents/kiro.ts +1 -1
  100. package/src/agents/opencode.ts +1 -1
  101. package/src/agents/qoder.ts +1 -1
  102. package/src/agents/shared-prompt.ts +0 -3
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +3 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +28 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +3 -2
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +12 -18
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +5 -7
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/macos.ts +310 -0
  121. package/src/platform/platform.ts +1 -4
  122. package/src/platform/windows.ts +19 -40
  123. package/src/rpc-handler.ts +14 -47
  124. package/src/spawn-command.ts +11 -27
  125. package/src/task.ts +7 -70
  126. package/src/transports/http-transport.ts +7 -39
  127. package/src/transports/nats-transport.ts +3 -9
  128. package/src/types.ts +3 -10
  129. package/src/update-checker.ts +2 -5
  130. package/test/macos-plist.test.ts +112 -0
  131. package/test/task-parsing.test.ts +2 -3
  132. package/test/windows-xml.test.ts +11 -12
  133. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -2,52 +2,15 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useNavigate } from "react-router-dom";
4
4
  import { Capacitor } from "@capacitor/core";
5
- import { App as CapacitorApp } from "@capacitor/app";
6
- import { Device, type PermissionType } from "../native/Device";
7
5
  import { useHostStore } from "../contexts/HostStoreContext";
8
6
  import { useMediaQuery } from "../hooks/useMediaQuery";
9
7
  import { confirmLeaveDraft } from "../draftGuard";
8
+ import CapabilityToggles from "./CapabilityToggles";
10
9
 
11
10
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
12
11
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
13
12
  const isNative = Capacitor.isNativePlatform();
14
13
 
15
- interface CapabilityDefinition {
16
- /** Server-side capability name used in device.capability.{enable,disable} RPCs. */
17
- capability: string;
18
- /** Label shown in the drawer toggle. */
19
- label: string;
20
- /** Runtime or settings permission to request before enabling. */
21
- permission?: PermissionType;
22
- /** True for capabilities that display full-screen alerts (alert, send-email). */
23
- needsFullScreenIntent?: boolean;
24
- /** Override RPC methods; location uses device.location.{enable,disable} instead. */
25
- enableMethod?: string;
26
- disableMethod?: string;
27
- enableParams?(fcmToken: string): Record<string, unknown>;
28
- disableParams?(): Record<string, unknown>;
29
- }
30
-
31
- const CAPABILITIES: CapabilityDefinition[] = [
32
- {
33
- capability: "location",
34
- label: "Location Access",
35
- permission: "location",
36
- enableMethod: "device.location.enable",
37
- disableMethod: "device.location.disable",
38
- enableParams: (fcmToken) => ({ fcmToken }),
39
- disableParams: () => ({}),
40
- },
41
- { capability: "notifications", label: "Notification Access", permission: "notificationListener" },
42
- { capability: "sms", label: "SMS Access", permission: "sms" },
43
- { capability: "contacts", label: "Contacts Access", permission: "contacts" },
44
- { capability: "calendar", label: "Calendar Access", permission: "calendar" },
45
- { capability: "dnd", label: "Do Not Disturb Control", permission: "dnd" },
46
- { capability: "alert", label: "Alert Access", needsFullScreenIntent: true },
47
- { capability: "battery", label: "Battery Access" },
48
- { capability: "send-email", label: "Email Drafting", needsFullScreenIntent: true },
49
- ];
50
-
51
14
  interface HostMenuProps {
52
15
  daemonVersion?: string | null;
53
16
  capabilityTokens?: Record<string, string | null>;
@@ -66,147 +29,10 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
66
29
  const [renamingId, setRenamingId] = useState<string | null>(null);
67
30
  const [renameValue, setRenameValue] = useState("");
68
31
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
69
- const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
70
- /**
71
- * Permission types the installed APK understands. Null while loading; an empty
72
- * set means the native plugin doesn't expose a discovery method (pre-Device
73
- * plugin build) — in that case we don't pre-filter the UI and rely on per-call
74
- * {supported: false} from the native side as the fallback.
75
- */
76
- const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
77
-
78
- useEffect(() => {
79
- if (!isNative || !Device) {
80
- setSupportedPerms(new Set());
81
- return;
82
- }
83
- Device.getSupportedPermissions()
84
- .then(({ types }) => setSupportedPerms(new Set(types)))
85
- .catch(() => setSupportedPerms(new Set())); // old APK: fall back to per-call supported flag
86
- }, []);
87
-
88
- // Capability enabled = this device's client token matches the registered device for that capability
89
- function isCapabilityEnabled(capability: string): boolean {
90
- return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
91
- }
92
-
93
- /**
94
- * A capability is shown when native either explicitly supports its permission, or
95
- * can't advertise support (empty set = old APK or web) — in the latter case the
96
- * toggle still works because the per-call `supported` flag guards at tap time.
97
- */
98
- function isCapabilityVisible(definition: CapabilityDefinition): boolean {
99
- if (!supportedPerms) return false;
100
- if (supportedPerms.size === 0) return true;
101
- if (definition.permission && !supportedPerms.has(definition.permission)) return false;
102
- if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
103
- return true;
104
- }
105
-
106
- /** Update local capability tokens state after a toggle change */
107
- function setCapabilityEnabled(capability: string, enabled: boolean) {
108
- const updated: Record<string, string | null> = {};
109
- for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
110
- updated[capability] = enabled ? (activeClientToken ?? null) : null;
111
- onCapabilityTokensChange?.(updated);
112
- }
113
-
114
- // If the OS location permission is revoked while the app is backgrounded,
115
- // disable the capability on the host so agents don't keep pinging for fixes.
116
- useEffect(() => {
117
- if (!isNative || !Device || !request) return;
118
-
119
- const locationEnabled = isCapabilityEnabled("location");
120
- if (!locationEnabled) return;
121
-
122
- function syncPermissionState() {
123
- Device!.checkPermission({ type: "location" }).then(({ granted }) => {
124
- if (!granted) {
125
- request!("device.location.disable").then(() => {
126
- setCapabilityEnabled("location", false);
127
- }).catch(() => {});
128
- }
129
- });
130
- }
131
32
 
132
- syncPermissionState();
133
- const listener = CapacitorApp.addListener("resume", syncPermissionState);
134
- return () => { listener.then((h) => h.remove()); };
135
- }, [capabilityTokens, activeClientToken]);
136
-
137
- // Mirror the server-derived enabled set into native as the local kill-switch.
138
- // Toggling below writes through immediately; this useEffect catches host-initiated
139
- // changes (e.g. a capability revoked on another device) on the next render.
140
- useEffect(() => {
141
- if (!isNative || !Device) return;
142
- const enabledCapabilities = CAPABILITIES
143
- .map((definition) => definition.capability)
144
- .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
145
- Device.setEnabledCapabilities({ capabilities: enabledCapabilities }).catch(() => {});
146
- }, [capabilityTokens, activeClientToken]);
147
-
148
- async function toggleCapability(definition: CapabilityDefinition) {
149
- if (!request) return;
150
- const enabled = isCapabilityEnabled(definition.capability);
151
- setTogglingCapability(definition.capability);
152
- try {
153
- if (enabled) {
154
- const method = definition.disableMethod ?? "device.capability.disable";
155
- const params = definition.disableParams?.() ?? { capability: definition.capability };
156
- await request(method, params);
157
- setCapabilityEnabled(definition.capability, false);
158
- return;
159
- }
160
-
161
- if (Device && definition.permission) {
162
- const check = await Device.checkPermission({ type: definition.permission });
163
- if (!check.supported) {
164
- console.warn(`Native build does not support permission '${definition.permission}'`);
165
- return;
166
- }
167
- if (!check.granted) {
168
- const result = await Device.requestPermission({ type: definition.permission });
169
- if (!result.granted) return;
170
- }
171
- }
172
- if (Device && definition.needsFullScreenIntent) {
173
- const check = await Device.checkPermission({ type: "fullScreenIntent" });
174
- if (!check.supported) {
175
- console.warn("Native build does not support fullScreenIntent");
176
- return;
177
- }
178
- if (!check.granted) {
179
- const result = await Device.requestPermission({ type: "fullScreenIntent" });
180
- if (!result.granted) return;
181
- }
182
- }
183
-
184
- if (!Device) return;
185
- const { token: fcmToken } = await Device.getFcmToken();
186
- if (!fcmToken) { console.warn("No FCM token available"); return; }
187
-
188
- // Whitelist the capability natively before enabling on the host, so an FCM
189
- // from the host can't arrive in the gap where our useEffect hasn't synced yet.
190
- const enabledNow = CAPABILITIES
191
- .map((c) => c.capability)
192
- .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
193
- await Device.setEnabledCapabilities({ capabilities: enabledNow });
194
-
195
- const method = definition.enableMethod ?? "device.capability.enable";
196
- const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
197
- await request(method, params);
198
- setCapabilityEnabled(definition.capability, true);
199
- } catch (err) {
200
- console.error(`Failed to toggle ${definition.capability}:`, err);
201
- } finally {
202
- setTogglingCapability(null);
203
- }
204
- }
205
33
  const drawerRef = useRef<HTMLDivElement>(null);
206
34
  const renameInputRef = useRef<HTMLInputElement>(null);
207
35
 
208
- // In LAN mode, there's only one host — no picker/pairing needed
209
-
210
36
  const close = useCallback(() => {
211
37
  setClosing(true);
212
38
  }, []);
@@ -261,20 +87,17 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
261
87
 
262
88
  const drawerContent = (
263
89
  <>
264
- <div className="drawer-header">
265
- <span className="drawer-title">Palmier</span>
266
- {!isDesktop && (
267
- <button
268
- className="drawer-close-btn"
269
- onClick={close}
270
- aria-label="Close menu"
271
- >
272
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
273
- <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
274
- </svg>
275
- </button>
276
- )}
277
- </div>
90
+ {!isDesktop && (
91
+ <button
92
+ className="drawer-close-btn"
93
+ onClick={close}
94
+ aria-label="Close menu"
95
+ >
96
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
97
+ <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
98
+ </svg>
99
+ </button>
100
+ )}
278
101
 
279
102
  {!isLanMode && pairedHosts.length > 0 && (
280
103
  <div className="drawer-section">
@@ -382,27 +205,17 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
382
205
  </div>
383
206
  </>)}
384
207
 
385
- {isNative && (
208
+ {isNative && request && (
386
209
  <>
387
210
  <div className="drawer-divider" />
388
211
  <div className="drawer-section">
389
- {CAPABILITIES.filter(isCapabilityVisible).map((definition) => {
390
- const enabled = isCapabilityEnabled(definition.capability);
391
- return (
392
- <label key={definition.capability} className="drawer-toggle">
393
- <span className="drawer-toggle-label">{definition.label}</span>
394
- <button
395
- className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
396
- onClick={() => toggleCapability(definition)}
397
- disabled={togglingCapability === definition.capability}
398
- role="switch"
399
- aria-checked={enabled}
400
- >
401
- <span className="toggle-switch-thumb" />
402
- </button>
403
- </label>
404
- );
405
- })}
212
+ <h3 className="drawer-section-label">Host capabilities on this device</h3>
213
+ <CapabilityToggles
214
+ capabilityTokens={capabilityTokens}
215
+ activeClientToken={activeClientToken}
216
+ request={request}
217
+ onCapabilityTokensChange={(tokens) => onCapabilityTokensChange?.(tokens)}
218
+ />
406
219
  </div>
407
220
  </>
408
221
  )}
@@ -131,7 +131,8 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
131
131
  }, [messages]);
132
132
 
133
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).
134
+ // input is visible. Deliberately not focusing the input on mobile that
135
+ // would pop the soft keyboard as soon as the run opens.
135
136
  useEffect(() => {
136
137
  if (loading || isLatestEmpty || !resolvedRunId) return;
137
138
  if (initialFocusForRunId.current === resolvedRunId) return;
@@ -139,9 +140,8 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
139
140
  requestAnimationFrame(() => {
140
141
  if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
141
142
  window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "auto" });
142
- if (!isAgentGenerating) followupInputRef.current?.focus();
143
143
  });
144
- }, [loading, isLatestEmpty, resolvedRunId, isAgentGenerating]);
144
+ }, [loading, isLatestEmpty, resolvedRunId]);
145
145
 
146
146
  function typeLabel(type?: string): string | undefined {
147
147
  if (type === "input") return "User Input";
@@ -5,6 +5,7 @@ import type { AgentInfo } from "../types";
5
5
 
6
6
  interface SessionComposerProps {
7
7
  agents: AgentInfo[];
8
+ hostPlatform?: string;
8
9
  onStarted(taskId: string, runId?: string): void;
9
10
  }
10
11
 
@@ -15,7 +16,7 @@ function pickDefaultAgent(agents: AgentInfo[]): string {
15
16
  return agents[0]?.key ?? "";
16
17
  }
17
18
 
18
- export default function SessionComposer({ agents, onStarted }: SessionComposerProps) {
19
+ export default function SessionComposer({ agents, hostPlatform, onStarted }: SessionComposerProps) {
19
20
  const { request } = useHostConnection();
20
21
  const [prompt, setPrompt] = useState("");
21
22
  const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
@@ -62,7 +63,15 @@ export default function SessionComposer({ agents, onStarted }: SessionComposerPr
62
63
  try {
63
64
  const result = await request<{ task_id?: string; run_id?: string; error?: string }>(
64
65
  "task.run_oneoff",
65
- { user_prompt: prompt, agent, yolo_mode: yoloMode },
66
+ {
67
+ user_prompt: prompt,
68
+ agent,
69
+ yolo_mode: yoloMode,
70
+ // Direct runs on Windows need a visible session so interactive tools
71
+ // (browsers, GUI apps) can attach; background task-scheduler runs
72
+ // would otherwise land in session 0 with no display.
73
+ ...(hostPlatform === "win32" ? { foreground_mode: true } : {}),
74
+ },
66
75
  );
67
76
  if (result.error) {
68
77
  setError(result.error);
@@ -4,6 +4,7 @@ import { formatTime } from "../formatTime";
4
4
  import { confirmLeaveDraft } from "../draftGuard";
5
5
  import SessionComposer from "./SessionComposer";
6
6
  import PullToRefreshIndicator from "./PullToRefreshIndicator";
7
+ import SwipeToDeleteRow from "./SwipeToDeleteRow";
7
8
  import { usePullToRefresh } from "../hooks/usePullToRefresh";
8
9
  import type { AgentInfo, HistoryEntry } from "../types";
9
10
 
@@ -13,19 +14,37 @@ interface SessionsViewProps {
13
14
  request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
14
15
  subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
15
16
  agents: AgentInfo[];
17
+ hostPlatform?: string;
16
18
  filterTaskId?: string | null;
17
19
  onClearFilter?: () => void;
18
20
  }
19
21
 
20
22
  const PAGE_SIZE = 10;
21
23
 
22
- export default function SessionsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: SessionsViewProps) {
24
+ export default function SessionsView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, filterTaskId, onClearFilter }: SessionsViewProps) {
23
25
  const [entries, setEntries] = useState<HistoryEntry[]>([]);
24
26
  const [total, setTotal] = useState(0);
25
27
  const [loading, setLoading] = useState(false);
26
28
  const [loadingMore, setLoadingMore] = useState(false);
29
+ /** Key of the row currently showing its delete action, or null. iOS pattern — at most one at a time. */
30
+ const [revealedKey, setRevealedKey] = useState<string | null>(null);
27
31
  const navigate = useNavigate();
28
32
 
33
+ async function deleteEntry(entry: HistoryEntry) {
34
+ const key = `${entry.task_id}:${entry.run_id}`;
35
+ // Optimistic: drop from the list immediately, restore if the RPC fails.
36
+ setEntries((prev) => prev.filter((e) => `${e.task_id}:${e.run_id}` !== key));
37
+ setTotal((t) => Math.max(0, t - 1));
38
+ setRevealedKey(null);
39
+ try {
40
+ await request("taskrun.delete", { task_id: entry.task_id, run_id: entry.run_id });
41
+ } catch (err) {
42
+ console.error("Failed to delete run:", err);
43
+ setEntries((prev) => [entry, ...prev]);
44
+ setTotal((t) => t + 1);
45
+ }
46
+ }
47
+
29
48
  const sentinelRef = useRef<HTMLDivElement>(null);
30
49
 
31
50
  // Build RPC params with optional task_id filter
@@ -157,6 +176,7 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
157
176
  const composer = !filterTaskId && (
158
177
  <SessionComposer
159
178
  agents={agents}
179
+ hostPlatform={hostPlatform}
160
180
  onStarted={(taskId, runId) => {
161
181
  if (runId) navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
162
182
  else navigate(`/runs/${encodeURIComponent(taskId)}`);
@@ -247,38 +267,46 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
247
267
  {composer}
248
268
  {filterChip}
249
269
  <div className="task-list">
250
- {entries.map((entry, i) => (
251
- <div
252
- key={`${entry.task_id}-${entry.run_id}-${i}`}
253
- className="sessions-card"
254
- onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
255
- >
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">
259
- {entry.running_state === "started" ? (
260
- <span className="status-spinner" aria-label="Running">
261
- <span />
262
- </span>
263
- ) : (
264
- <span style={{ color: stateColor(entry.running_state) }}>
265
- {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
266
- </span>
267
- )}
268
- {entry.end_time && <span>{formatTime(entry.end_time)}</span>}
269
- {entry.start_time && entry.end_time && (
270
- <span style={{ color: "var(--color-muted)" }}>
271
- {formatDuration(entry.start_time, entry.end_time)}
272
- </span>
273
- )}
274
- {entry.error && (
275
- <span style={{ color: "var(--color-muted)" }}>{entry.error}</span>
276
- )}
270
+ {entries.map((entry, i) => {
271
+ const key = `${entry.task_id}:${entry.run_id}`;
272
+ return (
273
+ <SwipeToDeleteRow
274
+ key={`${key}-${i}`}
275
+ id={key}
276
+ revealedId={revealedKey}
277
+ setRevealedId={setRevealedKey}
278
+ onDelete={() => deleteEntry(entry)}
279
+ onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
280
+ >
281
+ <div className="sessions-card">
282
+ <div className="sessions-card-body">
283
+ <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
284
+ <div className="sessions-card-meta">
285
+ {entry.running_state === "started" ? (
286
+ <span className="status-spinner" aria-label="Running">
287
+ <span />
288
+ </span>
289
+ ) : (
290
+ <span style={{ color: stateColor(entry.running_state) }}>
291
+ {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
292
+ </span>
293
+ )}
294
+ {entry.end_time && <span>{formatTime(entry.end_time)}</span>}
295
+ {entry.start_time && entry.end_time && (
296
+ <span style={{ color: "var(--color-muted)" }}>
297
+ {formatDuration(entry.start_time, entry.end_time)}
298
+ </span>
299
+ )}
300
+ {entry.error && (
301
+ <span style={{ color: "var(--color-muted)" }}>{entry.error}</span>
302
+ )}
303
+ </div>
304
+ </div>
305
+ <span className="sessions-card-chevron">&#8250;</span>
277
306
  </div>
278
- </div>
279
- <span className="sessions-card-chevron">&#8250;</span>
280
- </div>
281
- ))}
307
+ </SwipeToDeleteRow>
308
+ );
309
+ })}
282
310
 
283
311
  {/* Sentinel for infinite scroll */}
284
312
  <div ref={sentinelRef} style={{ height: 1 }} />
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef, useState, type ReactNode, type PointerEvent } from "react";
2
+
3
+ interface SwipeToDeleteRowProps {
4
+ /** Unique id used to coordinate "at most one row revealed" with the parent. */
5
+ id: string;
6
+ /** The id of the currently-revealed row (or null). Set to this row's id to reveal it. */
7
+ revealedId: string | null;
8
+ setRevealedId(id: string | null): void;
9
+ onDelete(): void;
10
+ onClick?(): void;
11
+ children: ReactNode;
12
+ /** Label for the action button (default "Delete"). */
13
+ actionLabel?: string;
14
+ }
15
+
16
+ const REVEAL_WIDTH = 88; // px width of the action button
17
+ const OPEN_THRESHOLD = REVEAL_WIDTH / 2;
18
+ const AXIS_LOCK_THRESHOLD = 6; // px of horizontal travel before we claim the gesture
19
+
20
+ /**
21
+ * Wraps a row with swipe-left to reveal a destructive action button.
22
+ * Tap the button to confirm, tap elsewhere to dismiss the reveal.
23
+ *
24
+ * Uses pointer events so the same code works for touch and mouse. A short
25
+ * axis-lock period at the start of a drag decides whether the user is
26
+ * scrolling vertically (let it through) or swiping horizontally (capture).
27
+ */
28
+ export default function SwipeToDeleteRow({
29
+ id,
30
+ revealedId,
31
+ setRevealedId,
32
+ onDelete,
33
+ onClick,
34
+ children,
35
+ actionLabel = "Delete",
36
+ }: SwipeToDeleteRowProps) {
37
+ const revealed = revealedId === id;
38
+ const [dragOffset, setDragOffset] = useState(0);
39
+ const [dragging, setDragging] = useState(false);
40
+
41
+ const startX = useRef(0);
42
+ const startY = useRef(0);
43
+ const axis = useRef<"x" | "y" | null>(null);
44
+ const baseOffset = useRef(0); // translateX when the gesture started
45
+ const movedEnough = useRef(false); // whether we should suppress the click that follows
46
+ const rowRef = useRef<HTMLDivElement>(null);
47
+
48
+ // Reset local drag offset whenever parent closes this row.
49
+ useEffect(() => {
50
+ if (!revealed) setDragOffset(0);
51
+ }, [revealed]);
52
+
53
+ // Close when the user taps elsewhere in the document.
54
+ useEffect(() => {
55
+ if (!revealed) return;
56
+ function onDocPointerDown(e: Event) {
57
+ if (rowRef.current?.contains(e.target as Node)) return;
58
+ setRevealedId(null);
59
+ }
60
+ document.addEventListener("pointerdown", onDocPointerDown);
61
+ return () => document.removeEventListener("pointerdown", onDocPointerDown);
62
+ }, [revealed, setRevealedId]);
63
+
64
+ function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
65
+ // Ignore non-primary buttons (right-click etc.) so we don't steal them.
66
+ if (e.button !== undefined && e.button !== 0) return;
67
+ startX.current = e.clientX;
68
+ startY.current = e.clientY;
69
+ axis.current = null;
70
+ baseOffset.current = revealed ? -REVEAL_WIDTH : 0;
71
+ movedEnough.current = false;
72
+ }
73
+
74
+ function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
75
+ if (e.pointerType === "mouse" && e.buttons === 0) return; // not dragging
76
+ const dx = e.clientX - startX.current;
77
+ const dy = e.clientY - startY.current;
78
+
79
+ if (axis.current === null) {
80
+ if (Math.abs(dx) < AXIS_LOCK_THRESHOLD && Math.abs(dy) < AXIS_LOCK_THRESHOLD) return;
81
+ axis.current = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
82
+ if (axis.current === "x") {
83
+ try { (e.currentTarget as Element).setPointerCapture(e.pointerId); } catch { /* unsupported */ }
84
+ }
85
+ }
86
+ if (axis.current !== "x") return;
87
+
88
+ movedEnough.current = true;
89
+ if (!dragging) setDragging(true);
90
+ // Clamp: can swipe left to reveal fully, a bit of rubber-band on the right.
91
+ let next = baseOffset.current + dx;
92
+ if (next > 0) next = next / 4;
93
+ if (next < -REVEAL_WIDTH) next = -REVEAL_WIDTH + (next + REVEAL_WIDTH) / 4;
94
+ setDragOffset(next);
95
+ }
96
+
97
+ function handlePointerUp() {
98
+ if (axis.current !== "x") {
99
+ setDragging(false);
100
+ return;
101
+ }
102
+ axis.current = null;
103
+ setDragging(false);
104
+
105
+ const finalOffset = dragOffset;
106
+ const openNow = finalOffset <= -OPEN_THRESHOLD;
107
+ if (openNow) {
108
+ setDragOffset(-REVEAL_WIDTH);
109
+ setRevealedId(id);
110
+ } else {
111
+ setDragOffset(0);
112
+ if (revealed) setRevealedId(null);
113
+ }
114
+ }
115
+
116
+ function handleClickCapture(e: React.MouseEvent) {
117
+ // If the gesture translated the row, treat it as a swipe — not a click.
118
+ // Also absorb the click that re-hides a revealed row.
119
+ if (movedEnough.current) {
120
+ movedEnough.current = false;
121
+ e.stopPropagation();
122
+ e.preventDefault();
123
+ return;
124
+ }
125
+ if (revealed) {
126
+ e.stopPropagation();
127
+ e.preventDefault();
128
+ setRevealedId(null);
129
+ }
130
+ }
131
+
132
+ const currentOffset = dragOffset !== 0 ? dragOffset : (revealed ? -REVEAL_WIDTH : 0);
133
+
134
+ return (
135
+ <div ref={rowRef} className="swipe-row">
136
+ <button
137
+ type="button"
138
+ className="swipe-row-action"
139
+ style={{ width: REVEAL_WIDTH }}
140
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
141
+ tabIndex={revealed ? 0 : -1}
142
+ aria-hidden={!revealed}
143
+ >
144
+ {actionLabel}
145
+ </button>
146
+ <div
147
+ className={`swipe-row-content ${dragging ? "swipe-row-content-dragging" : ""}`}
148
+ style={{ transform: `translateX(${currentOffset}px)` }}
149
+ onPointerDown={handlePointerDown}
150
+ onPointerMove={handlePointerMove}
151
+ onPointerUp={handlePointerUp}
152
+ onPointerCancel={handlePointerUp}
153
+ onClickCapture={handleClickCapture}
154
+ onClick={onClick}
155
+ >
156
+ {children}
157
+ </div>
158
+ </div>
159
+ );
160
+ }
@@ -133,7 +133,7 @@ export default function TaskCard({ task, lastEvent, onEdit, onDelete, onViewRun
133
133
  values: string[] | undefined,
134
134
  ): string {
135
135
  if (!scheduleType) return "";
136
- if (scheduleType === "on_new_notification") return "On new push notification";
136
+ if (scheduleType === "on_new_notification") return "On new notification";
137
137
  if (scheduleType === "on_new_sms") return "On new SMS";
138
138
  if (!values || values.length === 0) return "";
139
139
  if (values.length === 1) return formatSingleValue(scheduleType, values[0]);