palmier 0.7.6 → 0.7.8
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/dist/agents/agent.d.ts +3 -0
- package/dist/agents/agent.js +1 -1
- package/dist/agents/aider.d.ts +1 -0
- package/dist/agents/aider.js +1 -0
- package/dist/agents/claude.d.ts +1 -0
- package/dist/agents/claude.js +1 -0
- package/dist/agents/cline.d.ts +1 -0
- package/dist/agents/cline.js +1 -0
- package/dist/agents/codex.d.ts +1 -0
- package/dist/agents/codex.js +1 -0
- package/dist/agents/copilot.d.ts +1 -0
- package/dist/agents/copilot.js +1 -0
- package/dist/agents/cursor.d.ts +1 -0
- package/dist/agents/cursor.js +1 -0
- package/dist/agents/deepagents.d.ts +1 -0
- package/dist/agents/deepagents.js +1 -0
- package/dist/agents/droid.d.ts +1 -0
- package/dist/agents/droid.js +1 -0
- package/dist/agents/gemini.d.ts +1 -0
- package/dist/agents/gemini.js +1 -0
- package/dist/agents/goose.d.ts +1 -0
- package/dist/agents/goose.js +1 -0
- package/dist/agents/hermes.d.ts +1 -0
- package/dist/agents/hermes.js +1 -0
- package/dist/agents/kimi.d.ts +1 -0
- package/dist/agents/kimi.js +1 -0
- package/dist/agents/kiro.d.ts +1 -0
- package/dist/agents/kiro.js +1 -0
- package/dist/agents/openclaw.d.ts +1 -0
- package/dist/agents/openclaw.js +2 -2
- package/dist/agents/opencode.d.ts +1 -0
- package/dist/agents/opencode.js +1 -0
- package/dist/agents/qoder.d.ts +1 -0
- package/dist/agents/qoder.js +1 -0
- package/dist/agents/qwen.d.ts +1 -0
- package/dist/agents/qwen.js +1 -0
- package/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +3 -3
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +79 -7
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- package/dist/pending-requests.d.ts +30 -8
- package/dist/pending-requests.js +28 -15
- package/dist/pwa/assets/index-8cTctVnD.js +120 -0
- package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
- package/dist/pwa/assets/{web-DnuoxUd4.js → web-BNr628AV.js} +1 -1
- package/dist/pwa/assets/{web-7raT3zOZ.js → web-DyQPewAi.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +12 -16
- package/dist/transports/http-transport.js +6 -3
- package/dist/types.d.ts +4 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- package/palmier-server/pwa/src/App.css +66 -0
- package/palmier-server/pwa/src/App.tsx +1 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +65 -2
- package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
- package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
- package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
- package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/pwa/src/types.ts +1 -6
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +28 -14
- package/src/agents/agent.ts +5 -1
- package/src/agents/aider.ts +1 -0
- package/src/agents/claude.ts +1 -0
- package/src/agents/cline.ts +1 -0
- package/src/agents/codex.ts +1 -0
- package/src/agents/copilot.ts +1 -0
- package/src/agents/cursor.ts +1 -0
- package/src/agents/deepagents.ts +1 -0
- package/src/agents/droid.ts +1 -0
- package/src/agents/gemini.ts +1 -0
- package/src/agents/goose.ts +1 -0
- package/src/agents/hermes.ts +1 -0
- package/src/agents/kimi.ts +1 -0
- package/src/agents/kiro.ts +1 -0
- package/src/agents/openclaw.ts +2 -2
- package/src/agents/opencode.ts +1 -0
- package/src/agents/qoder.ts +1 -0
- package/src/agents/qwen.ts +1 -0
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +3 -3
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +83 -7
- package/src/nats-client.ts +10 -3
- package/src/pending-requests.ts +47 -15
- package/src/rpc-handler.ts +13 -16
- package/src/transports/http-transport.ts +6 -3
- package/src/types.ts +4 -3
- package/test/agent-instructions.test.ts +10 -10
- package/test/pairing.test.ts +2 -2
- package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
- package/dist/pwa/assets/index-uSwkmHBs.js +0 -118
|
@@ -64,6 +64,15 @@ interface DndAccessPlugin {
|
|
|
64
64
|
check(): Promise<DndAccessResult>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface FullScreenIntentResult {
|
|
68
|
+
granted: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface FullScreenIntentPlugin {
|
|
72
|
+
request(): Promise<FullScreenIntentResult>;
|
|
73
|
+
check(): Promise<FullScreenIntentResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
const NotificationListener = Capacitor.isNativePlatform()
|
|
68
77
|
? registerPlugin<NotificationListenerPlugin>("NotificationListener")
|
|
69
78
|
: null;
|
|
@@ -83,8 +92,13 @@ const CalendarPermission = Capacitor.isNativePlatform()
|
|
|
83
92
|
const DndAccess = Capacitor.isNativePlatform()
|
|
84
93
|
? registerPlugin<DndAccessPlugin>("DndAccess")
|
|
85
94
|
: null;
|
|
95
|
+
|
|
96
|
+
const FullScreenIntent = Capacitor.isNativePlatform()
|
|
97
|
+
? registerPlugin<FullScreenIntentPlugin>("FullScreenIntent")
|
|
98
|
+
: null;
|
|
86
99
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
87
100
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
101
|
+
import { confirmLeaveDraft } from "../draftGuard";
|
|
88
102
|
|
|
89
103
|
/** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
|
|
90
104
|
const isLanMode = !!(window as any).__PALMIER_SERVE__;
|
|
@@ -116,6 +130,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
116
130
|
const [togglingDnd, setTogglingDnd] = useState(false);
|
|
117
131
|
const [togglingAlarm, setTogglingAlarm] = useState(false);
|
|
118
132
|
const [togglingBattery, setTogglingBattery] = useState(false);
|
|
133
|
+
const [togglingEmail, setTogglingEmail] = useState(false);
|
|
119
134
|
|
|
120
135
|
// Capability enabled = this device's client token matches the registered device for that capability
|
|
121
136
|
function isCapEnabled(cap: string): boolean {
|
|
@@ -129,6 +144,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
129
144
|
const dndEnabled = isCapEnabled("dnd");
|
|
130
145
|
const alarmEnabled = isCapEnabled("alert");
|
|
131
146
|
const batteryEnabled = isCapEnabled("battery");
|
|
147
|
+
const emailEnabled = isCapEnabled("email");
|
|
132
148
|
|
|
133
149
|
/** Update local capability tokens state after a toggle change */
|
|
134
150
|
function setCapEnabled(cap: string, enabled: boolean) {
|
|
@@ -296,6 +312,15 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
296
312
|
}
|
|
297
313
|
}
|
|
298
314
|
|
|
315
|
+
/** Ensure full-screen intent permission is granted (needed for alert + email). */
|
|
316
|
+
async function ensureFullScreenIntent(): Promise<boolean> {
|
|
317
|
+
if (!FullScreenIntent) return true;
|
|
318
|
+
const { granted } = await FullScreenIntent.check();
|
|
319
|
+
if (granted) return true;
|
|
320
|
+
const result = await FullScreenIntent.request();
|
|
321
|
+
return result.granted;
|
|
322
|
+
}
|
|
323
|
+
|
|
299
324
|
async function handleAlarmToggle() {
|
|
300
325
|
if (!request) return;
|
|
301
326
|
setTogglingAlarm(true);
|
|
@@ -304,6 +329,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
304
329
|
await request("device.capability.disable", { capability: "alert" });
|
|
305
330
|
setCapEnabled("alert", false);
|
|
306
331
|
} else {
|
|
332
|
+
if (!await ensureFullScreenIntent()) return;
|
|
307
333
|
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
308
334
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
309
335
|
await request("device.capability.enable", { capability: "alert", fcmToken });
|
|
@@ -316,6 +342,27 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
316
342
|
}
|
|
317
343
|
}
|
|
318
344
|
|
|
345
|
+
async function handleEmailToggle() {
|
|
346
|
+
if (!request) return;
|
|
347
|
+
setTogglingEmail(true);
|
|
348
|
+
try {
|
|
349
|
+
if (emailEnabled) {
|
|
350
|
+
await request("device.capability.disable", { capability: "email" });
|
|
351
|
+
setCapEnabled("email", false);
|
|
352
|
+
} else {
|
|
353
|
+
if (!await ensureFullScreenIntent()) return;
|
|
354
|
+
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
355
|
+
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
356
|
+
await request("device.capability.enable", { capability: "email", fcmToken });
|
|
357
|
+
setCapEnabled("email", true);
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
console.error("Failed to toggle email access:", err);
|
|
361
|
+
} finally {
|
|
362
|
+
setTogglingEmail(false);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
319
366
|
async function handleBatteryToggle() {
|
|
320
367
|
if (!request) return;
|
|
321
368
|
setTogglingBattery(true);
|
|
@@ -454,7 +501,11 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
454
501
|
className={`host-picker-item ${isActive ? "host-picker-item-active" : ""}`}
|
|
455
502
|
onClick={() => {
|
|
456
503
|
if (isRenaming) return;
|
|
457
|
-
if (!isActive) {
|
|
504
|
+
if (!isActive) {
|
|
505
|
+
if (!confirmLeaveDraft()) return;
|
|
506
|
+
setActiveHostId(host.hostId);
|
|
507
|
+
if (!isDesktop) close();
|
|
508
|
+
}
|
|
458
509
|
}}
|
|
459
510
|
role="option"
|
|
460
511
|
aria-selected={isActive}
|
|
@@ -534,7 +585,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
534
585
|
<div className="drawer-section">
|
|
535
586
|
<button
|
|
536
587
|
className="btn btn-primary btn-full"
|
|
537
|
-
onClick={() => { navigate("/pair"); if (!isDesktop) close(); }}
|
|
588
|
+
onClick={() => { if (!confirmLeaveDraft()) return; navigate("/pair"); if (!isDesktop) close(); }}
|
|
538
589
|
>
|
|
539
590
|
Pair New Host
|
|
540
591
|
</button>
|
|
@@ -641,6 +692,18 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
641
692
|
<span className="toggle-switch-thumb" />
|
|
642
693
|
</button>
|
|
643
694
|
</label>
|
|
695
|
+
<label className="drawer-toggle">
|
|
696
|
+
<span className="drawer-toggle-label">Email Access</span>
|
|
697
|
+
<button
|
|
698
|
+
className={`toggle-switch ${emailEnabled ? "toggle-switch-on" : ""}`}
|
|
699
|
+
onClick={handleEmailToggle}
|
|
700
|
+
disabled={togglingEmail}
|
|
701
|
+
role="switch"
|
|
702
|
+
aria-checked={emailEnabled}
|
|
703
|
+
>
|
|
704
|
+
<span className="toggle-switch-thumb" />
|
|
705
|
+
</button>
|
|
706
|
+
</label>
|
|
644
707
|
</div>
|
|
645
708
|
</>
|
|
646
709
|
)}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
import { formatTime } from "../formatTime";
|
|
4
|
-
import
|
|
4
|
+
import { confirmLeaveDraft } from "../draftGuard";
|
|
5
|
+
import SessionComposer from "./SessionComposer";
|
|
6
|
+
import type { AgentInfo, HistoryEntry } from "../types";
|
|
5
7
|
|
|
6
8
|
interface RunsViewProps {
|
|
7
9
|
connected: boolean;
|
|
8
10
|
hostId: string | null;
|
|
9
11
|
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
10
12
|
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
13
|
+
agents: AgentInfo[];
|
|
11
14
|
filterTaskId?: string | null;
|
|
12
15
|
onClearFilter?: () => void;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const PAGE_SIZE = 10;
|
|
16
19
|
|
|
17
|
-
export default function RunsView({ connected, hostId, request, subscribeEvents, filterTaskId, onClearFilter }: RunsViewProps) {
|
|
20
|
+
export default function RunsView({ connected, hostId, request, subscribeEvents, agents, filterTaskId, onClearFilter }: RunsViewProps) {
|
|
18
21
|
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
|
19
22
|
const [total, setTotal] = useState(0);
|
|
20
23
|
const [loading, setLoading] = useState(false);
|
|
@@ -139,6 +142,21 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
|
|
|
139
142
|
aborted: "Aborted",
|
|
140
143
|
};
|
|
141
144
|
|
|
145
|
+
function handleCardClick(taskId: string, runId: string) {
|
|
146
|
+
if (!confirmLeaveDraft()) return;
|
|
147
|
+
navigate(`/runs/${taskId}/${encodeURIComponent(runId)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const composer = !filterTaskId && (
|
|
151
|
+
<SessionComposer
|
|
152
|
+
agents={agents}
|
|
153
|
+
onStarted={(taskId, runId) => {
|
|
154
|
+
if (runId) navigate(`/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
|
|
155
|
+
else navigate(`/runs/${encodeURIComponent(taskId)}`);
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
|
|
142
160
|
function stateColor(state?: string): string | undefined {
|
|
143
161
|
if (state === "failed") return "var(--color-error)";
|
|
144
162
|
if (state === "aborted") return "var(--color-warning, #d97706)";
|
|
@@ -148,38 +166,45 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
|
|
|
148
166
|
// Loading skeleton
|
|
149
167
|
if (loading && entries.length === 0 && connected) {
|
|
150
168
|
return (
|
|
151
|
-
|
|
152
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
<>
|
|
170
|
+
{composer}
|
|
171
|
+
<div className="task-list">
|
|
172
|
+
{[0, 1, 2].map((i) => (
|
|
173
|
+
<div key={i} className="task-card" style={{ pointerEvents: "none" }}>
|
|
174
|
+
<div className="task-card-header">
|
|
175
|
+
<div className="task-card-title-row">
|
|
176
|
+
<div className="skeleton-line" style={{ width: `${70 + i * 10}%` }} />
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="task-card-meta">
|
|
180
|
+
<div className="skeleton-line" style={{ width: "45%" }} />
|
|
157
181
|
</div>
|
|
158
182
|
</div>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
</div>
|
|
163
|
-
))}
|
|
164
|
-
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
</>
|
|
165
186
|
);
|
|
166
187
|
}
|
|
167
188
|
|
|
168
189
|
// Empty / disconnected states
|
|
169
190
|
if (!connected || (loading && entries.length === 0)) {
|
|
170
191
|
return (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
<
|
|
192
|
+
<>
|
|
193
|
+
{composer}
|
|
194
|
+
<div className="runs-view">
|
|
195
|
+
<div className="empty-state">
|
|
196
|
+
<p className="empty-state-text">Sessions</p>
|
|
197
|
+
<p className="empty-state-hint">Your sessions will appear here</p>
|
|
198
|
+
</div>
|
|
175
199
|
</div>
|
|
176
|
-
|
|
200
|
+
</>
|
|
177
201
|
);
|
|
178
202
|
}
|
|
179
203
|
|
|
180
204
|
if (entries.length === 0) {
|
|
181
205
|
return (
|
|
182
206
|
<>
|
|
207
|
+
{composer}
|
|
183
208
|
{filterTaskId && onClearFilter && (
|
|
184
209
|
<div style={{ marginBottom: "var(--space-sm)" }}>
|
|
185
210
|
<span className="runs-filter-chip">
|
|
@@ -190,11 +215,11 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
|
|
|
190
215
|
)}
|
|
191
216
|
<div className="runs-view">
|
|
192
217
|
<div className="empty-state">
|
|
193
|
-
<p className="empty-state-text">No
|
|
218
|
+
<p className="empty-state-text">No sessions yet</p>
|
|
194
219
|
<p className="empty-state-hint">
|
|
195
220
|
{filterTaskId
|
|
196
221
|
? "This task hasn't been executed yet. Run it from the task menu or wait for its next trigger."
|
|
197
|
-
: "
|
|
222
|
+
: "Your sessions will appear here."}
|
|
198
223
|
</p>
|
|
199
224
|
</div>
|
|
200
225
|
</div>
|
|
@@ -204,6 +229,7 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
|
|
|
204
229
|
|
|
205
230
|
return (
|
|
206
231
|
<>
|
|
232
|
+
{composer}
|
|
207
233
|
{filterTaskId && onClearFilter && (
|
|
208
234
|
<div style={{ marginBottom: "var(--space-sm)" }}>
|
|
209
235
|
<span className="runs-filter-chip">
|
|
@@ -217,7 +243,7 @@ export default function RunsView({ connected, hostId, request, subscribeEvents,
|
|
|
217
243
|
<div
|
|
218
244
|
key={`${entry.task_id}-${entry.run_id}-${i}`}
|
|
219
245
|
className="runs-card"
|
|
220
|
-
onClick={() => !entry.error &&
|
|
246
|
+
onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
|
|
221
247
|
>
|
|
222
248
|
<div className="runs-card-body">
|
|
223
249
|
<h3 className="runs-card-name">{entry.task_name || entry.task_id}</h3>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
3
|
+
import { setDraftMessage } from "../draftGuard";
|
|
4
|
+
import type { AgentInfo } from "../types";
|
|
5
|
+
|
|
6
|
+
interface SessionComposerProps {
|
|
7
|
+
agents: AgentInfo[];
|
|
8
|
+
onStarted(taskId: string, runId?: string): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pickDefaultAgent(agents: AgentInfo[]): string {
|
|
12
|
+
const stored = localStorage.getItem("palmier:lastAgent");
|
|
13
|
+
const keys = agents.map((a) => a.key);
|
|
14
|
+
if (stored && keys.includes(stored)) return stored;
|
|
15
|
+
return agents[0]?.key ?? "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function SessionComposer({ agents, onStarted }: SessionComposerProps) {
|
|
19
|
+
const { request } = useHostConnection();
|
|
20
|
+
const [prompt, setPrompt] = useState("");
|
|
21
|
+
const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
|
|
22
|
+
const [yoloMode, setYoloMode] = useState(false);
|
|
23
|
+
const [running, setRunning] = useState(false);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
// Keep agent selection valid as the agent list arrives/changes.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!agents.length) return;
|
|
29
|
+
if (!agents.find((a) => a.key === agent)) {
|
|
30
|
+
setAgent(pickDefaultAgent(agents));
|
|
31
|
+
}
|
|
32
|
+
}, [agents, agent]);
|
|
33
|
+
|
|
34
|
+
// Draft guard: warns on navigation / reload when the input has content.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const hasDraft = prompt.trim().length > 0;
|
|
37
|
+
setDraftMessage(hasDraft ? "Your session draft will be lost. Continue?" : null);
|
|
38
|
+
return () => setDraftMessage(null);
|
|
39
|
+
}, [prompt]);
|
|
40
|
+
|
|
41
|
+
const canRun = !!prompt.trim() && !!agent && !running;
|
|
42
|
+
|
|
43
|
+
function confirmYolo(): boolean {
|
|
44
|
+
if (!yoloMode) return true;
|
|
45
|
+
return confirm(
|
|
46
|
+
"Yolo mode is enabled. The agent will auto-approve all tool calls \u2014 it can read, write, delete files, run arbitrary commands, and access the network without asking for permission.\n\nAre you sure you want to continue?"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleRun() {
|
|
51
|
+
if (!canRun || !confirmYolo()) return;
|
|
52
|
+
setRunning(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
try {
|
|
55
|
+
const result = await request<{ task_id?: string; run_id?: string; error?: string }>(
|
|
56
|
+
"task.run_oneoff",
|
|
57
|
+
{ user_prompt: prompt, agent, yolo_mode: yoloMode },
|
|
58
|
+
);
|
|
59
|
+
if (result.error) {
|
|
60
|
+
setError(result.error);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
localStorage.setItem("palmier:lastAgent", agent);
|
|
64
|
+
setPrompt("");
|
|
65
|
+
setDraftMessage(null);
|
|
66
|
+
if (result.task_id) onStarted(result.task_id, result.run_id);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
69
|
+
} finally {
|
|
70
|
+
setRunning(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
75
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
handleRun();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="session-composer">
|
|
83
|
+
{error && <div className="form-error">{error}</div>}
|
|
84
|
+
<textarea
|
|
85
|
+
className="session-composer-textarea"
|
|
86
|
+
value={prompt}
|
|
87
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
88
|
+
onKeyDown={handleKeyDown}
|
|
89
|
+
placeholder="What can I do for you?"
|
|
90
|
+
rows={3}
|
|
91
|
+
disabled={running}
|
|
92
|
+
/>
|
|
93
|
+
<div className="session-composer-controls">
|
|
94
|
+
<div className="agent-picker-section-inline">
|
|
95
|
+
<span className="agent-picker-label">Run with</span>
|
|
96
|
+
<select
|
|
97
|
+
className="form-select form-select-sm"
|
|
98
|
+
value={agent}
|
|
99
|
+
onChange={(e) => setAgent(e.target.value)}
|
|
100
|
+
disabled={running || !agents.length}
|
|
101
|
+
>
|
|
102
|
+
{agents.map((a) => (
|
|
103
|
+
<option key={a.key} value={a.key}>{a.label}</option>
|
|
104
|
+
))}
|
|
105
|
+
</select>
|
|
106
|
+
</div>
|
|
107
|
+
<label className="session-composer-yolo">
|
|
108
|
+
<input
|
|
109
|
+
type="checkbox"
|
|
110
|
+
checked={yoloMode}
|
|
111
|
+
onChange={(e) => setYoloMode(e.target.checked)}
|
|
112
|
+
disabled={running}
|
|
113
|
+
/>
|
|
114
|
+
Yolo
|
|
115
|
+
</label>
|
|
116
|
+
<button
|
|
117
|
+
className="btn btn-primary chat-send-btn"
|
|
118
|
+
onClick={handleRun}
|
|
119
|
+
disabled={!canRun}
|
|
120
|
+
aria-label="Run session"
|
|
121
|
+
title="Run session"
|
|
122
|
+
>
|
|
123
|
+
{running ? (
|
|
124
|
+
<span className="btn-spinner" />
|
|
125
|
+
) : (
|
|
126
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></svg>
|
|
127
|
+
)}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
{yoloMode && (
|
|
131
|
+
<p className="command-help-text">
|
|
132
|
+
The agent will auto-approve all tool calls without asking for permission.
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
2
|
+
import { confirmLeaveDraft } from "../draftGuard";
|
|
2
3
|
|
|
3
4
|
export default function TabBar() {
|
|
4
5
|
const navigate = useNavigate();
|
|
5
6
|
const location = useLocation();
|
|
6
|
-
const
|
|
7
|
+
const isTasks = location.pathname.startsWith("/tasks");
|
|
8
|
+
const isSessions = !isTasks;
|
|
9
|
+
|
|
10
|
+
function go(path: string) {
|
|
11
|
+
if (!confirmLeaveDraft()) return;
|
|
12
|
+
navigate(path);
|
|
13
|
+
}
|
|
7
14
|
|
|
8
15
|
return (
|
|
9
16
|
<>
|
|
10
17
|
<button
|
|
11
|
-
className={`tab-btn ${
|
|
12
|
-
onClick={() =>
|
|
18
|
+
className={`tab-btn ${isSessions ? "tab-btn-active" : ""}`}
|
|
19
|
+
onClick={() => go("/")}
|
|
13
20
|
>
|
|
14
21
|
<svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
15
|
-
<
|
|
16
|
-
<path d="M5.5 8L7 9.5L10.5 6" />
|
|
22
|
+
<path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
|
|
17
23
|
</svg>
|
|
18
|
-
|
|
24
|
+
Sessions
|
|
19
25
|
</button>
|
|
20
26
|
<button
|
|
21
|
-
className={`tab-btn ${
|
|
22
|
-
onClick={() =>
|
|
27
|
+
className={`tab-btn ${isTasks ? "tab-btn-active" : ""}`}
|
|
28
|
+
onClick={() => go("/tasks")}
|
|
23
29
|
>
|
|
24
30
|
<svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
25
|
-
<
|
|
31
|
+
<rect x="2" y="2" width="12" height="12" rx="2" />
|
|
32
|
+
<path d="M5.5 8L7 9.5L10.5 6" />
|
|
26
33
|
</svg>
|
|
27
|
-
|
|
34
|
+
Tasks
|
|
28
35
|
</button>
|
|
29
36
|
</>
|
|
30
37
|
);
|
|
@@ -64,11 +64,10 @@ interface TaskFormProps {
|
|
|
64
64
|
agents: AgentInfo[];
|
|
65
65
|
hostPlatform?: string;
|
|
66
66
|
onSaved(task: Task): void;
|
|
67
|
-
onRun(taskId: string, runId?: string): void;
|
|
68
67
|
onCancel(): void;
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
export default function TaskForm({ initial, agents, hostPlatform, onSaved,
|
|
70
|
+
export default function TaskForm({ initial, agents, hostPlatform, onSaved, onCancel }: TaskFormProps) {
|
|
72
71
|
const { request } = useHostConnection();
|
|
73
72
|
|
|
74
73
|
// Default agent: last used from localStorage, or first available
|
|
@@ -107,8 +106,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
107
106
|
);
|
|
108
107
|
const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
|
|
109
108
|
const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
|
|
110
|
-
const [
|
|
111
|
-
const saving = savingAction !== null;
|
|
109
|
+
const [saving, setSaving] = useState(false);
|
|
112
110
|
|
|
113
111
|
// Command-triggered mode
|
|
114
112
|
const [commandEnabled, setCommandEnabled] = useState(!!initial?.command);
|
|
@@ -170,7 +168,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
170
168
|
}
|
|
171
169
|
|
|
172
170
|
async function handleSave() {
|
|
173
|
-
|
|
171
|
+
setSaving(true);
|
|
174
172
|
setError(null);
|
|
175
173
|
try {
|
|
176
174
|
const method = isEdit ? "task.update" : "task.create";
|
|
@@ -202,34 +200,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
202
200
|
setError(err instanceof Error ? err.message : String(err));
|
|
203
201
|
return null;
|
|
204
202
|
} finally {
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function handleRunOneoff() {
|
|
210
|
-
setSavingAction("run");
|
|
211
|
-
setError(null);
|
|
212
|
-
try {
|
|
213
|
-
const payload: Record<string, unknown> = {
|
|
214
|
-
user_prompt: userPrompt,
|
|
215
|
-
agent,
|
|
216
|
-
requires_confirmation: requiresConfirmation,
|
|
217
|
-
yolo_mode: yoloMode,
|
|
218
|
-
foreground_mode: foregroundMode,
|
|
219
|
-
command: commandEnabled ? command : "",
|
|
220
|
-
};
|
|
221
|
-
const result = await request<{ ok?: boolean; task_id?: string; run_id?: string; error?: string }>("task.run_oneoff", payload);
|
|
222
|
-
if (result.error) {
|
|
223
|
-
setError(result.error);
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
localStorage.setItem("palmier:lastAgent", agent);
|
|
227
|
-
onRun(result.task_id!, result.run_id);
|
|
228
|
-
onCancel();
|
|
229
|
-
} catch (err) {
|
|
230
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
231
|
-
} finally {
|
|
232
|
-
setSavingAction(null);
|
|
203
|
+
setSaving(false);
|
|
233
204
|
}
|
|
234
205
|
}
|
|
235
206
|
|
|
@@ -505,59 +476,33 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
505
476
|
<div className="form-actions">
|
|
506
477
|
{(() => {
|
|
507
478
|
const hasSchedule = triggerRows.length > 0;
|
|
508
|
-
const
|
|
479
|
+
const label = hasSchedule ? "Schedule" : "Save";
|
|
509
480
|
if (!isEdit) {
|
|
510
|
-
|
|
511
|
-
// New task with schedule: "Schedule" only
|
|
512
|
-
return (
|
|
513
|
-
<button
|
|
514
|
-
className="btn btn-primary"
|
|
515
|
-
onClick={() => confirmYolo() && handleSave()}
|
|
516
|
-
disabled={!canSave || saving}
|
|
517
|
-
>
|
|
518
|
-
{savingAction === "save" && <span className="btn-spinner" />}
|
|
519
|
-
Schedule
|
|
520
|
-
</button>
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
// New task, no schedule: "Run" (primary) + "Save"
|
|
524
|
-
return (<>
|
|
481
|
+
return (
|
|
525
482
|
<button
|
|
526
483
|
className="btn btn-primary"
|
|
527
|
-
onClick={() => confirmYolo() && handleRunOneoff()}
|
|
528
|
-
disabled={!canRun || saving}
|
|
529
|
-
>
|
|
530
|
-
{savingAction === "run" && <span className="btn-spinner" />}
|
|
531
|
-
Run
|
|
532
|
-
</button>
|
|
533
|
-
<button
|
|
534
|
-
className="btn btn-secondary"
|
|
535
484
|
onClick={() => confirmYolo() && handleSave()}
|
|
536
485
|
disabled={!canSave || saving}
|
|
537
486
|
>
|
|
538
|
-
|
|
487
|
+
{saving && <span className="btn-spinner" />}
|
|
488
|
+
{label}
|
|
539
489
|
</button>
|
|
540
|
-
|
|
490
|
+
);
|
|
541
491
|
}
|
|
542
492
|
if (isDirty) {
|
|
543
|
-
// Edit, changed: Save only
|
|
544
493
|
return (
|
|
545
494
|
<button
|
|
546
495
|
className="btn btn-primary"
|
|
547
496
|
onClick={() => confirmYolo() && handleSave()}
|
|
548
497
|
disabled={!canSave || saving}
|
|
549
498
|
>
|
|
550
|
-
{
|
|
499
|
+
{saving && <span className="btn-spinner" />}
|
|
551
500
|
Save
|
|
552
501
|
</button>
|
|
553
502
|
);
|
|
554
503
|
}
|
|
555
|
-
// Edit, unchanged: disabled Save
|
|
556
504
|
return (
|
|
557
|
-
<button
|
|
558
|
-
className="btn btn-primary"
|
|
559
|
-
disabled
|
|
560
|
-
>
|
|
505
|
+
<button className="btn btn-primary" disabled>
|
|
561
506
|
Save
|
|
562
507
|
</button>
|
|
563
508
|
);
|