palmier 0.8.1 → 0.8.3

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 (125) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +11 -11
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/app-registry.d.ts +10 -0
  17. package/dist/app-registry.js +44 -0
  18. package/dist/commands/info.d.ts +0 -3
  19. package/dist/commands/info.js +0 -5
  20. package/dist/commands/init.d.ts +0 -3
  21. package/dist/commands/init.js +2 -11
  22. package/dist/commands/pair.d.ts +1 -4
  23. package/dist/commands/pair.js +1 -12
  24. package/dist/commands/restart.d.ts +0 -3
  25. package/dist/commands/restart.js +0 -3
  26. package/dist/commands/run.d.ts +1 -14
  27. package/dist/commands/run.js +18 -61
  28. package/dist/commands/serve.d.ts +0 -3
  29. package/dist/commands/serve.js +33 -27
  30. package/dist/config.d.ts +0 -8
  31. package/dist/config.js +0 -8
  32. package/dist/device-capabilities.d.ts +1 -1
  33. package/dist/event-queues.d.ts +6 -21
  34. package/dist/event-queues.js +6 -21
  35. package/dist/events.d.ts +0 -6
  36. package/dist/events.js +1 -9
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-handler.js +1 -2
  39. package/dist/mcp-tools.d.ts +0 -3
  40. package/dist/mcp-tools.js +12 -16
  41. package/dist/nats-client.d.ts +0 -3
  42. package/dist/nats-client.js +1 -4
  43. package/dist/pending-requests.d.ts +4 -18
  44. package/dist/pending-requests.js +4 -18
  45. package/dist/platform/index.d.ts +1 -4
  46. package/dist/platform/index.js +1 -4
  47. package/dist/platform/linux.d.ts +3 -9
  48. package/dist/platform/linux.js +9 -20
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/{index-CQxcuDhM.css → index-B0F9mtid.css} +1 -1
  53. package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
  54. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-C6lkQj9J.js} +1 -1
  55. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Z1623me-.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +18 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +6 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +165 -20
  73. package/palmier-server/pwa/src/components/HostMenu.tsx +159 -49
  74. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  75. package/palmier-server/pwa/src/components/SessionsView.tsx +57 -31
  76. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  77. package/palmier-server/pwa/src/components/TaskForm.tsx +152 -2
  78. package/palmier-server/pwa/src/constants.ts +1 -1
  79. package/palmier-server/pwa/src/native/Device.ts +20 -2
  80. package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
  81. package/palmier-server/server/src/index.ts +7 -7
  82. package/palmier-server/server/src/routes/device.ts +4 -4
  83. package/palmier-server/spec.md +38 -7
  84. package/src/agents/agent.ts +0 -4
  85. package/src/agents/claude.ts +1 -1
  86. package/src/agents/codex.ts +2 -2
  87. package/src/agents/cursor.ts +1 -1
  88. package/src/agents/deepagents.ts +1 -1
  89. package/src/agents/gemini.ts +3 -2
  90. package/src/agents/goose.ts +1 -1
  91. package/src/agents/hermes.ts +1 -1
  92. package/src/agents/kiro.ts +1 -1
  93. package/src/agents/opencode.ts +1 -1
  94. package/src/agents/qoder.ts +1 -1
  95. package/src/agents/shared-prompt.ts +0 -3
  96. package/src/app-registry.ts +52 -0
  97. package/src/commands/info.ts +0 -5
  98. package/src/commands/init.ts +2 -11
  99. package/src/commands/pair.ts +1 -12
  100. package/src/commands/restart.ts +0 -3
  101. package/src/commands/run.ts +18 -65
  102. package/src/commands/serve.ts +31 -27
  103. package/src/config.ts +0 -8
  104. package/src/device-capabilities.ts +3 -2
  105. package/src/event-queues.ts +6 -21
  106. package/src/events.ts +1 -9
  107. package/src/index.ts +0 -1
  108. package/src/mcp-handler.ts +1 -2
  109. package/src/mcp-tools.ts +12 -18
  110. package/src/nats-client.ts +1 -4
  111. package/src/pending-requests.ts +4 -18
  112. package/src/platform/index.ts +1 -4
  113. package/src/platform/linux.ts +9 -20
  114. package/src/platform/platform.ts +1 -4
  115. package/src/platform/windows.ts +19 -40
  116. package/src/rpc-handler.ts +19 -47
  117. package/src/spawn-command.ts +11 -27
  118. package/src/task.ts +7 -70
  119. package/src/transports/http-transport.ts +6 -39
  120. package/src/transports/nats-transport.ts +3 -9
  121. package/src/types.ts +3 -10
  122. package/src/update-checker.ts +2 -5
  123. package/test/task-parsing.test.ts +2 -3
  124. package/test/windows-xml.test.ts +11 -12
  125. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -1793,25 +1793,16 @@ body {
1793
1793
  z-index: 101;
1794
1794
  display: flex;
1795
1795
  flex-direction: column;
1796
+ overflow-y: auto;
1797
+ overscroll-behavior: contain;
1796
1798
  animation: drawerSlideIn 0.25s ease;
1797
1799
  }
1798
1800
 
1799
- .drawer-header {
1800
- display: flex;
1801
- align-items: center;
1802
- justify-content: space-between;
1803
- padding: var(--space-md);
1804
- border-bottom: 1px solid var(--color-border);
1805
- }
1806
-
1807
- .drawer-title {
1808
- font-size: 1.125rem;
1809
- font-weight: 800;
1810
- color: var(--color-primary);
1811
- letter-spacing: -0.04em;
1812
- }
1813
-
1814
1801
  .drawer-close-btn {
1802
+ position: absolute;
1803
+ top: 8px;
1804
+ right: 8px;
1805
+ z-index: 1;
1815
1806
  display: flex;
1816
1807
  align-items: center;
1817
1808
  justify-content: center;
@@ -1847,6 +1838,18 @@ body {
1847
1838
  margin-bottom: var(--space-sm);
1848
1839
  }
1849
1840
 
1841
+ .drawer-toggle-group {
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ gap: var(--space-sm);
1845
+ }
1846
+
1847
+ .drawer-toggle-group-divided {
1848
+ border-top: 1px solid var(--color-border);
1849
+ padding-top: var(--space-sm);
1850
+ margin-top: var(--space-xs);
1851
+ }
1852
+
1850
1853
  .drawer-toggle {
1851
1854
  display: flex;
1852
1855
  align-items: center;
@@ -1961,17 +1964,42 @@ body {
1961
1964
 
1962
1965
  /* ===== Tab bar ===== */
1963
1966
 
1964
- .tab-bar {
1965
- display: flex;
1966
- align-items: center;
1967
- background: color-mix(in srgb, var(--color-surface) 92%, transparent);
1968
- border-bottom: 1px solid var(--color-border);
1967
+ .app-header {
1969
1968
  position: sticky;
1970
1969
  top: 0;
1971
1970
  z-index: 10;
1971
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
1972
+ border-bottom: 1px solid var(--color-border);
1972
1973
  backdrop-filter: blur(8px);
1973
1974
  }
1974
1975
 
1976
+ .app-title-bar {
1977
+ position: relative;
1978
+ display: flex;
1979
+ align-items: center;
1980
+ justify-content: center;
1981
+ padding: 8px 0;
1982
+ }
1983
+
1984
+ .app-title-bar .hamburger-btn {
1985
+ position: absolute;
1986
+ left: 4px;
1987
+ top: 50%;
1988
+ transform: translateY(-50%);
1989
+ }
1990
+
1991
+ .app-title {
1992
+ margin: 0;
1993
+ font-size: 1rem;
1994
+ font-weight: 600;
1995
+ color: var(--color-text);
1996
+ }
1997
+
1998
+ .tab-bar {
1999
+ display: flex;
2000
+ align-items: center;
2001
+ }
2002
+
1975
2003
  .tab-btn {
1976
2004
  flex: 1;
1977
2005
  display: flex;
@@ -2613,6 +2641,7 @@ body {
2613
2641
  display: flex;
2614
2642
  flex-direction: column;
2615
2643
  overflow-y: auto;
2644
+ overscroll-behavior: contain;
2616
2645
  animation: none;
2617
2646
  }
2618
2647
 
@@ -2630,3 +2659,119 @@ body {
2630
2659
  right: max(var(--space-lg), calc((100vw - 1080px) / 2));
2631
2660
  }
2632
2661
  }
2662
+
2663
+ /* ===== Swipe-to-delete row ===== */
2664
+
2665
+ .swipe-row {
2666
+ position: relative;
2667
+ overflow: hidden;
2668
+ touch-action: pan-y; /* let vertical scroll through; capture horizontal ourselves */
2669
+ }
2670
+
2671
+ .swipe-row-action {
2672
+ position: absolute;
2673
+ top: 0;
2674
+ right: 0;
2675
+ bottom: 0;
2676
+ display: flex;
2677
+ align-items: center;
2678
+ justify-content: center;
2679
+ background: var(--color-error, #dc2626);
2680
+ color: #fff;
2681
+ border: none;
2682
+ font-size: 0.9375rem;
2683
+ font-weight: 600;
2684
+ cursor: pointer;
2685
+ padding: 0;
2686
+ }
2687
+
2688
+ .swipe-row-action:focus-visible {
2689
+ outline: 2px solid var(--color-accent, #2E5CE5);
2690
+ outline-offset: -4px;
2691
+ }
2692
+
2693
+ .swipe-row-content {
2694
+ position: relative;
2695
+ background: var(--color-surface);
2696
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
2697
+ will-change: transform;
2698
+ }
2699
+
2700
+ /* During an active drag, skip the transition so the row tracks the finger 1:1.
2701
+ On release the class is removed and the snap animates. */
2702
+ .swipe-row-content-dragging {
2703
+ transition: none;
2704
+ }
2705
+
2706
+ .app-filter-help {
2707
+ margin-top: var(--space-xs);
2708
+ }
2709
+
2710
+ .app-combobox {
2711
+ position: relative;
2712
+ }
2713
+
2714
+ .app-combobox-list {
2715
+ position: absolute;
2716
+ top: calc(100% + 2px);
2717
+ left: 0;
2718
+ right: 0;
2719
+ max-height: 280px;
2720
+ overflow-y: auto;
2721
+ overscroll-behavior: contain;
2722
+ background: var(--color-surface);
2723
+ border: 1px solid var(--color-border);
2724
+ border-radius: var(--radius-md);
2725
+ box-shadow: var(--shadow-md);
2726
+ list-style: none;
2727
+ margin: 0;
2728
+ padding: var(--space-xs) 0;
2729
+ z-index: 20;
2730
+ }
2731
+
2732
+ .app-combobox-row {
2733
+ display: flex;
2734
+ align-items: center;
2735
+ gap: var(--space-md);
2736
+ padding: 8px var(--space-md);
2737
+ cursor: pointer;
2738
+ user-select: none;
2739
+ }
2740
+
2741
+ .app-combobox-row:hover {
2742
+ background: var(--color-hover, rgba(0, 0, 0, 0.04));
2743
+ }
2744
+
2745
+ .app-combobox-icon {
2746
+ width: 28px;
2747
+ height: 28px;
2748
+ border-radius: 6px;
2749
+ flex-shrink: 0;
2750
+ }
2751
+
2752
+ .app-combobox-icon-placeholder {
2753
+ background: var(--color-border);
2754
+ }
2755
+
2756
+ .app-combobox-labels {
2757
+ display: flex;
2758
+ flex-direction: column;
2759
+ min-width: 0;
2760
+ flex: 1;
2761
+ }
2762
+
2763
+ .app-combobox-name {
2764
+ font-size: 0.9375rem;
2765
+ font-weight: 500;
2766
+ white-space: nowrap;
2767
+ overflow: hidden;
2768
+ text-overflow: ellipsis;
2769
+ }
2770
+
2771
+ .app-combobox-pkg {
2772
+ font-size: 0.75rem;
2773
+ color: var(--color-muted);
2774
+ white-space: nowrap;
2775
+ overflow: hidden;
2776
+ text-overflow: ellipsis;
2777
+ }
@@ -12,14 +12,20 @@ import { confirmLeaveDraft } from "../draftGuard";
12
12
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
13
13
  const isNative = Capacitor.isNativePlatform();
14
14
 
15
+ type CapabilityGroup = "Messaging" | "Data" | "Device";
16
+
17
+ const CAPABILITY_GROUPS: CapabilityGroup[] = ["Device", "Data", "Messaging"];
18
+
15
19
  interface CapabilityDefinition {
16
20
  /** Server-side capability name used in device.capability.{enable,disable} RPCs. */
17
21
  capability: string;
18
22
  /** Label shown in the drawer toggle. */
19
23
  label: string;
24
+ /** Drawer section this capability is grouped under. */
25
+ group: CapabilityGroup;
20
26
  /** Runtime or settings permission to request before enabling. */
21
27
  permission?: PermissionType;
22
- /** True for capabilities that display full-screen alerts (alert, send-email). */
28
+ /** True for capabilities that display a full-screen UI (alarm). */
23
29
  needsFullScreenIntent?: boolean;
24
30
  /** Override RPC methods; location uses device.location.{enable,disable} instead. */
25
31
  enableMethod?: string;
@@ -29,23 +35,25 @@ interface CapabilityDefinition {
29
35
  }
30
36
 
31
37
  const CAPABILITIES: CapabilityDefinition[] = [
38
+ { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
39
+ { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
40
+ { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
41
+ { capability: "notifications", label: "Read Notifications", group: "Data", permission: "notificationListener" },
42
+ { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
43
+ { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
32
44
  {
33
45
  capability: "location",
34
- label: "Location Access",
46
+ label: "Get Location",
47
+ group: "Device",
35
48
  permission: "location",
36
49
  enableMethod: "device.location.enable",
37
50
  disableMethod: "device.location.disable",
38
51
  enableParams: (fcmToken) => ({ fcmToken }),
39
52
  disableParams: () => ({}),
40
53
  },
41
- { capability: "notifications", label: "Notification Access", permission: "notificationListener" },
42
- { capability: "sms", label: "SMS Access", permission: "sms" },
43
- { capability: "contacts", label: "Contacts Access", permission: "contacts" },
44
- { capability: "calendar", label: "Calendar Access", permission: "calendar" },
45
- { capability: "dnd", label: "Do Not Disturb Control", permission: "dnd" },
46
- { capability: "alert", label: "Alert Access", needsFullScreenIntent: true },
47
- { capability: "battery", label: "Battery Access" },
48
- { capability: "send-email", label: "Email Drafting", needsFullScreenIntent: true },
54
+ { capability: "battery", label: "Read Battery Status", group: "Device" },
55
+ { capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
56
+ { capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
49
57
  ];
50
58
 
51
59
  interface HostMenuProps {
@@ -67,6 +75,8 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
67
75
  const [renameValue, setRenameValue] = useState("");
68
76
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
69
77
  const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
78
+ const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
79
+ const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
70
80
  /**
71
81
  * Permission types the installed APK understands. Null while loading; an empty
72
82
  * set means the native plugin doesn't expose a discovery method (pre-Device
@@ -145,9 +155,22 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
145
155
  Device.setEnabledCapabilities({ capabilities: enabledCapabilities }).catch(() => {});
146
156
  }, [capabilityTokens, activeClientToken]);
147
157
 
148
- async function toggleCapability(definition: CapabilityDefinition) {
158
+ async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
149
159
  if (!request) return;
150
160
  const enabled = isCapabilityEnabled(definition.capability);
161
+
162
+ if (enabled && !bypassConfirmation) {
163
+ setConfirmingDisableCapability(definition);
164
+ return;
165
+ }
166
+
167
+ const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
168
+ && capabilityTokens[definition.capability] !== activeClientToken;
169
+ if (ownedByOther && !bypassConfirmation) {
170
+ setConfirmingSwitchCapability(definition);
171
+ return;
172
+ }
173
+
151
174
  setTogglingCapability(definition.capability);
152
175
  try {
153
176
  if (enabled) {
@@ -158,6 +181,21 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
158
181
  return;
159
182
  }
160
183
 
184
+ if (Device && definition.capability === "send-email") {
185
+ // Verify a mailto: handler exists before enabling — silent PackageManager
186
+ // lookup. If the APK predates this method, allow enabling and let the
187
+ // user discover the missing-app failure at first use.
188
+ try {
189
+ const result = await Device.hasEmailClient();
190
+ if (result.supported && !result.available) {
191
+ alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
192
+ return;
193
+ }
194
+ } catch {
195
+ // Older APK without this method — fall through.
196
+ }
197
+ }
198
+
161
199
  if (Device && definition.permission) {
162
200
  const check = await Device.checkPermission({ type: definition.permission });
163
201
  if (!check.supported) {
@@ -261,20 +299,17 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
261
299
 
262
300
  const drawerContent = (
263
301
  <>
264
- <div className="drawer-header">
265
- <span className="drawer-title">Palmier</span>
266
- {!isDesktop && (
267
- <button
268
- className="drawer-close-btn"
269
- onClick={close}
270
- aria-label="Close menu"
271
- >
272
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
273
- <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
274
- </svg>
275
- </button>
276
- )}
277
- </div>
302
+ {!isDesktop && (
303
+ <button
304
+ className="drawer-close-btn"
305
+ onClick={close}
306
+ aria-label="Close menu"
307
+ >
308
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
309
+ <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
310
+ </svg>
311
+ </button>
312
+ )}
278
313
 
279
314
  {!isLanMode && pairedHosts.length > 0 && (
280
315
  <div className="drawer-section">
@@ -382,30 +417,41 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
382
417
  </div>
383
418
  </>)}
384
419
 
385
- {isNative && (
386
- <>
387
- <div className="drawer-divider" />
388
- <div className="drawer-section">
389
- {CAPABILITIES.filter(isCapabilityVisible).map((definition) => {
390
- const enabled = isCapabilityEnabled(definition.capability);
391
- return (
392
- <label key={definition.capability} className="drawer-toggle">
393
- <span className="drawer-toggle-label">{definition.label}</span>
394
- <button
395
- className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
396
- onClick={() => toggleCapability(definition)}
397
- disabled={togglingCapability === definition.capability}
398
- role="switch"
399
- aria-checked={enabled}
400
- >
401
- <span className="toggle-switch-thumb" />
402
- </button>
403
- </label>
404
- );
405
- })}
406
- </div>
407
- </>
408
- )}
420
+ {isNative && (() => {
421
+ const visibleGroups = CAPABILITY_GROUPS
422
+ .map((group) => ({ group, items: CAPABILITIES.filter((definition) => definition.group === group && isCapabilityVisible(definition)) }))
423
+ .filter((g) => g.items.length > 0);
424
+ if (visibleGroups.length === 0) return null;
425
+ return (
426
+ <>
427
+ <div className="drawer-divider" />
428
+ <div className="drawer-section">
429
+ <h3 className="drawer-section-label">Host capabilities on this device</h3>
430
+ {visibleGroups.map(({ group, items }, index) => (
431
+ <div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
432
+ {items.map((definition) => {
433
+ const enabled = isCapabilityEnabled(definition.capability);
434
+ return (
435
+ <label key={definition.capability} className="drawer-toggle">
436
+ <span className="drawer-toggle-label">{definition.label}</span>
437
+ <button
438
+ className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
439
+ onClick={() => toggleCapability(definition)}
440
+ disabled={togglingCapability === definition.capability}
441
+ role="switch"
442
+ aria-checked={enabled}
443
+ >
444
+ <span className="toggle-switch-thumb" />
445
+ </button>
446
+ </label>
447
+ );
448
+ })}
449
+ </div>
450
+ ))}
451
+ </div>
452
+ </>
453
+ );
454
+ })()}
409
455
 
410
456
  <div className="drawer-footer">
411
457
  {daemonVersion && (
@@ -451,6 +497,66 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
451
497
  document.body
452
498
  );
453
499
 
500
+ const disableCapabilityModal = confirmingDisableCapability && createPortal(
501
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
502
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
503
+ <h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
504
+ <p className="confirm-modal-message">
505
+ Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
506
+ </p>
507
+ <div className="confirm-modal-actions">
508
+ <button
509
+ className="btn btn-secondary"
510
+ onClick={() => setConfirmingDisableCapability(null)}
511
+ >
512
+ Cancel
513
+ </button>
514
+ <button
515
+ className="btn btn-danger"
516
+ onClick={() => {
517
+ const definition = confirmingDisableCapability;
518
+ setConfirmingDisableCapability(null);
519
+ toggleCapability(definition, true);
520
+ }}
521
+ >
522
+ Disable
523
+ </button>
524
+ </div>
525
+ </div>
526
+ </div>,
527
+ document.body
528
+ );
529
+
530
+ const switchCapabilityModal = confirmingSwitchCapability && createPortal(
531
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
532
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
533
+ <h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
534
+ <p className="confirm-modal-message">
535
+ {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.
536
+ </p>
537
+ <div className="confirm-modal-actions">
538
+ <button
539
+ className="btn btn-secondary"
540
+ onClick={() => setConfirmingSwitchCapability(null)}
541
+ >
542
+ Cancel
543
+ </button>
544
+ <button
545
+ className="btn btn-primary"
546
+ onClick={() => {
547
+ const definition = confirmingSwitchCapability;
548
+ setConfirmingSwitchCapability(null);
549
+ toggleCapability(definition, true);
550
+ }}
551
+ >
552
+ Switch
553
+ </button>
554
+ </div>
555
+ </div>
556
+ </div>,
557
+ document.body
558
+ );
559
+
454
560
  // Desktop: persistent inline sidebar
455
561
  if (isDesktop) {
456
562
  return (
@@ -459,6 +565,8 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
459
565
  {drawerContent}
460
566
  </div>
461
567
  {deleteModal}
568
+ {switchCapabilityModal}
569
+ {disableCapabilityModal}
462
570
  </>
463
571
  );
464
572
  }
@@ -494,6 +602,8 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
494
602
  )}
495
603
 
496
604
  {deleteModal}
605
+ {switchCapabilityModal}
606
+ {disableCapabilityModal}
497
607
  </>
498
608
  );
499
609
  }
@@ -131,7 +131,8 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
131
131
  }, [messages]);
132
132
 
133
133
  // On first load of a run, scroll the window to the bottom so the follow-up
134
- // input is visible, and focus the input if it's rendered (agent not running).
134
+ // input is visible. Deliberately not focusing the input on mobile that
135
+ // would pop the soft keyboard as soon as the run opens.
135
136
  useEffect(() => {
136
137
  if (loading || isLatestEmpty || !resolvedRunId) return;
137
138
  if (initialFocusForRunId.current === resolvedRunId) return;
@@ -139,9 +140,8 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
139
140
  requestAnimationFrame(() => {
140
141
  if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
141
142
  window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "auto" });
142
- if (!isAgentGenerating) followupInputRef.current?.focus();
143
143
  });
144
- }, [loading, isLatestEmpty, resolvedRunId, isAgentGenerating]);
144
+ }, [loading, isLatestEmpty, resolvedRunId]);
145
145
 
146
146
  function typeLabel(type?: string): string | undefined {
147
147
  if (type === "input") return "User Input";
@@ -4,6 +4,7 @@ import { formatTime } from "../formatTime";
4
4
  import { confirmLeaveDraft } from "../draftGuard";
5
5
  import SessionComposer from "./SessionComposer";
6
6
  import PullToRefreshIndicator from "./PullToRefreshIndicator";
7
+ import SwipeToDeleteRow from "./SwipeToDeleteRow";
7
8
  import { usePullToRefresh } from "../hooks/usePullToRefresh";
8
9
  import type { AgentInfo, HistoryEntry } from "../types";
9
10
 
@@ -24,8 +25,25 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
24
25
  const [total, setTotal] = useState(0);
25
26
  const [loading, setLoading] = useState(false);
26
27
  const [loadingMore, setLoadingMore] = useState(false);
28
+ /** Key of the row currently showing its delete action, or null. iOS pattern — at most one at a time. */
29
+ const [revealedKey, setRevealedKey] = useState<string | null>(null);
27
30
  const navigate = useNavigate();
28
31
 
32
+ async function deleteEntry(entry: HistoryEntry) {
33
+ const key = `${entry.task_id}:${entry.run_id}`;
34
+ // Optimistic: drop from the list immediately, restore if the RPC fails.
35
+ setEntries((prev) => prev.filter((e) => `${e.task_id}:${e.run_id}` !== key));
36
+ setTotal((t) => Math.max(0, t - 1));
37
+ setRevealedKey(null);
38
+ try {
39
+ await request("taskrun.delete", { task_id: entry.task_id, run_id: entry.run_id });
40
+ } catch (err) {
41
+ console.error("Failed to delete run:", err);
42
+ setEntries((prev) => [entry, ...prev]);
43
+ setTotal((t) => t + 1);
44
+ }
45
+ }
46
+
29
47
  const sentinelRef = useRef<HTMLDivElement>(null);
30
48
 
31
49
  // Build RPC params with optional task_id filter
@@ -247,38 +265,46 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
247
265
  {composer}
248
266
  {filterChip}
249
267
  <div className="task-list">
250
- {entries.map((entry, i) => (
251
- <div
252
- key={`${entry.task_id}-${entry.run_id}-${i}`}
253
- className="sessions-card"
254
- onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
255
- >
256
- <div className="sessions-card-body">
257
- <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
258
- <div className="sessions-card-meta">
259
- {entry.running_state === "started" ? (
260
- <span className="status-spinner" aria-label="Running">
261
- <span />
262
- </span>
263
- ) : (
264
- <span style={{ color: stateColor(entry.running_state) }}>
265
- {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
266
- </span>
267
- )}
268
- {entry.end_time && <span>{formatTime(entry.end_time)}</span>}
269
- {entry.start_time && entry.end_time && (
270
- <span style={{ color: "var(--color-muted)" }}>
271
- {formatDuration(entry.start_time, entry.end_time)}
272
- </span>
273
- )}
274
- {entry.error && (
275
- <span style={{ color: "var(--color-muted)" }}>{entry.error}</span>
276
- )}
268
+ {entries.map((entry, i) => {
269
+ const key = `${entry.task_id}:${entry.run_id}`;
270
+ return (
271
+ <SwipeToDeleteRow
272
+ key={`${key}-${i}`}
273
+ id={key}
274
+ revealedId={revealedKey}
275
+ setRevealedId={setRevealedKey}
276
+ onDelete={() => deleteEntry(entry)}
277
+ onClick={() => !entry.error && handleCardClick(entry.task_id, entry.run_id)}
278
+ >
279
+ <div className="sessions-card">
280
+ <div className="sessions-card-body">
281
+ <h3 className="sessions-card-name">{entry.task_name || entry.task_id}</h3>
282
+ <div className="sessions-card-meta">
283
+ {entry.running_state === "started" ? (
284
+ <span className="status-spinner" aria-label="Running">
285
+ <span />
286
+ </span>
287
+ ) : (
288
+ <span style={{ color: stateColor(entry.running_state) }}>
289
+ {stateLabel[entry.running_state ?? ""] ?? entry.running_state}
290
+ </span>
291
+ )}
292
+ {entry.end_time && <span>{formatTime(entry.end_time)}</span>}
293
+ {entry.start_time && entry.end_time && (
294
+ <span style={{ color: "var(--color-muted)" }}>
295
+ {formatDuration(entry.start_time, entry.end_time)}
296
+ </span>
297
+ )}
298
+ {entry.error && (
299
+ <span style={{ color: "var(--color-muted)" }}>{entry.error}</span>
300
+ )}
301
+ </div>
302
+ </div>
303
+ <span className="sessions-card-chevron">&#8250;</span>
277
304
  </div>
278
- </div>
279
- <span className="sessions-card-chevron">&#8250;</span>
280
- </div>
281
- ))}
305
+ </SwipeToDeleteRow>
306
+ );
307
+ })}
282
308
 
283
309
  {/* Sentinel for infinite scroll */}
284
310
  <div ref={sentinelRef} style={{ height: 1 }} />