palmier 0.6.6 → 0.6.7
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 +1 -1
- package/dist/agents/agent-instructions.md +28 -6
- package/dist/commands/plan-generation.md +1 -0
- package/dist/commands/run.js +3 -3
- package/dist/location-device.d.ts +8 -0
- package/dist/location-device.js +32 -0
- package/dist/mcp-handler.d.ts +8 -0
- package/dist/mcp-handler.js +110 -0
- package/dist/mcp-tools.d.ts +22 -0
- package/dist/mcp-tools.js +152 -0
- package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
- package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
- package/dist/pwa/assets/web-DQteXlI7.js +1 -0
- package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +20 -7
- package/dist/transports/http-transport.js +61 -129
- package/package.json +1 -1
- package/palmier-server/README.md +6 -1
- package/palmier-server/package.json +7 -1
- package/palmier-server/pnpm-lock.yaml +1025 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/package.json +3 -0
- package/palmier-server/pwa/src/App.css +55 -0
- package/palmier-server/pwa/src/api.ts +8 -2
- package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
- package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
- package/palmier-server/pwa/src/service-worker.ts +7 -7
- package/palmier-server/server/.env.example +4 -0
- package/palmier-server/server/package.json +1 -0
- package/palmier-server/server/src/db.ts +10 -0
- package/palmier-server/server/src/fcm.ts +74 -0
- package/palmier-server/server/src/index.ts +101 -21
- package/palmier-server/server/src/notify.ts +34 -0
- package/palmier-server/server/src/push.ts +1 -1
- package/palmier-server/server/src/routes/fcm.ts +64 -0
- package/palmier-server/server/src/routes/push.ts +6 -5
- package/palmier-server/spec.md +4 -2
- package/src/agents/agent-instructions.md +28 -6
- package/src/commands/plan-generation.md +1 -0
- package/src/commands/run.ts +3 -3
- package/src/location-device.ts +35 -0
- package/src/mcp-handler.ts +133 -0
- package/src/mcp-tools.ts +182 -0
- package/src/rpc-handler.ts +21 -7
- package/src/transports/http-transport.ts +58 -128
- package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
8
8
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
9
9
|
<title>Palmier</title>
|
|
10
|
-
<meta name="description" content="
|
|
10
|
+
<meta name="description" content="Remote control for AI agents running on your own machine. Schedule tasks, approve permissions, and get push notifications." />
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
|
@@ -1308,6 +1308,12 @@ body {
|
|
|
1308
1308
|
font-size: 1.125rem;
|
|
1309
1309
|
font-weight: 700;
|
|
1310
1310
|
letter-spacing: -0.02em;
|
|
1311
|
+
margin-bottom: var(--space-xs);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.confirm-modal-subtitle {
|
|
1315
|
+
font-size: 0.8rem;
|
|
1316
|
+
color: var(--color-muted);
|
|
1311
1317
|
margin-bottom: var(--space-sm);
|
|
1312
1318
|
}
|
|
1313
1319
|
|
|
@@ -1681,6 +1687,55 @@ body {
|
|
|
1681
1687
|
margin-bottom: var(--space-sm);
|
|
1682
1688
|
}
|
|
1683
1689
|
|
|
1690
|
+
.drawer-toggle {
|
|
1691
|
+
display: flex;
|
|
1692
|
+
align-items: center;
|
|
1693
|
+
justify-content: space-between;
|
|
1694
|
+
gap: var(--space-sm);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
.drawer-toggle-label {
|
|
1698
|
+
font-size: 0.85rem;
|
|
1699
|
+
color: var(--color-text);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.toggle-switch {
|
|
1703
|
+
position: relative;
|
|
1704
|
+
width: 40px;
|
|
1705
|
+
height: 22px;
|
|
1706
|
+
border-radius: 11px;
|
|
1707
|
+
border: none;
|
|
1708
|
+
background: var(--color-border);
|
|
1709
|
+
cursor: pointer;
|
|
1710
|
+
padding: 0;
|
|
1711
|
+
transition: background 0.2s;
|
|
1712
|
+
flex-shrink: 0;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
.toggle-switch-on {
|
|
1716
|
+
background: var(--color-primary);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.toggle-switch-thumb {
|
|
1720
|
+
position: absolute;
|
|
1721
|
+
top: 2px;
|
|
1722
|
+
left: 2px;
|
|
1723
|
+
width: 18px;
|
|
1724
|
+
height: 18px;
|
|
1725
|
+
border-radius: 50%;
|
|
1726
|
+
background: white;
|
|
1727
|
+
transition: transform 0.2s;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
.toggle-switch-on .toggle-switch-thumb {
|
|
1731
|
+
transform: translateX(18px);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
.toggle-switch:disabled {
|
|
1735
|
+
opacity: 0.5;
|
|
1736
|
+
cursor: not-allowed;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1684
1739
|
.drawer-footer {
|
|
1685
1740
|
margin-top: auto;
|
|
1686
1741
|
padding: var(--space-md);
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { Capacitor } from "@capacitor/core";
|
|
2
|
+
|
|
3
|
+
/** On native platforms, API calls go to the production server. On web, they're relative (same-origin). */
|
|
4
|
+
export const SERVER_URL = Capacitor.isNativePlatform() ? "https://app.palmier.me" : "";
|
|
5
|
+
|
|
1
6
|
async function request<T>(
|
|
2
7
|
method: string,
|
|
3
8
|
path: string,
|
|
4
9
|
body?: unknown,
|
|
5
10
|
token?: string
|
|
6
11
|
): Promise<T> {
|
|
7
|
-
|
|
12
|
+
const url = `${SERVER_URL}${path}`;
|
|
13
|
+
console.log(`[API] ${method} ${url}`);
|
|
8
14
|
const headers: Record<string, string> = {
|
|
9
15
|
"Content-Type": "application/json",
|
|
10
16
|
};
|
|
11
17
|
if (token) {
|
|
12
18
|
headers["Authorization"] = `Bearer ${token}`;
|
|
13
19
|
}
|
|
14
|
-
const res = await fetch(
|
|
20
|
+
const res = await fetch(url, {
|
|
15
21
|
method,
|
|
16
22
|
headers,
|
|
17
23
|
body: body != null ? JSON.stringify(body) : undefined,
|
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useNavigate } from "react-router-dom";
|
|
4
|
+
import { Capacitor, registerPlugin } from "@capacitor/core";
|
|
5
|
+
import { App as CapApp } from "@capacitor/app";
|
|
6
|
+
import { Preferences } from "@capacitor/preferences";
|
|
7
|
+
|
|
8
|
+
interface LocationPermissionResult {
|
|
9
|
+
fine: boolean;
|
|
10
|
+
background: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface LocationPermissionPlugin {
|
|
14
|
+
request(): Promise<LocationPermissionResult>;
|
|
15
|
+
check(): Promise<LocationPermissionResult>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LocationPermission = Capacitor.isNativePlatform()
|
|
19
|
+
? registerPlugin<LocationPermissionPlugin>("LocationPermission")
|
|
20
|
+
: null;
|
|
4
21
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
5
22
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
6
23
|
|
|
7
24
|
/** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
|
|
8
25
|
const isLanMode = !!(window as any).__PALMIER_SERVE__;
|
|
26
|
+
const isNative = Capacitor.isNativePlatform();
|
|
9
27
|
|
|
10
28
|
interface HostMenuProps {
|
|
11
29
|
daemonVersion?: string | null;
|
|
30
|
+
locationClientToken?: string | null;
|
|
31
|
+
activeClientToken?: string | null;
|
|
32
|
+
request?<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
33
|
+
onLocationClientTokenChange?(token: string | null): void;
|
|
12
34
|
}
|
|
13
35
|
|
|
14
|
-
export default function HostMenu({ daemonVersion }: HostMenuProps) {
|
|
36
|
+
export default function HostMenu({ daemonVersion, locationClientToken, activeClientToken, request, onLocationClientTokenChange }: HostMenuProps) {
|
|
15
37
|
const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
|
|
16
38
|
const navigate = useNavigate();
|
|
17
39
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
@@ -21,6 +43,65 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
|
|
|
21
43
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
22
44
|
const [renameValue, setRenameValue] = useState("");
|
|
23
45
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
|
|
46
|
+
const [togglingLocation, setTogglingLocation] = useState(false);
|
|
47
|
+
|
|
48
|
+
const locationEnabled = !!(activeClientToken && locationClientToken === activeClientToken);
|
|
49
|
+
|
|
50
|
+
// Sync location toggle with permission state — on mount and when app resumes from background
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isNative || !LocationPermission || !request) return;
|
|
53
|
+
|
|
54
|
+
function syncPermissionState() {
|
|
55
|
+
if (!locationEnabled) return;
|
|
56
|
+
LocationPermission!.check().then(({ fine }) => {
|
|
57
|
+
if (!fine) {
|
|
58
|
+
// Permission revoked — disable on host
|
|
59
|
+
request!("device.location.disable").then(() => {
|
|
60
|
+
onLocationClientTokenChange?.(null);
|
|
61
|
+
}).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
syncPermissionState();
|
|
67
|
+
|
|
68
|
+
const listener = CapApp.addListener("resume", () => {
|
|
69
|
+
syncPermissionState();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return () => { listener.then((h) => h.remove()); };
|
|
73
|
+
}, [locationEnabled, activeClientToken]);
|
|
74
|
+
|
|
75
|
+
async function handleLocationToggle() {
|
|
76
|
+
if (!request) return;
|
|
77
|
+
setTogglingLocation(true);
|
|
78
|
+
try {
|
|
79
|
+
if (locationEnabled) {
|
|
80
|
+
await request("device.location.disable");
|
|
81
|
+
onLocationClientTokenChange?.(null);
|
|
82
|
+
} else {
|
|
83
|
+
// Request location permissions before enabling
|
|
84
|
+
if (LocationPermission) {
|
|
85
|
+
const result = await LocationPermission.request();
|
|
86
|
+
if (!result.fine) {
|
|
87
|
+
return; // User denied permission
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
92
|
+
if (!fcmToken) {
|
|
93
|
+
console.warn("No FCM token available");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await request("device.location.enable", { fcmToken });
|
|
97
|
+
onLocationClientTokenChange?.(activeClientToken ?? null);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error("Failed to toggle location:", err);
|
|
101
|
+
} finally {
|
|
102
|
+
setTogglingLocation(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
24
105
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
25
106
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
26
107
|
|
|
@@ -197,6 +278,26 @@ export default function HostMenu({ daemonVersion }: HostMenuProps) {
|
|
|
197
278
|
</div>
|
|
198
279
|
</>)}
|
|
199
280
|
|
|
281
|
+
{isNative && (
|
|
282
|
+
<>
|
|
283
|
+
<div className="drawer-divider" />
|
|
284
|
+
<div className="drawer-section">
|
|
285
|
+
<label className="drawer-toggle">
|
|
286
|
+
<span className="drawer-toggle-label">Location Access</span>
|
|
287
|
+
<button
|
|
288
|
+
className={`toggle-switch ${locationEnabled ? "toggle-switch-on" : ""}`}
|
|
289
|
+
onClick={handleLocationToggle}
|
|
290
|
+
disabled={togglingLocation}
|
|
291
|
+
role="switch"
|
|
292
|
+
aria-checked={locationEnabled}
|
|
293
|
+
>
|
|
294
|
+
<span className="toggle-switch-thumb" />
|
|
295
|
+
</button>
|
|
296
|
+
</label>
|
|
297
|
+
</div>
|
|
298
|
+
</>
|
|
299
|
+
)}
|
|
300
|
+
|
|
200
301
|
<div className="drawer-footer">
|
|
201
302
|
{daemonVersion && (
|
|
202
303
|
<div className="drawer-version">
|
|
@@ -26,16 +26,17 @@ interface TaskListViewProps {
|
|
|
26
26
|
onViewRun(taskId: string, runId?: string): void;
|
|
27
27
|
onUpdateRequired?(required: boolean): void;
|
|
28
28
|
onVersion?(version: string | null): void;
|
|
29
|
+
onLocationClientToken?(token: string | null): void;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion }: TaskListViewProps) {
|
|
32
|
+
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onLocationClientToken }: TaskListViewProps) {
|
|
32
33
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
33
34
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
34
35
|
const [taskError, setTaskError] = useState<string | null>(null);
|
|
35
36
|
const [taskEvents, setTaskStatuss] = useState<Map<string, TaskStatus>>(new Map());
|
|
36
|
-
const [pendingConfirms, setPendingConfirms] = useState<
|
|
37
|
+
const [pendingConfirms, setPendingConfirms] = useState<Map<string, { description: string; agentName?: string }>>(new Map());
|
|
37
38
|
const [pendingPermissions, setPendingPermissions] = useState<Map<string, RequiredPermission[]>>(new Map());
|
|
38
|
-
const [pendingInputs, setPendingInputs] = useState<Map<string, string[]>>(new Map());
|
|
39
|
+
const [pendingInputs, setPendingInputs] = useState<Map<string, { questions: string[]; description?: string; agentName?: string }>>(new Map());
|
|
39
40
|
const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
|
|
40
41
|
|
|
41
42
|
const [showForm, setShowForm] = useState(false);
|
|
@@ -52,20 +53,20 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
52
53
|
setLoadingTasks(true);
|
|
53
54
|
setTaskError(null);
|
|
54
55
|
try {
|
|
55
|
-
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string }>("task.list");
|
|
56
|
+
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null }>("task.list");
|
|
56
57
|
const taskList = result.tasks ?? [];
|
|
57
58
|
const initialEvents = new Map<string, TaskStatus>();
|
|
58
|
-
const initialConfirms = new
|
|
59
|
+
const initialConfirms = new Map<string, { description: string; agentName?: string }>();
|
|
59
60
|
const initialPerms = new Map<string, RequiredPermission[]>();
|
|
60
|
-
const initialInputs = new Map<string, string[]>();
|
|
61
|
+
const initialInputs = new Map<string, { questions: string[]; description?: string; agentName?: string }>();
|
|
61
62
|
const initialInputVals = new Map<string, string[]>();
|
|
62
63
|
for (const t of taskList) {
|
|
63
64
|
if (t.status) {
|
|
64
65
|
initialEvents.set(t.id, t.status);
|
|
65
|
-
|
|
66
|
+
// pending_confirmation no longer comes from task.list (confirmation is sessionId-based now)
|
|
66
67
|
if (t.status.pending_permission?.length) initialPerms.set(t.id, t.status.pending_permission);
|
|
67
68
|
if (t.status.pending_input?.length) {
|
|
68
|
-
initialInputs.set(t.id, t.status.pending_input);
|
|
69
|
+
initialInputs.set(t.id, { questions: t.status.pending_input });
|
|
69
70
|
initialInputVals.set(t.id, new Array(t.status.pending_input.length).fill(""));
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -81,6 +82,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
81
82
|
setAgentLabels(result.agents ?? []);
|
|
82
83
|
const version = result.version ?? null;
|
|
83
84
|
onVersion?.(version);
|
|
85
|
+
onLocationClientToken?.(result.location_client_token ?? null);
|
|
84
86
|
onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
85
87
|
} catch (err) {
|
|
86
88
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -109,43 +111,81 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
109
111
|
if (tokens.length < 3) return;
|
|
110
112
|
const taskId = tokens.slice(2).join(".");
|
|
111
113
|
|
|
112
|
-
let
|
|
114
|
+
let parsed: Record<string, unknown> = {};
|
|
113
115
|
try {
|
|
114
|
-
|
|
115
|
-
eventType = parsed.event_type;
|
|
116
|
+
parsed = JSON.parse(sc.decode(msg.data)) as Record<string, unknown>;
|
|
116
117
|
} catch { return; }
|
|
118
|
+
const eventType = parsed.event_type as string | undefined;
|
|
119
|
+
const sessionId = parsed.session_id as string | undefined;
|
|
117
120
|
|
|
118
|
-
// Handle
|
|
119
|
-
if (eventType === "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
// Handle input-request: show standalone input dialog (keyed by sessionId)
|
|
122
|
+
if (eventType === "input-request" && sessionId) {
|
|
123
|
+
const questions = parsed.input_questions as string[];
|
|
124
|
+
const agentName = parsed.agent_name as string | undefined;
|
|
125
|
+
const description = parsed.description as string | undefined;
|
|
126
|
+
if (questions?.length) {
|
|
127
|
+
setPendingInputs((prev) => {
|
|
128
|
+
if (prev.has(sessionId)) return prev;
|
|
129
|
+
const next = new Map(prev);
|
|
130
|
+
next.set(sessionId, { questions, description, agentName });
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
setInputValues((prev) => {
|
|
134
|
+
if (prev.has(sessionId)) return prev;
|
|
135
|
+
const next = new Map(prev);
|
|
136
|
+
next.set(sessionId, new Array(questions.length).fill(""));
|
|
137
|
+
return next;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
126
140
|
return;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
// Handle
|
|
130
|
-
if (eventType === "
|
|
131
|
-
|
|
132
|
-
if (!prev.has(
|
|
143
|
+
// Handle input-resolved: close pending input dialog
|
|
144
|
+
if (eventType === "input-resolved" && sessionId) {
|
|
145
|
+
setPendingInputs((prev) => {
|
|
146
|
+
if (!prev.has(sessionId)) return prev;
|
|
133
147
|
const next = new Map(prev);
|
|
134
|
-
next.delete(
|
|
148
|
+
next.delete(sessionId);
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
setInputValues((prev) => {
|
|
152
|
+
const next = new Map(prev);
|
|
153
|
+
next.delete(sessionId);
|
|
135
154
|
return next;
|
|
136
155
|
});
|
|
137
156
|
return;
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
// Handle
|
|
141
|
-
if (eventType === "
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
// Handle confirm-request: show standalone confirmation dialog (keyed by sessionId)
|
|
160
|
+
if (eventType === "confirm-request" && sessionId) {
|
|
161
|
+
const description = parsed.description as string;
|
|
162
|
+
const agentName = parsed.agent_name as string | undefined;
|
|
163
|
+
if (description) {
|
|
164
|
+
setPendingConfirms((prev) => {
|
|
165
|
+
if (prev.has(sessionId)) return prev;
|
|
166
|
+
const next = new Map(prev);
|
|
167
|
+
next.set(sessionId, { description, agentName });
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Handle confirm-resolved: close pending confirmation dialog
|
|
175
|
+
if (eventType === "confirm-resolved" && sessionId) {
|
|
176
|
+
setPendingConfirms((prev) => {
|
|
177
|
+
if (!prev.has(sessionId)) return prev;
|
|
144
178
|
const next = new Map(prev);
|
|
145
|
-
next.delete(
|
|
179
|
+
next.delete(sessionId);
|
|
146
180
|
return next;
|
|
147
181
|
});
|
|
148
|
-
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle permission-resolved: close pending permission dialog
|
|
186
|
+
if (eventType === "permission-resolved") {
|
|
187
|
+
setPendingPermissions((prev) => {
|
|
188
|
+
if (!prev.has(taskId)) return prev;
|
|
149
189
|
const next = new Map(prev);
|
|
150
190
|
next.delete(taskId);
|
|
151
191
|
return next;
|
|
@@ -157,14 +197,6 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
157
197
|
const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
|
|
158
198
|
if (status.error) return;
|
|
159
199
|
setTaskStatuss((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
|
|
160
|
-
if (status.pending_confirmation) {
|
|
161
|
-
setPendingConfirms((prev) => {
|
|
162
|
-
if (prev.has(taskId)) return prev;
|
|
163
|
-
const next = new Set(prev);
|
|
164
|
-
next.add(taskId);
|
|
165
|
-
return next;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
200
|
if (status.pending_permission?.length) {
|
|
169
201
|
setPendingPermissions((prev) => {
|
|
170
202
|
if (prev.has(taskId)) return prev;
|
|
@@ -173,28 +205,14 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
173
205
|
return next;
|
|
174
206
|
});
|
|
175
207
|
}
|
|
176
|
-
if (status.pending_input?.length) {
|
|
177
|
-
setPendingInputs((prev) => {
|
|
178
|
-
if (prev.has(taskId)) return prev;
|
|
179
|
-
const next = new Map(prev);
|
|
180
|
-
next.set(taskId, status.pending_input!);
|
|
181
|
-
return next;
|
|
182
|
-
});
|
|
183
|
-
setInputValues((prev) => {
|
|
184
|
-
if (prev.has(taskId)) return prev;
|
|
185
|
-
const next = new Map(prev);
|
|
186
|
-
next.set(taskId, new Array(status.pending_input!.length).fill(""));
|
|
187
|
-
return next;
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
208
|
} catch { /* skip */ }
|
|
191
209
|
});
|
|
192
210
|
return unsubscribe;
|
|
193
211
|
}, [connected, hostId, subscribeEvents, request]);
|
|
194
212
|
|
|
195
|
-
async function respondToConfirm(
|
|
213
|
+
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
196
214
|
try {
|
|
197
|
-
await request("task.user_input", { id:
|
|
215
|
+
await request("task.user_input", { id: sessionId, value: [response] });
|
|
198
216
|
} catch (err) {
|
|
199
217
|
console.error("[TaskListView] Failed to respond to confirmation:", err);
|
|
200
218
|
}
|
|
@@ -208,9 +226,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
208
226
|
}
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
async function respondToInput(
|
|
229
|
+
async function respondToInput(sessionId: string, values: string[]) {
|
|
212
230
|
try {
|
|
213
|
-
await request("task.user_input", { id:
|
|
231
|
+
await request("task.user_input", { id: sessionId, value: values });
|
|
214
232
|
} catch (err) {
|
|
215
233
|
console.error("[TaskListView] Failed to respond to input request:", err);
|
|
216
234
|
}
|
|
@@ -300,20 +318,19 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
300
318
|
)}
|
|
301
319
|
|
|
302
320
|
{createPortal(<>
|
|
303
|
-
{[...pendingConfirms].map((
|
|
304
|
-
const
|
|
321
|
+
{[...pendingConfirms.entries()].map(([sessionId, { description, agentName }]) => {
|
|
322
|
+
const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
|
|
305
323
|
return (
|
|
306
|
-
<div key={
|
|
324
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
307
325
|
<div className="confirm-modal">
|
|
308
|
-
<h2 className="confirm-modal-title">
|
|
309
|
-
<p className="confirm-modal-
|
|
310
|
-
|
|
311
|
-
</p>
|
|
326
|
+
<h2 className="confirm-modal-title">Confirmation Required</h2>
|
|
327
|
+
{subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
|
|
328
|
+
<p className="confirm-modal-message">{description}</p>
|
|
312
329
|
<div className="confirm-modal-actions">
|
|
313
|
-
<button className="btn btn-primary" onClick={() => respondToConfirm(
|
|
330
|
+
<button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
|
|
314
331
|
Confirm
|
|
315
332
|
</button>
|
|
316
|
-
<button className="btn btn-secondary" onClick={() => respondToConfirm(
|
|
333
|
+
<button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
|
|
317
334
|
Abort
|
|
318
335
|
</button>
|
|
319
336
|
</div>
|
|
@@ -358,18 +375,17 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
358
375
|
);
|
|
359
376
|
})}
|
|
360
377
|
|
|
361
|
-
{[...pendingInputs.entries()].map(([
|
|
362
|
-
const
|
|
363
|
-
const
|
|
378
|
+
{[...pendingInputs.entries()].map(([sessionId, { questions, description, agentName }]) => {
|
|
379
|
+
const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
|
|
380
|
+
const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
|
|
364
381
|
return (
|
|
365
|
-
<div key={
|
|
382
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
366
383
|
<div className="confirm-modal input-modal">
|
|
367
384
|
<h2 className="confirm-modal-title">Input Required</h2>
|
|
368
|
-
<p className="confirm-modal-
|
|
369
|
-
|
|
370
|
-
</p>
|
|
385
|
+
{subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
|
|
386
|
+
{description && <p className="confirm-modal-message">{description}</p>}
|
|
371
387
|
<div className="input-list">
|
|
372
|
-
{
|
|
388
|
+
{questions.map((desc: string, i: number) => (
|
|
373
389
|
<div key={i} className="input-item">
|
|
374
390
|
<label className="input-label">{desc}</label>
|
|
375
391
|
<input
|
|
@@ -379,9 +395,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
379
395
|
onChange={(e) => {
|
|
380
396
|
setInputValues((prev) => {
|
|
381
397
|
const next = new Map(prev);
|
|
382
|
-
const arr = [...(next.get(
|
|
398
|
+
const arr = [...(next.get(sessionId) ?? [])];
|
|
383
399
|
arr[i] = e.target.value;
|
|
384
|
-
next.set(
|
|
400
|
+
next.set(sessionId, arr);
|
|
385
401
|
return next;
|
|
386
402
|
});
|
|
387
403
|
}}
|
|
@@ -394,16 +410,16 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
394
410
|
<button
|
|
395
411
|
className="btn btn-primary"
|
|
396
412
|
disabled={values.some((v) => !v.trim())}
|
|
397
|
-
onClick={() => respondToInput(
|
|
413
|
+
onClick={() => respondToInput(sessionId, values)}
|
|
398
414
|
>
|
|
399
415
|
Submit
|
|
400
416
|
</button>
|
|
401
417
|
</div>
|
|
402
418
|
<button
|
|
403
419
|
className="permission-abort-link"
|
|
404
|
-
onClick={() => respondToInput(
|
|
420
|
+
onClick={() => respondToInput(sessionId, ["aborted"])}
|
|
405
421
|
>
|
|
406
|
-
Cancel
|
|
422
|
+
Cancel
|
|
407
423
|
</button>
|
|
408
424
|
</div>
|
|
409
425
|
</div>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
|
+
import { SERVER_URL } from "../api";
|
|
11
12
|
import { useHostStore } from "./HostStoreContext";
|
|
12
13
|
import type { PairedHost } from "../types";
|
|
13
14
|
|
|
@@ -89,7 +90,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
89
90
|
|
|
90
91
|
async function init() {
|
|
91
92
|
try {
|
|
92
|
-
const res = await fetch(
|
|
93
|
+
const res = await fetch(`${SERVER_URL}/api/config`);
|
|
93
94
|
if (!res.ok) {
|
|
94
95
|
console.error("[NATS] Failed to fetch config");
|
|
95
96
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Capacitor } from "@capacitor/core";
|
|
2
3
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
3
4
|
import { apiPost, apiGet } from "../api";
|
|
4
5
|
|
|
@@ -8,6 +9,8 @@ export function usePushSubscription() {
|
|
|
8
9
|
const subscribedRef = useRef<string | null>(null);
|
|
9
10
|
|
|
10
11
|
useEffect(() => {
|
|
12
|
+
// Native app uses FCM for notifications, not Web Push
|
|
13
|
+
if (Capacitor.isNativePlatform()) return;
|
|
11
14
|
// Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
|
|
12
15
|
if (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
|
|
13
16
|
|
|
@@ -47,6 +47,7 @@ export default function Dashboard() {
|
|
|
47
47
|
const [updating, setUpdating] = useState(false);
|
|
48
48
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
49
49
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
50
|
+
const [locationClientToken, setLocationClientToken] = useState<string | null>(null);
|
|
50
51
|
|
|
51
52
|
// Register push subscription for the active host
|
|
52
53
|
usePushSubscription();
|
|
@@ -83,14 +84,15 @@ export default function Dashboard() {
|
|
|
83
84
|
|
|
84
85
|
const hasHosts = pairedHosts.length > 0;
|
|
85
86
|
const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
|
|
87
|
+
const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
|
|
86
88
|
|
|
87
89
|
return (
|
|
88
90
|
<div className="dashboard">
|
|
89
|
-
{isDesktop && <HostMenu daemonVersion={daemonVersion} />}
|
|
91
|
+
{isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
|
|
90
92
|
|
|
91
93
|
<div className="dashboard-content">
|
|
92
94
|
<div className="tab-bar">
|
|
93
|
-
{!isDesktop && <HostMenu daemonVersion={daemonVersion} />}
|
|
95
|
+
{!isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
|
|
94
96
|
<TabBar />
|
|
95
97
|
</div>
|
|
96
98
|
|
|
@@ -139,6 +141,7 @@ export default function Dashboard() {
|
|
|
139
141
|
onViewRun={handleViewRun}
|
|
140
142
|
onUpdateRequired={setUpdateRequired}
|
|
141
143
|
onVersion={setDaemonVersion}
|
|
144
|
+
onLocationClientToken={setLocationClientToken}
|
|
142
145
|
/>
|
|
143
146
|
</div>
|
|
144
147
|
{isRunDetail ? (
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
import { connect, StringCodec } from "nats.ws";
|
|
4
|
+
import { Capacitor } from "@capacitor/core";
|
|
5
|
+
import { Preferences } from "@capacitor/preferences";
|
|
4
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
7
|
+
import { SERVER_URL } from "../api";
|
|
5
8
|
import type { PairedHost } from "../types";
|
|
6
9
|
|
|
7
10
|
interface PairResponse {
|
|
@@ -49,7 +52,7 @@ export default function PairHost() {
|
|
|
49
52
|
response = await res.json() as PairResponse;
|
|
50
53
|
} else {
|
|
51
54
|
// Server mode — pair via NATS
|
|
52
|
-
const configRes = await fetch(
|
|
55
|
+
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
53
56
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
54
57
|
const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
|
|
55
58
|
if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
|
|
@@ -78,6 +81,12 @@ export default function PairHost() {
|
|
|
78
81
|
};
|
|
79
82
|
|
|
80
83
|
addPairedHost(host);
|
|
84
|
+
|
|
85
|
+
// Write hostId to native SharedPreferences for FCM token registration
|
|
86
|
+
if (Capacitor.isNativePlatform()) {
|
|
87
|
+
await Preferences.set({ key: "hostId", value: response.hostId });
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
navigate("/");
|
|
82
91
|
} catch (err) {
|
|
83
92
|
const message = err instanceof Error ? err.message : String(err);
|