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.
- package/CLAUDE.md +13 -0
- package/README.md +16 -14
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +3 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +29 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +12 -16
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +8 -7
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.d.ts +0 -6
- package/dist/rpc-handler.js +14 -47
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +7 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +325 -22
- package/palmier-server/pwa/src/App.tsx +2 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +18 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +3 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +28 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +3 -2
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +12 -18
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +5 -7
- package/src/platform/linux.ts +9 -20
- package/src/platform/macos.ts +310 -0
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +14 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +7 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/macos-plist.test.ts +112 -0
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- 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
|
-
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
<
|
|
265
|
-
{
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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">›</span>
|
|
277
306
|
</div>
|
|
278
|
-
</
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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]);
|