@chrysb/alphaclaw 0.7.2-beta.5 → 0.7.2-beta.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.
@@ -1,19 +1,14 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
- import { useCallback, useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
4
3
  import { ActionButton } from "../../action-button.js";
5
4
  import { Badge } from "../../badge.js";
5
+ import { ConfirmDialog } from "../../confirm-dialog.js";
6
6
  import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js";
7
7
  import { LoadingSpinner } from "../../loading-spinner.js";
8
- import { copyTextToClipboard } from "../../../lib/clipboard.js";
9
- import { fetchNodeBrowserStatusForNode } from "../../../lib/api.js";
10
- import { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js";
11
- import { showToast } from "../../toast.js";
8
+ import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
9
+ import { useConnectedNodesCard } from "./use-connected-nodes-card.js";
12
10
 
13
11
  const html = htm.bind(h);
14
- const kBrowserCheckTimeoutMs = 10000;
15
- const kBrowserPollIntervalMs = 10000;
16
- const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode";
17
12
 
18
13
  const escapeDoubleQuotes = (value) => String(value || "").replace(/"/g, '\\"');
19
14
 
@@ -22,7 +17,9 @@ const buildReconnectCommand = ({ node, connectInfo, maskToken = false }) => {
22
17
  const port = Number(connectInfo?.gatewayPort) || 3000;
23
18
  const token = String(connectInfo?.gatewayToken || "").trim();
24
19
  const tlsFlag = connectInfo?.tls === true ? "--tls" : "";
25
- const displayName = String(node?.displayName || node?.nodeId || "My Node").trim();
20
+ const displayName = String(
21
+ node?.displayName || node?.nodeId || "My Node",
22
+ ).trim();
26
23
  const tokenValue = maskToken ? "****" : token;
27
24
 
28
25
  return [
@@ -63,236 +60,99 @@ const getBrowserStatusLabel = (status) => {
63
60
  return "Not connected";
64
61
  };
65
62
 
66
- const withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => {
67
- let timeoutId = null;
68
- try {
69
- return await Promise.race([
70
- promise,
71
- new Promise((_, reject) => {
72
- timeoutId = setTimeout(() => {
73
- reject(new Error("Browser check timed out"));
74
- }, timeoutMs);
75
- }),
76
- ]);
77
- } finally {
78
- if (timeoutId) {
79
- clearTimeout(timeoutId);
80
- }
81
- }
82
- };
83
-
84
- const readBrowserAttachStateByNode = () => {
85
- const uiSettings = readUiSettings();
86
- const attachState = uiSettings?.[kBrowserAttachStateByNodeKey];
87
- if (!attachState || typeof attachState !== "object" || Array.isArray(attachState)) {
88
- return {};
89
- }
90
- return attachState;
91
- };
92
-
93
- const writeBrowserAttachStateByNode = (nextState = {}) => {
94
- updateUiSettings((currentSettings) => {
95
- const nextSettings =
96
- currentSettings && typeof currentSettings === "object" ? currentSettings : {};
97
- return {
98
- ...nextSettings,
99
- [kBrowserAttachStateByNodeKey]:
100
- nextState && typeof nextState === "object" ? nextState : {},
101
- };
102
- });
103
- };
104
-
105
63
  export const ConnectedNodesCard = ({
106
64
  nodes = [],
107
65
  pending = [],
108
66
  loading = false,
109
67
  error = "",
110
68
  connectInfo = null,
69
+ onRefreshNodes = async () => {},
111
70
  }) => {
112
- const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
113
- const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
114
- const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState("");
115
- const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(() =>
116
- readBrowserAttachStateByNode(),
117
- );
118
- const browserPollCursorRef = useRef(0);
119
- const browserCheckInFlightNodeIdRef = useRef("");
120
-
121
- const handleCopyCommand = async (command) => {
122
- const copied = await copyTextToClipboard(command);
123
- if (copied) {
124
- showToast("Connection command copied", "success");
125
- return;
126
- }
127
- showToast("Could not copy connection command", "error");
128
- };
129
-
130
- const handleCheckNodeBrowser = useCallback(async (nodeId, { silent = false } = {}) => {
131
- const normalizedNodeId = String(nodeId || "").trim();
132
- if (!normalizedNodeId || browserCheckInFlightNodeIdRef.current) return;
133
- browserCheckInFlightNodeIdRef.current = normalizedNodeId;
134
- if (!silent) {
135
- setCheckingBrowserNodeId(normalizedNodeId);
136
- }
137
- setBrowserErrorByNodeId((prev) => ({
138
- ...prev,
139
- [normalizedNodeId]: "",
140
- }));
141
- try {
142
- const result = await withTimeout(
143
- fetchNodeBrowserStatusForNode(normalizedNodeId, "user"),
144
- );
145
- const status = result?.status && typeof result.status === "object" ? result.status : null;
146
- setBrowserStatusByNodeId((prev) => ({
147
- ...prev,
148
- [normalizedNodeId]: status,
149
- }));
150
- } catch (error) {
151
- const message = error.message || "Could not check node browser status";
152
- setBrowserErrorByNodeId((prev) => ({
153
- ...prev,
154
- [normalizedNodeId]: message,
155
- }));
156
- if (!silent) {
157
- showToast(message, "error");
158
- }
159
- } finally {
160
- browserCheckInFlightNodeIdRef.current = "";
161
- if (!silent) {
162
- setCheckingBrowserNodeId("");
163
- }
164
- }
165
- }, []);
166
-
167
- const setBrowserAttachStateForNode = useCallback((nodeId, enabled) => {
168
- const normalizedNodeId = String(nodeId || "").trim();
169
- if (!normalizedNodeId) return;
170
- setBrowserAttachStateByNodeId((prevState) => {
171
- const nextState = {
172
- ...(prevState && typeof prevState === "object" ? prevState : {}),
173
- [normalizedNodeId]: enabled === true,
174
- };
175
- writeBrowserAttachStateByNode(nextState);
176
- return nextState;
177
- });
178
- }, []);
179
-
180
- const handleAttachNodeBrowser = useCallback(async (nodeId) => {
181
- const normalizedNodeId = String(nodeId || "").trim();
182
- if (!normalizedNodeId) return;
183
- setBrowserAttachStateForNode(normalizedNodeId, true);
184
- await handleCheckNodeBrowser(normalizedNodeId);
185
- }, [handleCheckNodeBrowser, setBrowserAttachStateForNode]);
186
-
187
- const handleDetachNodeBrowser = useCallback((nodeId) => {
188
- const normalizedNodeId = String(nodeId || "").trim();
189
- if (!normalizedNodeId) return;
190
- setBrowserAttachStateForNode(normalizedNodeId, false);
191
- setBrowserStatusByNodeId((prevState) => {
192
- const nextState = { ...(prevState || {}) };
193
- delete nextState[normalizedNodeId];
194
- return nextState;
195
- });
196
- setBrowserErrorByNodeId((prevState) => {
197
- const nextState = { ...(prevState || {}) };
198
- delete nextState[normalizedNodeId];
199
- return nextState;
200
- });
201
- }, [setBrowserAttachStateForNode]);
202
-
203
- useEffect(() => {
204
- if (checkingBrowserNodeId) return;
205
- const pollableNodeIds = nodes
206
- .map((node) => ({
207
- nodeId: String(node?.nodeId || "").trim(),
208
- connected: node?.connected === true,
209
- browserCapable: isBrowserCapableNode(node),
210
- }))
211
- .filter(
212
- (entry) =>
213
- entry.nodeId &&
214
- entry.connected &&
215
- entry.browserCapable &&
216
- browserAttachStateByNodeId?.[entry.nodeId] === true,
217
- )
218
- .map((entry) => entry.nodeId);
219
- if (!pollableNodeIds.length) return;
220
-
221
- let active = true;
222
- const poll = async () => {
223
- if (!active || browserCheckInFlightNodeIdRef.current) return;
224
- const pollIndex = browserPollCursorRef.current % pollableNodeIds.length;
225
- browserPollCursorRef.current += 1;
226
- const nextNodeId = pollableNodeIds[pollIndex];
227
- await handleCheckNodeBrowser(nextNodeId, { silent: true });
228
- };
229
- poll();
230
- const timer = setInterval(poll, kBrowserPollIntervalMs);
231
- return () => {
232
- active = false;
233
- clearInterval(timer);
234
- };
235
- }, [browserAttachStateByNodeId, handleCheckNodeBrowser, nodes]);
71
+ const state = useConnectedNodesCard({ nodes, onRefreshNodes });
72
+ const {
73
+ browserStatusByNodeId,
74
+ browserErrorByNodeId,
75
+ checkingBrowserNodeId,
76
+ browserAttachStateByNodeId,
77
+ menuOpenNodeId,
78
+ removeDialogNode,
79
+ removingNodeId,
80
+ handleCopyText,
81
+ handleCheckNodeBrowser,
82
+ handleAttachNodeBrowser,
83
+ handleDetachNodeBrowser,
84
+ handleOpenNodeMenu,
85
+ handleRemoveNode,
86
+ setMenuOpenNodeId,
87
+ setRemoveDialogNode,
88
+ } = state;
236
89
 
237
90
  return html`
238
- <div class="space-y-3">
239
- ${pending.length
240
- ? html`
241
- <div class="bg-surface border border-yellow-500/40 rounded-xl px-4 py-3 text-xs text-yellow-300">
242
- ${pending.length} pending node${pending.length === 1 ? "" : "s"} waiting for approval.
243
- </div>
244
- `
245
- : null}
246
-
247
- ${loading
248
- ? html`
249
- <div class="bg-surface border border-border rounded-xl p-4">
250
- <div class="flex items-center gap-3 text-sm text-gray-400">
251
- <${LoadingSpinner} className="h-4 w-4" />
252
- <span>Loading nodes...</span>
91
+ <div class="space-y-3">
92
+ ${pending.length
93
+ ? html`
94
+ <div
95
+ class="bg-surface border border-yellow-500/40 rounded-xl px-4 py-3 text-xs text-yellow-300"
96
+ >
97
+ ${pending.length} pending node${pending.length === 1 ? "" : "s"}
98
+ waiting for approval.
253
99
  </div>
254
- </div>
255
- `
256
- : error
100
+ `
101
+ : null}
102
+ ${loading
257
103
  ? html`
258
- <div class="bg-surface border border-border rounded-xl p-4 text-xs text-red-400">
259
- ${error}
104
+ <div class="bg-surface border border-border rounded-xl p-4">
105
+ <div class="flex items-center gap-3 text-sm text-gray-400">
106
+ <${LoadingSpinner} className="h-4 w-4" />
107
+ <span>Loading nodes...</span>
108
+ </div>
260
109
  </div>
261
110
  `
262
- : !nodes.length
111
+ : error
263
112
  ? html`
264
113
  <div
265
- class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
114
+ class="bg-surface border border-border rounded-xl p-4 text-xs text-red-400"
266
115
  >
267
- <div class="max-w-md w-full flex flex-col items-center gap-4">
268
- <${ComputerLineIcon} className="h-12 w-12 text-cyan-400" />
269
- <div class="space-y-2">
270
- <h2 class="font-semibold text-lg text-gray-100">
271
- No connected nodes yet
272
- </h2>
273
- <p class="text-xs text-gray-400 leading-5">
274
- Connect a Mac, iOS, Android, or headless node to run
275
- system and browser commands through this gateway.
276
- </p>
277
- </div>
278
- </div>
116
+ ${error}
279
117
  </div>
280
118
  `
281
- : html`
282
- <div class="space-y-2">
283
- ${nodes.map(
284
- (node) => {
119
+ : !nodes.length
120
+ ? html`
121
+ <div
122
+ class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
123
+ >
124
+ <div class="max-w-md w-full flex flex-col items-center gap-4">
125
+ <${ComputerLineIcon} className="h-12 w-12 text-cyan-400" />
126
+ <div class="space-y-2">
127
+ <h2 class="font-semibold text-lg text-gray-100">
128
+ No connected nodes yet
129
+ </h2>
130
+ <p class="text-xs text-gray-400 leading-5">
131
+ Connect a Mac, iOS, Android, or headless node to run
132
+ system and browser commands through this gateway.
133
+ </p>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ `
138
+ : html`
139
+ <div class="space-y-2">
140
+ ${nodes.map((node) => {
285
141
  const nodeId = String(node?.nodeId || "").trim();
286
142
  const browserStatus = browserStatusByNodeId[nodeId] || null;
287
143
  const browserError = browserErrorByNodeId[nodeId] || "";
288
144
  const checkingBrowser = checkingBrowserNodeId === nodeId;
289
145
  const canCheckBrowser =
290
146
  node?.connected && isBrowserCapableNode(node) && nodeId;
291
- const browserAttachEnabled = browserAttachStateByNodeId?.[nodeId] === true;
292
- const hasBrowserCheckResult = !!browserStatus || !!browserError;
147
+ const browserAttachEnabled =
148
+ browserAttachStateByNodeId?.[nodeId] === true;
149
+ const hasBrowserCheckResult =
150
+ !!browserStatus || !!browserError;
293
151
  const browserAttached = browserStatus?.running === true;
294
152
  const showResolvingSpinner =
295
- browserAttachEnabled && !hasBrowserCheckResult && !checkingBrowser;
153
+ browserAttachEnabled &&
154
+ !hasBrowserCheckResult &&
155
+ !checkingBrowser;
296
156
  const showBrowserCheckButton =
297
157
  canCheckBrowser &&
298
158
  browserAttachEnabled &&
@@ -300,164 +160,281 @@ export const ConnectedNodesCard = ({
300
160
  hasBrowserCheckResult &&
301
161
  !browserAttached;
302
162
  return html`
303
- <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
304
- <div class="flex items-center justify-between gap-2">
305
- <div class="min-w-0">
306
- <div class="text-sm font-medium truncate">
307
- ${node?.displayName || node?.nodeId || "Unnamed node"}
163
+ <div
164
+ class="bg-surface border border-border rounded-xl p-4 space-y-2"
165
+ >
166
+ <div class="flex items-center justify-between gap-2">
167
+ <div class="min-w-0 space-y-1">
168
+ <div class="flex items-center gap-2 min-w-0 mb-2">
169
+ <div class="text-sm font-medium truncate">
170
+ ${node?.displayName ||
171
+ node?.nodeId ||
172
+ "Unnamed node"}
173
+ </div>
174
+ ${nodeId
175
+ ? html`
176
+ <button
177
+ type="button"
178
+ class="shrink-0 inline-flex items-center gap-1 text-[11px] text-gray-500 hover:text-gray-300"
179
+ onclick=${() =>
180
+ handleCopyText(nodeId, {
181
+ successMessage: "Device ID copied",
182
+ errorMessage:
183
+ "Could not copy device ID",
184
+ })}
185
+ >
186
+ <${FileCopyLineIcon}
187
+ className="w-3.5 h-3.5"
188
+ />
189
+ <span>Copy device id</span>
190
+ </button>
191
+ `
192
+ : null}
193
+ </div>
308
194
  </div>
309
- <div class="text-[11px] text-gray-500 font-mono truncate">
310
- ${node?.nodeId || ""}
195
+ <div class="flex items-center gap-1.5">
196
+ ${renderNodeStatusBadge(node)}
197
+ ${node?.paired
198
+ ? html`
199
+ <${OverflowMenu}
200
+ open=${menuOpenNodeId === nodeId}
201
+ ariaLabel="Open node actions"
202
+ title="Open node actions"
203
+ onClose=${() => setMenuOpenNodeId("")}
204
+ onToggle=${() => handleOpenNodeMenu(nodeId)}
205
+ >
206
+ <${OverflowMenuItem}
207
+ className="text-red-300 hover:text-red-200"
208
+ onClick=${() => {
209
+ setMenuOpenNodeId("");
210
+ setRemoveDialogNode(node);
211
+ }}
212
+ >
213
+ Remove device
214
+ </${OverflowMenuItem}>
215
+ </${OverflowMenu}>
216
+ `
217
+ : null}
311
218
  </div>
312
219
  </div>
313
- ${renderNodeStatusBadge(node)}
314
- </div>
315
- <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
316
- <span>platform: <code>${node?.platform || "unknown"}</code></span>
317
- <span>version: <code>${node?.version || "unknown"}</code></span>
318
- <span>
319
- caps:
320
- <code>${Array.isArray(node?.caps) ? node.caps.join(", ") : "none"}</code>
321
- </span>
322
- </div>
323
- ${canCheckBrowser
324
- ? html`
325
- <div class="space-y-2">
326
- <div class="ac-surface-inset rounded-lg px-3 py-2 space-y-2">
327
- <div class="flex items-start justify-between gap-2">
328
- <div class="space-y-0.5">
329
- <div class="text-sm font-medium">Browser</div>
330
- ${browserAttachEnabled
331
- ? html`
332
- <div class="text-[11px] text-gray-500">
333
- profile: <code>user</code>
334
- </div>
335
- `
336
- : html`
337
- <div class="text-[11px] text-gray-500">
338
- Attach is disabled until you click
339
- ${" "}
340
- <code>Attach</code>
341
- ${" "}
342
- (prevents control prompts when opening this tab).
343
- </div>
344
- `}
345
- </div>
346
- <div class="flex items-start gap-2">
347
- ${browserStatus
348
- ? html`
220
+ <div class="flex flex-wrap gap-2 text-[11px]">
221
+ <div class="ac-surface-inset rounded-lg px-2.5 py-1">
222
+ <span class="text-gray-500">platform: </span>
223
+ <code>${node?.platform || "unknown"}</code>
224
+ </div>
225
+ <div class="ac-surface-inset rounded-lg px-2.5 py-1">
226
+ <span class="text-gray-500">version: </span>
227
+ <code>${node?.version || "unknown"}</code>
228
+ </div>
229
+ <div class="ac-surface-inset rounded-lg px-2.5 py-1">
230
+ <span class="text-gray-500">capabilities: </span>
231
+ <code
232
+ >${Array.isArray(node?.caps)
233
+ ? node.caps.join(", ")
234
+ : "none"}</code
235
+ >
236
+ </div>
237
+ </div>
238
+ ${canCheckBrowser
239
+ ? html`
240
+ <div class="space-y-2">
241
+ <div
242
+ class="ac-surface-inset rounded-lg px-3 py-2 space-y-2"
243
+ >
244
+ <div
245
+ class="flex items-start justify-between gap-2"
246
+ >
247
+ <div class="space-y-0.5">
248
+ <div class="text-sm font-medium">
249
+ Browser
250
+ </div>
251
+ ${browserAttachEnabled
252
+ ? html`
253
+ <div
254
+ class="text-[11px] text-gray-500"
255
+ >
256
+ profile: <code>user</code>
257
+ </div>
258
+ `
259
+ : html`
260
+ <div
261
+ class="text-[11px] text-gray-500"
262
+ >
263
+ Attach is disabled until you click
264
+ ${" "}
265
+ <code>Attach</code>
266
+ ${" "} (prevents control prompts
267
+ when opening this tab).
268
+ </div>
269
+ `}
270
+ </div>
271
+ <div class="flex items-start gap-2">
272
+ ${browserStatus
273
+ ? html`
349
274
  <span class="inline-flex mt-0.5">
350
275
  <${Badge} tone=${getBrowserStatusTone(browserStatus)}
351
276
  >${getBrowserStatusLabel(browserStatus)}</${Badge}
352
277
  >
353
278
  </span>
354
279
  `
355
- : null}
356
- ${showResolvingSpinner
357
- ? html`
358
- <${LoadingSpinner} className="h-3.5 w-3.5" />
359
- `
360
- : null}
361
- ${checkingBrowser
362
- ? html`
363
- <${LoadingSpinner} className="h-3.5 w-3.5" />
364
- `
365
- : null}
366
- ${canCheckBrowser && !browserAttachEnabled
367
- ? html`
368
- <${ActionButton}
369
- onClick=${() => handleAttachNodeBrowser(nodeId)}
370
- idleLabel="Attach"
371
- tone="primary"
372
- size="sm"
373
- />
374
- `
375
- : null}
376
- ${showBrowserCheckButton
377
- ? html`
378
- <${ActionButton}
379
- onClick=${() => handleCheckNodeBrowser(nodeId)}
380
- idleLabel="Check"
381
- tone="secondary"
382
- size="sm"
383
- />
384
- `
385
- : null}
280
+ : null}
281
+ ${showResolvingSpinner
282
+ ? html`
283
+ <${LoadingSpinner}
284
+ className="h-3.5 w-3.5"
285
+ />
286
+ `
287
+ : null}
288
+ ${checkingBrowser
289
+ ? html`
290
+ <${LoadingSpinner}
291
+ className="h-3.5 w-3.5"
292
+ />
293
+ `
294
+ : null}
295
+ ${canCheckBrowser && !browserAttachEnabled
296
+ ? html`
297
+ <${ActionButton}
298
+ onClick=${() =>
299
+ handleAttachNodeBrowser(nodeId)}
300
+ idleLabel="Attach"
301
+ tone="primary"
302
+ size="sm"
303
+ />
304
+ `
305
+ : null}
306
+ ${showBrowserCheckButton
307
+ ? html`
308
+ <${ActionButton}
309
+ onClick=${() =>
310
+ handleCheckNodeBrowser(nodeId)}
311
+ idleLabel="Check"
312
+ tone="secondary"
313
+ size="sm"
314
+ />
315
+ `
316
+ : null}
317
+ </div>
386
318
  </div>
387
- </div>
388
- ${browserStatus
389
- ? html`
390
- <div class="flex items-center justify-between gap-2">
391
- <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
392
- <span>driver: <code>${browserStatus?.driver || "unknown"}</code></span>
393
- <span>transport: <code>${browserStatus?.transport || "unknown"}</code></span>
319
+ ${browserStatus
320
+ ? html`
321
+ <div
322
+ class="flex items-center justify-between gap-2"
323
+ >
324
+ <div
325
+ class="flex flex-wrap gap-2 text-[11px] text-gray-500"
326
+ >
327
+ <span
328
+ >driver:
329
+ <code
330
+ >${browserStatus?.driver ||
331
+ "unknown"}</code
332
+ ></span
333
+ >
334
+ <span
335
+ >transport:
336
+ <code
337
+ >${browserStatus?.transport ||
338
+ "unknown"}</code
339
+ ></span
340
+ >
341
+ </div>
342
+ ${browserAttachEnabled
343
+ ? html`
344
+ <button
345
+ type="button"
346
+ onclick=${() =>
347
+ handleDetachNodeBrowser(
348
+ nodeId,
349
+ )}
350
+ class="shrink-0 text-[11px] text-gray-500 hover:text-gray-300"
351
+ >
352
+ Detach
353
+ </button>
354
+ `
355
+ : null}
394
356
  </div>
395
- ${browserAttachEnabled
396
- ? html`
397
- <button
398
- type="button"
399
- onclick=${() =>
400
- handleDetachNodeBrowser(nodeId)}
401
- class="shrink-0 text-[11px] text-gray-500 hover:text-gray-300"
402
- >
403
- Detach
404
- </button>
405
- `
406
- : null}
407
- </div>
408
- `
409
- : null}
410
- ${browserError
411
- ? html`<div class="text-[11px] text-red-400">${browserError}</div>`
412
- : null}
413
- </div>
414
- </div>
415
- `
416
- : null}
417
- ${node?.paired && !node?.connected && connectInfo
418
- ? html`
419
- <div class="border-t border-border pt-2 space-y-2">
420
- <div class="text-[11px] text-gray-500">
421
- Reconnect command
357
+ `
358
+ : null}
359
+ ${browserError
360
+ ? html`<div
361
+ class="text-[11px] text-red-400"
362
+ >
363
+ ${browserError}
364
+ </div>`
365
+ : null}
366
+ </div>
422
367
  </div>
423
- <div class="flex items-center gap-2">
424
- <input
425
- type="text"
426
- readonly
427
- value=${buildReconnectCommand({
428
- node,
429
- connectInfo,
430
- maskToken: true,
431
- })}
432
- class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-gray-300"
433
- />
434
- <${ActionButton}
435
- onClick=${() =>
436
- handleCopyCommand(
437
- buildReconnectCommand({
438
- node,
439
- connectInfo,
440
- maskToken: false,
441
- }),
442
- )}
443
- tone="secondary"
444
- size="sm"
445
- iconOnly=${true}
446
- idleIcon=${FileCopyLineIcon}
447
- idleIconClassName="w-3.5 h-3.5"
448
- ariaLabel="Copy reconnect command"
449
- title="Copy reconnect command"
450
- />
368
+ `
369
+ : null}
370
+ ${node?.paired && !node?.connected && connectInfo
371
+ ? html`
372
+ <div
373
+ class="border-t border-border pt-2 space-y-2"
374
+ >
375
+ <div class="text-[11px] text-gray-500">
376
+ Reconnect command
377
+ </div>
378
+ <div class="flex items-center gap-2">
379
+ <input
380
+ type="text"
381
+ readonly
382
+ value=${buildReconnectCommand({
383
+ node,
384
+ connectInfo,
385
+ maskToken: true,
386
+ })}
387
+ class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-gray-300"
388
+ />
389
+ <${ActionButton}
390
+ onClick=${() =>
391
+ handleCopyText(
392
+ buildReconnectCommand({
393
+ node,
394
+ connectInfo,
395
+ maskToken: false,
396
+ }),
397
+ {
398
+ successMessage:
399
+ "Connection command copied",
400
+ errorMessage:
401
+ "Could not copy connection command",
402
+ },
403
+ )}
404
+ tone="secondary"
405
+ size="sm"
406
+ iconOnly=${true}
407
+ idleIcon=${FileCopyLineIcon}
408
+ idleIconClassName="w-3.5 h-3.5"
409
+ ariaLabel="Copy reconnect command"
410
+ title="Copy reconnect command"
411
+ />
412
+ </div>
451
413
  </div>
452
- </div>
453
- `
454
- : null}
455
- </div>
456
- `;
457
- },
458
- )}
459
- </div>
460
- `}
461
- </div>
462
- `;
414
+ `
415
+ : null}
416
+ </div>
417
+ `;
418
+ })}
419
+ </div>
420
+ `}
421
+ </div>
422
+ <${ConfirmDialog}
423
+ visible=${!!removeDialogNode}
424
+ title="Remove device?"
425
+ message=${removeDialogNode?.connected
426
+ ? "This device is currently connected. Removing it will disconnect and remove the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."
427
+ : "This removes the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."}
428
+ confirmLabel="Remove device"
429
+ confirmLoadingLabel="Removing..."
430
+ confirmTone="warning"
431
+ confirmLoading=${Boolean(removingNodeId)}
432
+ confirmDisabled=${Boolean(removingNodeId)}
433
+ onCancel=${() => {
434
+ if (removingNodeId) return;
435
+ setRemoveDialogNode(null);
436
+ }}
437
+ onConfirm=${handleRemoveNode}
438
+ />
439
+ `;
463
440
  };