palmier 0.8.11 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DhphickB.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-4WNPL7z3.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-DjwsAB0V.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-Bpd2nO1M.js → web-DugGj1t8.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +17 -23
- package/package.json +1 -1
- package/palmier-server/README.md +2 -1
- package/palmier-server/pwa/src/App.css +37 -0
- package/palmier-server/pwa/src/App.tsx +36 -15
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
- package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
- package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
- package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
- package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
- package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
- package/palmier-server/pwa/src/native/Device.ts +23 -38
- package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
- package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
- package/palmier-server/pwa/src/service-worker.ts +9 -6
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/spec.md +37 -11
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- package/src/rpc-handler.ts +14 -22
- package/dist/device-capabilities.d.ts +0 -9
- package/dist/device-capabilities.js +0 -36
- package/dist/pwa/assets/index-B7S0YoMo.js +0 -120
- package/src/device-capabilities.ts +0 -57
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
2
|
-
import { createPortal } from "react-dom";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
3
2
|
import { Capacitor } from "@capacitor/core";
|
|
4
3
|
import { App as CapacitorApp } from "@capacitor/app";
|
|
5
|
-
import { Device, type
|
|
4
|
+
import { Device, type CapabilityStatus } from "../native/Device";
|
|
6
5
|
|
|
7
6
|
const isNative = Capacitor.isNativePlatform();
|
|
8
7
|
|
|
@@ -14,265 +13,108 @@ interface CapabilityDefinition {
|
|
|
14
13
|
capability: string;
|
|
15
14
|
label: string;
|
|
16
15
|
group: CapabilityGroup;
|
|
17
|
-
permission?: PermissionType;
|
|
18
|
-
needsFullScreenIntent?: boolean;
|
|
19
|
-
enableMethod?: string;
|
|
20
|
-
disableMethod?: string;
|
|
21
|
-
enableParams?(fcmToken: string): Record<string, unknown>;
|
|
22
|
-
disableParams?(): Record<string, unknown>;
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
const CAPABILITIES: CapabilityDefinition[] = [
|
|
26
|
-
{ capability: "sms-read", label: "Read SMS", group: "Messaging"
|
|
27
|
-
{ capability: "sms-send", label: "Send SMS", group: "Messaging"
|
|
28
|
-
{ capability: "send-email", label: "Send Email", group: "Messaging"
|
|
29
|
-
{ capability: "notifications", label: "Notifications from Other Apps", group: "Data"
|
|
30
|
-
{ capability: "contacts", label: "Manage Contacts", group: "Data"
|
|
31
|
-
{ capability: "calendar", label: "Manage Calendar", group: "Data"
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
group: "Device",
|
|
36
|
-
permission: "location",
|
|
37
|
-
enableMethod: "device.location.enable",
|
|
38
|
-
disableMethod: "device.location.disable",
|
|
39
|
-
enableParams: (fcmToken) => ({ fcmToken }),
|
|
40
|
-
disableParams: () => ({}),
|
|
41
|
-
},
|
|
42
|
-
{ capability: "battery", label: "Read Battery Status", group: "Device" },
|
|
43
|
-
{ capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
|
|
44
|
-
{ capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
|
|
19
|
+
{ capability: "sms-read", label: "Read SMS", group: "Messaging" },
|
|
20
|
+
{ capability: "sms-send", label: "Send SMS", group: "Messaging" },
|
|
21
|
+
{ capability: "send-email", label: "Send Email", group: "Messaging" },
|
|
22
|
+
{ capability: "notifications", label: "Notifications from Other Apps", group: "Data" },
|
|
23
|
+
{ capability: "contacts", label: "Manage Contacts", group: "Data" },
|
|
24
|
+
{ capability: "calendar", label: "Manage Calendar", group: "Data" },
|
|
25
|
+
{ capability: "location", label: "Get Location", group: "Device" },
|
|
26
|
+
{ capability: "dnd", label: "Set Ringer Mode", group: "Device" },
|
|
27
|
+
{ capability: "alarm", label: "Trigger Alarms", group: "Device" },
|
|
45
28
|
];
|
|
46
29
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export default function CapabilityToggles({ capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: CapabilityTogglesProps) {
|
|
55
|
-
const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
|
|
56
|
-
const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
|
|
57
|
-
const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
|
|
58
|
-
|
|
59
|
-
// Null while loading; empty set means the native plugin doesn't advertise the
|
|
60
|
-
// list (old APK / web) — fall back to the per-call {supported} flag.
|
|
61
|
-
const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!isNative || !Device) {
|
|
65
|
-
setSupportedPerms(new Set());
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
Device.getSupportedPermissions()
|
|
69
|
-
.then(({ types }) => setSupportedPerms(new Set(types)))
|
|
70
|
-
.catch(() => setSupportedPerms(new Set()));
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
|
-
function isCapabilityEnabled(capability: string): boolean {
|
|
74
|
-
return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
|
|
30
|
+
export async function loadEnabledCapabilities(): Promise<Set<string>> {
|
|
31
|
+
if (!isNative || !Device) return new Set();
|
|
32
|
+
try {
|
|
33
|
+
const { capabilities } = await Device.getCapabilityStatus();
|
|
34
|
+
return new Set(capabilities.filter((c) => c.enabled).map((c) => c.name));
|
|
35
|
+
} catch {
|
|
36
|
+
return new Set();
|
|
75
37
|
}
|
|
38
|
+
}
|
|
76
39
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (definition.permission && !supportedPerms.has(definition.permission)) return false;
|
|
81
|
-
if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
40
|
+
interface CapabilityTogglesProps {
|
|
41
|
+
onChange?(enabled: Set<string>): void;
|
|
42
|
+
}
|
|
84
43
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
updated[capability] = enabled ? (activeClientToken ?? null) : null;
|
|
89
|
-
onCapabilityTokensChange(updated);
|
|
90
|
-
}
|
|
44
|
+
export default function CapabilityToggles({ onChange }: CapabilityTogglesProps) {
|
|
45
|
+
const [statuses, setStatuses] = useState<Map<string, CapabilityStatus>>(new Map());
|
|
46
|
+
const [busyCapability, setBusyCapability] = useState<string | null>(null);
|
|
91
47
|
|
|
92
|
-
|
|
93
|
-
// disable the capability on the host so agents don't keep pinging for fixes.
|
|
94
|
-
useEffect(() => {
|
|
48
|
+
const refresh = useCallback(async () => {
|
|
95
49
|
if (!isNative || !Device) return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}).catch(() => {});
|
|
105
|
-
}
|
|
106
|
-
});
|
|
50
|
+
try {
|
|
51
|
+
const { capabilities } = await Device.getCapabilityStatus();
|
|
52
|
+
const next = new Map<string, CapabilityStatus>();
|
|
53
|
+
for (const c of capabilities) next.set(c.name, c);
|
|
54
|
+
setStatuses(next);
|
|
55
|
+
onChange?.(new Set(capabilities.filter((c) => c.enabled).map((c) => c.name)));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("Failed to read capability status:", err);
|
|
107
58
|
}
|
|
59
|
+
}, [onChange]);
|
|
108
60
|
|
|
109
|
-
|
|
110
|
-
const listener = CapacitorApp.addListener("resume", syncPermissionState);
|
|
111
|
-
return () => { listener.then((h) => h.remove()); };
|
|
112
|
-
}, [capabilityTokens, activeClientToken]);
|
|
61
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
113
62
|
|
|
114
|
-
// Mirror the server-derived enabled set into native as a local kill-switch.
|
|
115
|
-
// Toggle paths write through immediately; this catches host-initiated changes
|
|
116
|
-
// (e.g. a capability revoked on another device) on the next render.
|
|
117
63
|
useEffect(() => {
|
|
118
|
-
if (!isNative
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Device.setEnabledCapabilities({ capabilities: enabled }).catch(() => {});
|
|
123
|
-
}, [capabilityTokens, activeClientToken]);
|
|
124
|
-
|
|
125
|
-
async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
|
|
126
|
-
const enabled = isCapabilityEnabled(definition.capability);
|
|
127
|
-
|
|
128
|
-
if (enabled && !bypassConfirmation) {
|
|
129
|
-
setConfirmingDisableCapability(definition);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
|
|
134
|
-
&& capabilityTokens[definition.capability] !== activeClientToken;
|
|
135
|
-
if (ownedByOther && !bypassConfirmation) {
|
|
136
|
-
setConfirmingSwitchCapability(definition);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
64
|
+
if (!isNative) return;
|
|
65
|
+
const listener = CapacitorApp.addListener("resume", refresh);
|
|
66
|
+
return () => { listener.then((h) => h.remove()); };
|
|
67
|
+
}, [refresh]);
|
|
139
68
|
|
|
140
|
-
|
|
69
|
+
async function toggleCapability(definition: CapabilityDefinition) {
|
|
70
|
+
if (!Device) return;
|
|
71
|
+
const status = statuses.get(definition.capability);
|
|
72
|
+
if (!status) return;
|
|
73
|
+
setBusyCapability(definition.capability);
|
|
141
74
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (Device && definition.capability === "send-email") {
|
|
151
|
-
try {
|
|
152
|
-
const result = await Device.hasEmailClient();
|
|
153
|
-
if (result.supported && !result.available) {
|
|
154
|
-
alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
} catch { /* older APK: fall through */ }
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (Device && definition.permission) {
|
|
161
|
-
const check = await Device.checkPermission({ type: definition.permission });
|
|
162
|
-
if (!check.supported) {
|
|
163
|
-
console.warn(`Native build does not support permission '${definition.permission}'`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (!check.granted) {
|
|
167
|
-
const result = await Device.requestPermission({ type: definition.permission });
|
|
168
|
-
if (!result.granted) return;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (Device && definition.needsFullScreenIntent) {
|
|
172
|
-
const check = await Device.checkPermission({ type: "fullScreenIntent" });
|
|
173
|
-
if (!check.supported) {
|
|
174
|
-
console.warn("Native build does not support fullScreenIntent");
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
if (!check.granted) {
|
|
178
|
-
const result = await Device.requestPermission({ type: "fullScreenIntent" });
|
|
179
|
-
if (!result.granted) return;
|
|
180
|
-
}
|
|
75
|
+
const result = await Device.setCapabilityEnabled({
|
|
76
|
+
capability: definition.capability,
|
|
77
|
+
enabled: !status.enabled,
|
|
78
|
+
});
|
|
79
|
+
if (!result.enabled && result.reason === "no-email-client") {
|
|
80
|
+
alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Send Email.");
|
|
181
81
|
}
|
|
182
|
-
|
|
183
|
-
if (!Device) return;
|
|
184
|
-
const { token: fcmToken } = await Device.getFcmToken();
|
|
185
|
-
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
186
|
-
|
|
187
|
-
// Whitelist the capability natively before enabling on the host, so an FCM
|
|
188
|
-
// from the host can't arrive in the gap before our useEffect syncs.
|
|
189
|
-
const enabledNow = CAPABILITIES
|
|
190
|
-
.map((c) => c.capability)
|
|
191
|
-
.filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
|
|
192
|
-
await Device.setEnabledCapabilities({ capabilities: enabledNow });
|
|
193
|
-
|
|
194
|
-
const method = definition.enableMethod ?? "device.capability.enable";
|
|
195
|
-
const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
|
|
196
|
-
await request(method, params);
|
|
197
|
-
setCapabilityEnabled(definition.capability, true);
|
|
82
|
+
await refresh();
|
|
198
83
|
} catch (err) {
|
|
199
84
|
console.error(`Failed to toggle ${definition.capability}:`, err);
|
|
200
85
|
} finally {
|
|
201
|
-
|
|
86
|
+
setBusyCapability(null);
|
|
202
87
|
}
|
|
203
88
|
}
|
|
204
89
|
|
|
205
90
|
const visibleGroups = CAPABILITY_GROUPS
|
|
206
|
-
.map((group) => ({
|
|
91
|
+
.map((group) => ({
|
|
92
|
+
group,
|
|
93
|
+
items: CAPABILITIES.filter((d) => {
|
|
94
|
+
const s = statuses.get(d.capability);
|
|
95
|
+
return d.group === group && s?.supported;
|
|
96
|
+
}),
|
|
97
|
+
}))
|
|
207
98
|
.filter((g) => g.items.length > 0);
|
|
208
99
|
|
|
209
100
|
if (!isNative || visibleGroups.length === 0) return null;
|
|
210
101
|
|
|
211
|
-
const switchModal = confirmingSwitchCapability && createPortal(
|
|
212
|
-
<div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
|
|
213
|
-
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
214
|
-
<h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
|
|
215
|
-
<p className="confirm-modal-message">
|
|
216
|
-
{confirmingSwitchCapability.label} is currently enabled on another device. Switching will make this device the one the host uses for {confirmingSwitchCapability.label}, and it will be disabled on the other device.
|
|
217
|
-
</p>
|
|
218
|
-
<div className="confirm-modal-actions">
|
|
219
|
-
<button className="btn btn-secondary" onClick={() => setConfirmingSwitchCapability(null)}>Cancel</button>
|
|
220
|
-
<button
|
|
221
|
-
className="btn btn-primary"
|
|
222
|
-
onClick={() => {
|
|
223
|
-
const d = confirmingSwitchCapability;
|
|
224
|
-
setConfirmingSwitchCapability(null);
|
|
225
|
-
toggleCapability(d, true);
|
|
226
|
-
}}
|
|
227
|
-
>
|
|
228
|
-
Switch
|
|
229
|
-
</button>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
232
|
-
</div>,
|
|
233
|
-
document.body,
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
const disableModal = confirmingDisableCapability && createPortal(
|
|
237
|
-
<div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
|
|
238
|
-
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
239
|
-
<h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
|
|
240
|
-
<p className="confirm-modal-message">
|
|
241
|
-
Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
|
|
242
|
-
</p>
|
|
243
|
-
<div className="confirm-modal-actions">
|
|
244
|
-
<button className="btn btn-secondary" onClick={() => setConfirmingDisableCapability(null)}>Cancel</button>
|
|
245
|
-
<button
|
|
246
|
-
className="btn btn-danger"
|
|
247
|
-
onClick={() => {
|
|
248
|
-
const d = confirmingDisableCapability;
|
|
249
|
-
setConfirmingDisableCapability(null);
|
|
250
|
-
toggleCapability(d, true);
|
|
251
|
-
}}
|
|
252
|
-
>
|
|
253
|
-
Disable
|
|
254
|
-
</button>
|
|
255
|
-
</div>
|
|
256
|
-
</div>
|
|
257
|
-
</div>,
|
|
258
|
-
document.body,
|
|
259
|
-
);
|
|
260
|
-
|
|
261
102
|
return (
|
|
262
103
|
<>
|
|
263
104
|
{visibleGroups.map(({ group, items }, index) => (
|
|
264
105
|
<div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
|
|
265
106
|
{items.map((definition) => {
|
|
266
|
-
const
|
|
107
|
+
const status = statuses.get(definition.capability);
|
|
108
|
+
const on = !!status?.enabled;
|
|
267
109
|
return (
|
|
268
110
|
<label key={definition.capability} className="drawer-toggle">
|
|
269
111
|
<span className="drawer-toggle-label">{definition.label}</span>
|
|
270
112
|
<button
|
|
271
|
-
className={`toggle-switch ${
|
|
113
|
+
className={`toggle-switch ${on ? "toggle-switch-on" : ""}`}
|
|
272
114
|
onClick={() => toggleCapability(definition)}
|
|
273
|
-
disabled={
|
|
115
|
+
disabled={busyCapability === definition.capability}
|
|
274
116
|
role="switch"
|
|
275
|
-
aria-checked={
|
|
117
|
+
aria-checked={on}
|
|
276
118
|
>
|
|
277
119
|
<span className="toggle-switch-thumb" />
|
|
278
120
|
</button>
|
|
@@ -281,8 +123,6 @@ export default function CapabilityToggles({ capabilityTokens, activeClientToken,
|
|
|
281
123
|
})}
|
|
282
124
|
</div>
|
|
283
125
|
))}
|
|
284
|
-
{switchModal}
|
|
285
|
-
{disableModal}
|
|
286
126
|
</>
|
|
287
127
|
);
|
|
288
128
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
6
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
6
7
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
7
8
|
import { confirmLeaveDraft } from "../draftGuard";
|
|
9
|
+
import { Device } from "../native/Device";
|
|
8
10
|
import CapabilityToggles from "./CapabilityToggles";
|
|
9
11
|
|
|
10
12
|
/** Local mode: PWA is served by palmier serve on loopback. */
|
|
@@ -14,22 +16,64 @@ const isNative = Capacitor.isNativePlatform();
|
|
|
14
16
|
|
|
15
17
|
interface HostMenuProps {
|
|
16
18
|
daemonVersion?: string | null;
|
|
17
|
-
|
|
18
|
-
activeClientToken?: string | null;
|
|
19
|
+
linkedClientToken?: string | null;
|
|
19
20
|
request?<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
20
|
-
|
|
21
|
+
onEnabledCapabilitiesChange?(next: Set<string>): void;
|
|
22
|
+
onLinkedClientTokenChange?(next: string | null): void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export default function HostMenu({ daemonVersion,
|
|
24
|
-
const { pairedHosts,
|
|
25
|
+
export default function HostMenu({ daemonVersion, linkedClientToken, request, onEnabledCapabilitiesChange, onLinkedClientTokenChange }: HostMenuProps) {
|
|
26
|
+
const { pairedHosts, removePairedHost, renamePairedHost } = useHostStore();
|
|
27
|
+
const { activeHost } = useHostConnection();
|
|
25
28
|
const navigate = useNavigate();
|
|
29
|
+
const location = useLocation();
|
|
30
|
+
const params = useParams<{ hostId?: string }>();
|
|
31
|
+
const activeHostId = params.hostId ?? null;
|
|
32
|
+
const activeClientToken = activeHost.clientToken || null;
|
|
33
|
+
const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
|
|
26
34
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
35
|
+
const [linkingBusy, setLinkingBusy] = useState(false);
|
|
36
|
+
|
|
37
|
+
async function makeThisLinkedDevice() {
|
|
38
|
+
if (!Device || !request || !activeClientToken) return;
|
|
39
|
+
setLinkingBusy(true);
|
|
40
|
+
try {
|
|
41
|
+
const { token: fcmToken } = await Device.getFcmToken();
|
|
42
|
+
if (!fcmToken) return;
|
|
43
|
+
await request("device.link", { fcmToken });
|
|
44
|
+
onLinkedClientTokenChange?.(activeClientToken);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error("Failed to make this the linked device:", err);
|
|
47
|
+
} finally {
|
|
48
|
+
setLinkingBusy(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleDelete(hostId: string) {
|
|
53
|
+
const wasActive = hostId === activeHostId;
|
|
54
|
+
// Only revoke against the currently-active host — `request` uses its client
|
|
55
|
+
// token. Non-active unpair is local-only; the host keeps a dangling token
|
|
56
|
+
// the user can clear via `palmier clients revoke`.
|
|
57
|
+
if (wasActive && request) {
|
|
58
|
+
try { await request("clients.revoke_self"); } catch { /* best effort */ }
|
|
59
|
+
}
|
|
60
|
+
removePairedHost(hostId);
|
|
61
|
+
setConfirmingDeleteId(null);
|
|
62
|
+
if (wasActive) navigate("/", { replace: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function switchHost(newHostId: string) {
|
|
66
|
+
const onTasks = location.pathname.endsWith("/tasks");
|
|
67
|
+
const tabSuffix = onTasks ? "/tasks" : "";
|
|
68
|
+
navigate(`/hosts/${encodeURIComponent(newHostId)}${tabSuffix}`);
|
|
69
|
+
}
|
|
27
70
|
|
|
28
71
|
const [visible, setVisible] = useState(false);
|
|
29
72
|
const [closing, setClosing] = useState(false);
|
|
30
73
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
31
74
|
const [renameValue, setRenameValue] = useState("");
|
|
32
75
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
|
|
76
|
+
const [confirmingLink, setConfirmingLink] = useState(false);
|
|
33
77
|
|
|
34
78
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
35
79
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -117,7 +161,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
117
161
|
if (isRenaming) return;
|
|
118
162
|
if (!isActive) {
|
|
119
163
|
if (!confirmLeaveDraft()) return;
|
|
120
|
-
|
|
164
|
+
switchHost(host.hostId);
|
|
121
165
|
if (!isDesktop) close();
|
|
122
166
|
}
|
|
123
167
|
}}
|
|
@@ -210,13 +254,29 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
210
254
|
<>
|
|
211
255
|
<div className="drawer-divider" />
|
|
212
256
|
<div className="drawer-section">
|
|
213
|
-
<h3 className="drawer-section-label">
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
257
|
+
<h3 className="drawer-section-label">Device Capabilities</h3>
|
|
258
|
+
{isLinkedDevice ? (
|
|
259
|
+
<CapabilityToggles onChange={onEnabledCapabilitiesChange} />
|
|
260
|
+
) : (
|
|
261
|
+
<>
|
|
262
|
+
<p className="drawer-section-hint">
|
|
263
|
+
This device isn't the linked device for this host, so it can't provide capabilities (SMS, contacts, location, etc.).
|
|
264
|
+
</p>
|
|
265
|
+
<button
|
|
266
|
+
className="btn btn-secondary btn-full"
|
|
267
|
+
onClick={() => {
|
|
268
|
+
if (linkedClientToken && linkedClientToken !== activeClientToken) {
|
|
269
|
+
setConfirmingLink(true);
|
|
270
|
+
} else {
|
|
271
|
+
makeThisLinkedDevice();
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
disabled={linkingBusy}
|
|
275
|
+
>
|
|
276
|
+
{linkingBusy ? "Linking…" : "Link this device"}
|
|
277
|
+
</button>
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
220
280
|
</div>
|
|
221
281
|
</>
|
|
222
282
|
)}
|
|
@@ -236,12 +296,16 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
236
296
|
</>
|
|
237
297
|
);
|
|
238
298
|
|
|
299
|
+
const deletingIsLinked = !!confirmingDeleteId && confirmingDeleteId === activeHostId && isLinkedDevice;
|
|
239
300
|
const deleteModal = confirmingDeleteId && createPortal(
|
|
240
301
|
<div className="confirm-modal-overlay" onClick={() => setConfirmingDeleteId(null)}>
|
|
241
302
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
242
|
-
<h2 className="confirm-modal-title">
|
|
303
|
+
<h2 className="confirm-modal-title">Unpair host?</h2>
|
|
243
304
|
<p className="confirm-modal-message">
|
|
244
|
-
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired
|
|
305
|
+
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired.
|
|
306
|
+
{deletingIsLinked && (
|
|
307
|
+
<> This device is currently linked to the host — unpairing will revoke its access to all device capabilities (SMS, contacts, location, etc.) until another device is linked.</>
|
|
308
|
+
)}
|
|
245
309
|
</p>
|
|
246
310
|
<div className="confirm-modal-actions">
|
|
247
311
|
<button
|
|
@@ -252,12 +316,35 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
252
316
|
</button>
|
|
253
317
|
<button
|
|
254
318
|
className="btn btn-danger"
|
|
255
|
-
onClick={() =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
319
|
+
onClick={() => handleDelete(confirmingDeleteId)}
|
|
320
|
+
>
|
|
321
|
+
Unpair
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>,
|
|
326
|
+
document.body
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const linkModal = confirmingLink && createPortal(
|
|
330
|
+
<div className="confirm-modal-overlay" onClick={() => setConfirmingLink(false)}>
|
|
331
|
+
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
332
|
+
<h2 className="confirm-modal-title">Link this device?</h2>
|
|
333
|
+
<p className="confirm-modal-message">
|
|
334
|
+
Only one device can be linked at a time — switching will disable those capabilities on the currently linked device.
|
|
335
|
+
</p>
|
|
336
|
+
<div className="confirm-modal-actions">
|
|
337
|
+
<button
|
|
338
|
+
className="btn btn-secondary"
|
|
339
|
+
onClick={() => setConfirmingLink(false)}
|
|
340
|
+
>
|
|
341
|
+
Cancel
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
className="btn btn-primary"
|
|
345
|
+
onClick={() => { setConfirmingLink(false); makeThisLinkedDevice(); }}
|
|
259
346
|
>
|
|
260
|
-
|
|
347
|
+
Link
|
|
261
348
|
</button>
|
|
262
349
|
</div>
|
|
263
350
|
</div>
|
|
@@ -273,6 +360,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
273
360
|
{drawerContent}
|
|
274
361
|
</div>
|
|
275
362
|
{deleteModal}
|
|
363
|
+
{linkModal}
|
|
276
364
|
</>
|
|
277
365
|
);
|
|
278
366
|
}
|
|
@@ -308,6 +396,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
308
396
|
)}
|
|
309
397
|
|
|
310
398
|
{deleteModal}
|
|
399
|
+
{linkModal}
|
|
311
400
|
</>
|
|
312
401
|
);
|
|
313
402
|
}
|
|
@@ -69,7 +69,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
69
69
|
|
|
70
70
|
if (result.error) {
|
|
71
71
|
console.error("No result:", result.error);
|
|
72
|
-
navigate("/
|
|
72
|
+
navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
75
|
setMessages(result.messages ?? []);
|
|
@@ -77,7 +77,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
77
77
|
setAgent(result.agent);
|
|
78
78
|
} catch (err) {
|
|
79
79
|
console.error("Failed to load result:", err);
|
|
80
|
-
navigate("/
|
|
80
|
+
navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
|
|
81
81
|
} finally {
|
|
82
82
|
setLoading(false);
|
|
83
83
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
3
|
+
import { useHostStore } from "../contexts/HostStoreContext";
|
|
3
4
|
import { setDraftMessage } from "../draftGuard";
|
|
4
5
|
import type { AgentInfo } from "../types";
|
|
5
6
|
|
|
@@ -9,17 +10,17 @@ interface SessionComposerProps {
|
|
|
9
10
|
onStarted(taskId: string, runId?: string): void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function pickDefaultAgent(agents: AgentInfo[]): string {
|
|
13
|
-
const stored = localStorage.getItem("palmier:lastAgent");
|
|
13
|
+
function pickDefaultAgent(agents: AgentInfo[], preferred?: string): string {
|
|
14
14
|
const keys = agents.map((a) => a.key);
|
|
15
|
-
if (
|
|
15
|
+
if (preferred && keys.includes(preferred)) return preferred;
|
|
16
16
|
return agents[0]?.key ?? "";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export default function SessionComposer({ agents, hostPlatform, onStarted }: SessionComposerProps) {
|
|
20
|
-
const { request } = useHostConnection();
|
|
20
|
+
const { request, activeHost } = useHostConnection();
|
|
21
|
+
const { setHostLastAgent } = useHostStore();
|
|
21
22
|
const [prompt, setPrompt] = useState("");
|
|
22
|
-
const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
|
|
23
|
+
const [agent, setAgent] = useState(() => pickDefaultAgent(agents, activeHost.lastAgent));
|
|
23
24
|
const [yoloMode, setYoloMode] = useState(false);
|
|
24
25
|
const [running, setRunning] = useState(false);
|
|
25
26
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -28,9 +29,9 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
|
|
|
28
29
|
useEffect(() => {
|
|
29
30
|
if (!agents.length) return;
|
|
30
31
|
if (!agents.find((a) => a.key === agent)) {
|
|
31
|
-
setAgent(pickDefaultAgent(agents));
|
|
32
|
+
setAgent(pickDefaultAgent(agents, activeHost.lastAgent));
|
|
32
33
|
}
|
|
33
|
-
}, [agents, agent]);
|
|
34
|
+
}, [agents, agent, activeHost.lastAgent]);
|
|
34
35
|
|
|
35
36
|
// Draft guard: warns on navigation / reload when the input has content.
|
|
36
37
|
useEffect(() => {
|
|
@@ -77,7 +78,7 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
|
|
|
77
78
|
setError(result.error);
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
setHostLastAgent(activeHost.hostId, agent);
|
|
81
82
|
setPrompt("");
|
|
82
83
|
setDraftMessage(null);
|
|
83
84
|
if (result.task_id) onStarted(result.task_id, result.run_id);
|
|
@@ -170,7 +170,8 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
|
|
|
170
170
|
|
|
171
171
|
function handleCardClick(taskId: string, runId: string) {
|
|
172
172
|
if (!confirmLeaveDraft()) return;
|
|
173
|
-
|
|
173
|
+
if (!hostId) return;
|
|
174
|
+
navigate(`/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const composer = !filterTaskId && (
|
|
@@ -178,8 +179,9 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
|
|
|
178
179
|
agents={agents}
|
|
179
180
|
hostPlatform={hostPlatform}
|
|
180
181
|
onStarted={(taskId, runId) => {
|
|
181
|
-
if (
|
|
182
|
-
|
|
182
|
+
if (!hostId) return;
|
|
183
|
+
const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
|
|
184
|
+
navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
|
|
183
185
|
}}
|
|
184
186
|
/>
|
|
185
187
|
);
|