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.
Files changed (122) hide show
  1. package/dist/agents/agent.d.ts +3 -0
  2. package/dist/agents/agent.js +1 -1
  3. package/dist/agents/aider.d.ts +1 -0
  4. package/dist/agents/aider.js +1 -0
  5. package/dist/agents/claude.d.ts +1 -0
  6. package/dist/agents/claude.js +1 -0
  7. package/dist/agents/cline.d.ts +1 -0
  8. package/dist/agents/cline.js +1 -0
  9. package/dist/agents/codex.d.ts +1 -0
  10. package/dist/agents/codex.js +1 -0
  11. package/dist/agents/copilot.d.ts +1 -0
  12. package/dist/agents/copilot.js +1 -0
  13. package/dist/agents/cursor.d.ts +1 -0
  14. package/dist/agents/cursor.js +1 -0
  15. package/dist/agents/deepagents.d.ts +1 -0
  16. package/dist/agents/deepagents.js +1 -0
  17. package/dist/agents/droid.d.ts +1 -0
  18. package/dist/agents/droid.js +1 -0
  19. package/dist/agents/gemini.d.ts +1 -0
  20. package/dist/agents/gemini.js +1 -0
  21. package/dist/agents/goose.d.ts +1 -0
  22. package/dist/agents/goose.js +1 -0
  23. package/dist/agents/hermes.d.ts +1 -0
  24. package/dist/agents/hermes.js +1 -0
  25. package/dist/agents/kimi.d.ts +1 -0
  26. package/dist/agents/kimi.js +1 -0
  27. package/dist/agents/kiro.d.ts +1 -0
  28. package/dist/agents/kiro.js +1 -0
  29. package/dist/agents/openclaw.d.ts +1 -0
  30. package/dist/agents/openclaw.js +2 -2
  31. package/dist/agents/opencode.d.ts +1 -0
  32. package/dist/agents/opencode.js +1 -0
  33. package/dist/agents/qoder.d.ts +1 -0
  34. package/dist/agents/qoder.js +1 -0
  35. package/dist/agents/qwen.d.ts +1 -0
  36. package/dist/agents/qwen.js +1 -0
  37. package/dist/agents/shared-prompt.js +1 -1
  38. package/dist/commands/init.js +3 -2
  39. package/dist/commands/pair.js +3 -3
  40. package/dist/commands/run.js +4 -4
  41. package/dist/commands/serve.js +1 -1
  42. package/dist/config.js +2 -2
  43. package/dist/device-capabilities.d.ts +1 -1
  44. package/dist/events.js +1 -1
  45. package/dist/mcp-tools.js +79 -7
  46. package/dist/nats-client.d.ts +1 -1
  47. package/dist/nats-client.js +6 -3
  48. package/dist/pending-requests.d.ts +30 -8
  49. package/dist/pending-requests.js +28 -15
  50. package/dist/pwa/assets/index-8cTctVnD.js +120 -0
  51. package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
  52. package/dist/pwa/assets/{web-DnuoxUd4.js → web-BNr628AV.js} +1 -1
  53. package/dist/pwa/assets/{web-7raT3zOZ.js → web-DyQPewAi.js} +1 -1
  54. package/dist/pwa/index.html +2 -2
  55. package/dist/pwa/service-worker.js +1 -1
  56. package/dist/rpc-handler.js +12 -16
  57. package/dist/transports/http-transport.js +6 -3
  58. package/dist/types.d.ts +4 -1
  59. package/package.json +1 -1
  60. package/palmier-server/PRODUCTION.md +31 -28
  61. package/palmier-server/README.md +35 -5
  62. package/palmier-server/nats.conf +9 -5
  63. package/palmier-server/package.json +2 -1
  64. package/palmier-server/pnpm-lock.yaml +6 -0
  65. package/palmier-server/pwa/src/App.css +66 -0
  66. package/palmier-server/pwa/src/App.tsx +1 -0
  67. package/palmier-server/pwa/src/components/HostMenu.tsx +65 -2
  68. package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
  69. package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
  70. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  71. package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
  72. package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
  73. package/palmier-server/pwa/src/constants.ts +1 -1
  74. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
  75. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  76. package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
  77. package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
  78. package/palmier-server/pwa/src/types.ts +1 -6
  79. package/palmier-server/server/package.json +3 -1
  80. package/palmier-server/server/src/index.ts +83 -2
  81. package/palmier-server/server/src/nats-jwt.ts +299 -0
  82. package/palmier-server/server/src/nats-setup.ts +48 -0
  83. package/palmier-server/server/src/nats.ts +12 -4
  84. package/palmier-server/server/src/routes/device.ts +24 -0
  85. package/palmier-server/server/src/routes/hosts.ts +13 -2
  86. package/palmier-server/spec.md +28 -14
  87. package/src/agents/agent.ts +5 -1
  88. package/src/agents/aider.ts +1 -0
  89. package/src/agents/claude.ts +1 -0
  90. package/src/agents/cline.ts +1 -0
  91. package/src/agents/codex.ts +1 -0
  92. package/src/agents/copilot.ts +1 -0
  93. package/src/agents/cursor.ts +1 -0
  94. package/src/agents/deepagents.ts +1 -0
  95. package/src/agents/droid.ts +1 -0
  96. package/src/agents/gemini.ts +1 -0
  97. package/src/agents/goose.ts +1 -0
  98. package/src/agents/hermes.ts +1 -0
  99. package/src/agents/kimi.ts +1 -0
  100. package/src/agents/kiro.ts +1 -0
  101. package/src/agents/openclaw.ts +2 -2
  102. package/src/agents/opencode.ts +1 -0
  103. package/src/agents/qoder.ts +1 -0
  104. package/src/agents/qwen.ts +1 -0
  105. package/src/agents/shared-prompt.ts +1 -1
  106. package/src/commands/init.ts +7 -5
  107. package/src/commands/pair.ts +3 -3
  108. package/src/commands/run.ts +4 -4
  109. package/src/commands/serve.ts +1 -1
  110. package/src/config.ts +2 -2
  111. package/src/device-capabilities.ts +1 -0
  112. package/src/events.ts +1 -1
  113. package/src/mcp-tools.ts +83 -7
  114. package/src/nats-client.ts +10 -3
  115. package/src/pending-requests.ts +47 -15
  116. package/src/rpc-handler.ts +13 -16
  117. package/src/transports/http-transport.ts +6 -3
  118. package/src/types.ts +4 -3
  119. package/test/agent-instructions.test.ts +10 -10
  120. package/test/pairing.test.ts +2 -2
  121. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  122. 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) { setActiveHostId(host.hostId); if (!isDesktop) close(); }
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 type { HistoryEntry } from "../types";
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
- <div className="task-list">
152
- {[0, 1, 2].map((i) => (
153
- <div key={i} className="task-card" style={{ pointerEvents: "none" }}>
154
- <div className="task-card-header">
155
- <div className="task-card-title-row">
156
- <div className="skeleton-line" style={{ width: `${70 + i * 10}%` }} />
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
- <div className="task-card-meta">
160
- <div className="skeleton-line" style={{ width: "45%" }} />
161
- </div>
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
- <div className="runs-view">
172
- <div className="empty-state">
173
- <p className="empty-state-text">Runs</p>
174
- <p className="empty-state-hint">Run history will appear here</p>
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
- </div>
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 runs yet</p>
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
- : "Run history will appear here."}
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 && navigate(`/runs/${entry.task_id}/${encodeURIComponent(entry.run_id)}`)}
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 isRuns = location.pathname.startsWith("/runs");
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 ${!isRuns ? "tab-btn-active" : ""}`}
12
- onClick={() => navigate("/")}
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
- <rect x="2" y="2" width="12" height="12" rx="2" />
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
- Tasks
24
+ Sessions
19
25
  </button>
20
26
  <button
21
- className={`tab-btn ${isRuns ? "tab-btn-active" : ""}`}
22
- onClick={() => navigate("/runs")}
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
- <path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
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
- Runs
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, onRun, onCancel }: TaskFormProps) {
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 [savingAction, setSavingAction] = useState<"save" | "run" | null>(null);
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
- setSavingAction("save");
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
- setSavingAction(null);
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 canRun = !!userPrompt.trim() && (!commandEnabled || !!command.trim());
479
+ const label = hasSchedule ? "Schedule" : "Save";
509
480
  if (!isEdit) {
510
- if (hasSchedule) {
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
- Save
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
- {savingAction === "save" && <span className="btn-spinner" />}
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
  );