@chrysb/alphaclaw 0.7.2-beta.3 → 0.7.2-beta.4

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,16 +1,19 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
- import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
+ import { useCallback, useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
4
4
  import { ActionButton } from "../../action-button.js";
5
5
  import { Badge } from "../../badge.js";
6
6
  import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js";
7
7
  import { LoadingSpinner } from "../../loading-spinner.js";
8
8
  import { copyTextToClipboard } from "../../../lib/clipboard.js";
9
9
  import { fetchNodeBrowserStatusForNode } from "../../../lib/api.js";
10
+ import { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js";
10
11
  import { showToast } from "../../toast.js";
11
12
 
12
13
  const html = htm.bind(h);
13
14
  const kBrowserCheckTimeoutMs = 10000;
15
+ const kBrowserPollIntervalMs = 10000;
16
+ const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode";
14
17
 
15
18
  const escapeDoubleQuotes = (value) => String(value || "").replace(/"/g, '\\"');
16
19
 
@@ -78,6 +81,27 @@ const withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => {
78
81
  }
79
82
  };
80
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
+
81
105
  export const ConnectedNodesCard = ({
82
106
  nodes = [],
83
107
  pending = [],
@@ -88,7 +112,11 @@ export const ConnectedNodesCard = ({
88
112
  const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
89
113
  const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
90
114
  const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState("");
91
- const autoCheckedNodeIdsRef = useRef(new Set());
115
+ const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(() =>
116
+ readBrowserAttachStateByNode(),
117
+ );
118
+ const browserPollCursorRef = useRef(0);
119
+ const browserCheckInFlightNodeIdRef = useRef("");
92
120
 
93
121
  const handleCopyCommand = async (command) => {
94
122
  const copied = await copyTextToClipboard(command);
@@ -99,10 +127,13 @@ export const ConnectedNodesCard = ({
99
127
  showToast("Could not copy connection command", "error");
100
128
  };
101
129
 
102
- const handleCheckNodeBrowser = async (nodeId, { silent = false } = {}) => {
130
+ const handleCheckNodeBrowser = useCallback(async (nodeId, { silent = false } = {}) => {
103
131
  const normalizedNodeId = String(nodeId || "").trim();
104
- if (!normalizedNodeId || checkingBrowserNodeId) return;
105
- setCheckingBrowserNodeId(normalizedNodeId);
132
+ if (!normalizedNodeId || browserCheckInFlightNodeIdRef.current) return;
133
+ browserCheckInFlightNodeIdRef.current = normalizedNodeId;
134
+ if (!silent) {
135
+ setCheckingBrowserNodeId(normalizedNodeId);
136
+ }
106
137
  setBrowserErrorByNodeId((prev) => ({
107
138
  ...prev,
108
139
  [normalizedNodeId]: "",
@@ -126,22 +157,82 @@ export const ConnectedNodesCard = ({
126
157
  showToast(message, "error");
127
158
  }
128
159
  } finally {
129
- setCheckingBrowserNodeId("");
160
+ browserCheckInFlightNodeIdRef.current = "";
161
+ if (!silent) {
162
+ setCheckingBrowserNodeId("");
163
+ }
130
164
  }
131
- };
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]);
132
202
 
133
203
  useEffect(() => {
134
204
  if (checkingBrowserNodeId) return;
135
- for (const node of nodes) {
136
- const nodeId = String(node?.nodeId || "").trim();
137
- if (!nodeId) continue;
138
- if (!node?.connected || !isBrowserCapableNode(node)) continue;
139
- if (autoCheckedNodeIdsRef.current.has(nodeId)) continue;
140
- autoCheckedNodeIdsRef.current.add(nodeId);
141
- handleCheckNodeBrowser(nodeId, { silent: true });
142
- break;
143
- }
144
- }, [checkingBrowserNodeId, nodes]);
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]);
145
236
 
146
237
  return html`
147
238
  <div class="space-y-3">
@@ -197,9 +288,17 @@ export const ConnectedNodesCard = ({
197
288
  const checkingBrowser = checkingBrowserNodeId === nodeId;
198
289
  const canCheckBrowser =
199
290
  node?.connected && isBrowserCapableNode(node) && nodeId;
291
+ const browserAttachEnabled = browserAttachStateByNodeId?.[nodeId] === true;
200
292
  const hasBrowserCheckResult = !!browserStatus || !!browserError;
293
+ const browserAttached = browserStatus?.running === true;
294
+ const showResolvingSpinner =
295
+ browserAttachEnabled && !hasBrowserCheckResult && !checkingBrowser;
201
296
  const showBrowserCheckButton =
202
- canCheckBrowser && !checkingBrowser && !hasBrowserCheckResult;
297
+ canCheckBrowser &&
298
+ browserAttachEnabled &&
299
+ !checkingBrowser &&
300
+ hasBrowserCheckResult &&
301
+ !browserAttached;
203
302
  return html`
204
303
  <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
205
304
  <div class="flex items-center justify-between gap-2">
@@ -228,9 +327,21 @@ export const ConnectedNodesCard = ({
228
327
  <div class="flex items-start justify-between gap-2">
229
328
  <div class="space-y-0.5">
230
329
  <div class="text-sm font-medium">Browser</div>
231
- <div class="text-[11px] text-gray-500">
232
- profile: <code>user</code>
233
- </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
+ `}
234
345
  </div>
235
346
  <div class="flex items-start gap-2">
236
347
  ${browserStatus
@@ -242,11 +353,26 @@ export const ConnectedNodesCard = ({
242
353
  </span>
243
354
  `
244
355
  : null}
356
+ ${showResolvingSpinner
357
+ ? html`
358
+ <${LoadingSpinner} className="h-3.5 w-3.5" />
359
+ `
360
+ : null}
245
361
  ${checkingBrowser
246
362
  ? html`
247
363
  <${LoadingSpinner} className="h-3.5 w-3.5" />
248
364
  `
249
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}
250
376
  ${showBrowserCheckButton
251
377
  ? html`
252
378
  <${ActionButton}
@@ -261,10 +387,23 @@ export const ConnectedNodesCard = ({
261
387
  </div>
262
388
  ${browserStatus
263
389
  ? html`
264
- <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
265
- <span>tabs: <code>${Number(browserStatus?.tabCount || 0)}</code></span>
266
- <span>driver: <code>${browserStatus?.driver || "unknown"}</code></span>
267
- <span>transport: <code>${browserStatus?.transport || "unknown"}</code></span>
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>
394
+ </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}
268
407
  </div>
269
408
  `
270
409
  : null}
@@ -19,6 +19,8 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
19
19
  actions=${html`
20
20
  <${ActionButton}
21
21
  onClick=${actions.refreshNodes}
22
+ loading=${state.refreshingNodes}
23
+ loadingMode="inline"
22
24
  idleLabel="Refresh"
23
25
  tone="secondary"
24
26
  size="sm"
@@ -2,7 +2,7 @@ import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
3
  import { ModalShell } from "../../modal-shell.js";
4
4
  import { ActionButton } from "../../action-button.js";
5
- import { CloseIcon } from "../../icons.js";
5
+ import { CloseIcon, FileCopyLineIcon } from "../../icons.js";
6
6
  import { copyTextToClipboard } from "../../../lib/clipboard.js";
7
7
  import { showToast } from "../../toast.js";
8
8
  import { useSetupWizard } from "./use-setup-wizard.js";
@@ -23,8 +23,9 @@ const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
23
23
  <button
24
24
  type="button"
25
25
  onclick=${onCopy}
26
- class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
26
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost inline-flex items-center gap-1.5"
27
27
  >
28
+ <${FileCopyLineIcon} className="w-3.5 h-3.5" />
28
29
  Copy
29
30
  </button>
30
31
  </div>
@@ -57,6 +58,7 @@ export const NodesSetupWizard = ({
57
58
  onClose,
58
59
  });
59
60
  const isFinalStep = state.step === kWizardSteps.length - 1;
61
+ const canApproveSelectedNode = state.selectedNode?.paired === false;
60
62
 
61
63
  return html`
62
64
  <${ModalShell}
@@ -125,6 +127,13 @@ export const NodesSetupWizard = ({
125
127
  onCopy: () =>
126
128
  copyAndToast(state.connectCommand || "", "command"),
127
129
  })}
130
+ <div class="rounded-lg border border-border bg-black/20 px-3 py-2 text-xs text-gray-400">
131
+ ${state.pendingSelectableNodes.length
132
+ ? `${state.pendingSelectableNodes.length} pending node${state.pendingSelectableNodes.length === 1
133
+ ? ""
134
+ : "s"} detected. Continue to Step 3 to approve.`
135
+ : "Pairing requests will show up here. Checks every 3s."}
136
+ </div>
128
137
  </div>
129
138
  `
130
139
  : null}
@@ -135,6 +144,13 @@ export const NodesSetupWizard = ({
135
144
  <div class="text-xs text-gray-500">
136
145
  Select the node to approve after you run the connect command.
137
146
  </div>
147
+ <div class="text-xs text-gray-500">
148
+ This list refreshes automatically every
149
+ ${" "}
150
+ <code>${Math.round(state.nodeDiscoveryPollIntervalMs / 1000)}s</code>
151
+ ${" "}
152
+ while this step is open.
153
+ </div>
138
154
  <div class="flex items-center gap-2">
139
155
  <select
140
156
  value=${state.selectedNodeId}
@@ -149,7 +165,10 @@ export const NodesSetupWizard = ({
149
165
  ${state.selectableNodes.map(
150
166
  (entry) => html`
151
167
  <option value=${entry.nodeId}>
152
- ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)
168
+ ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)${entry.paired ===
169
+ false
170
+ ? " · pending approval"
171
+ : " · already paired"}
153
172
  </option>
154
173
  `,
155
174
  )}
@@ -161,6 +180,17 @@ export const NodesSetupWizard = ({
161
180
  size="sm"
162
181
  />
163
182
  </div>
183
+ ${state.selectedNodeId && !canApproveSelectedNode
184
+ ? html`
185
+ <div class="text-xs text-yellow-300">
186
+ This node is already paired. Pick a
187
+ ${" "}
188
+ <code>pending approval</code>
189
+ ${" "}
190
+ node to continue.
191
+ </div>
192
+ `
193
+ : null}
164
194
  </div>
165
195
  `
166
196
  : null}
@@ -234,7 +264,8 @@ export const NodesSetupWizard = ({
234
264
  tone="primary"
235
265
  size="md"
236
266
  className="w-full justify-center"
237
- disabled=${state.step === 2 && !state.selectedNodeId}
267
+ disabled=${state.step === 2 &&
268
+ (!state.selectedNodeId || !canApproveSelectedNode)}
238
269
  />
239
270
  `}
240
271
  </div>
@@ -1,4 +1,10 @@
1
- import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
2
8
  import {
3
9
  approveNode,
4
10
  fetchNodeConnectInfo,
@@ -6,6 +12,8 @@ import {
6
12
  } from "../../../lib/api.js";
7
13
  import { showToast } from "../../toast.js";
8
14
 
15
+ const kNodeDiscoveryPollIntervalMs = 3000;
16
+
9
17
  export const useSetupWizard = ({
10
18
  visible = false,
11
19
  nodes = [],
@@ -21,6 +29,7 @@ export const useSetupWizard = ({
21
29
  const [selectedNodeId, setSelectedNodeId] = useState("");
22
30
  const [approvingNodeId, setApprovingNodeId] = useState("");
23
31
  const [configuring, setConfiguring] = useState(false);
32
+ const refreshInFlightRef = useRef(false);
24
33
 
25
34
  useEffect(() => {
26
35
  if (!visible) return;
@@ -46,7 +55,10 @@ export const useSetupWizard = ({
46
55
  }, [visible]);
47
56
 
48
57
  const selectableNodes = useMemo(() => {
49
- const all = [...pending, ...nodes];
58
+ const all = [
59
+ ...pending.map((entry) => ({ ...entry, pendingApproval: true })),
60
+ ...nodes.map((entry) => ({ ...entry, pendingApproval: false })),
61
+ ];
50
62
  const seen = new Set();
51
63
  const unique = [];
52
64
  for (const entry of all) {
@@ -56,13 +68,18 @@ export const useSetupWizard = ({
56
68
  unique.push({
57
69
  nodeId,
58
70
  displayName: String(entry?.displayName || entry?.name || nodeId),
59
- paired: entry?.paired !== false,
71
+ paired: entry?.pendingApproval ? false : entry?.paired !== false,
60
72
  connected: entry?.connected === true,
61
73
  });
62
74
  }
63
75
  return unique;
64
76
  }, [nodes, pending]);
65
77
 
78
+ const pendingSelectableNodes = useMemo(
79
+ () => selectableNodes.filter((entry) => entry.paired === false),
80
+ [selectableNodes],
81
+ );
82
+
66
83
  const selectedNode = useMemo(
67
84
  () =>
68
85
  selectableNodes.find(
@@ -93,9 +110,44 @@ export const useSetupWizard = ({
93
110
  }, [connectInfo, displayName]);
94
111
 
95
112
  const refreshNodeList = useCallback(async () => {
96
- await refreshNodes();
113
+ if (refreshInFlightRef.current) return;
114
+ refreshInFlightRef.current = true;
115
+ try {
116
+ await refreshNodes();
117
+ } finally {
118
+ refreshInFlightRef.current = false;
119
+ }
97
120
  }, [refreshNodes]);
98
121
 
122
+ useEffect(() => {
123
+ if (!visible || (step !== 1 && step !== 2)) return;
124
+ let active = true;
125
+ const poll = async () => {
126
+ if (!active) return;
127
+ try {
128
+ await refreshNodeList();
129
+ } catch {}
130
+ };
131
+ poll();
132
+ const timer = setInterval(poll, kNodeDiscoveryPollIntervalMs);
133
+ return () => {
134
+ active = false;
135
+ clearInterval(timer);
136
+ };
137
+ }, [refreshNodeList, step, visible]);
138
+
139
+ useEffect(() => {
140
+ if (!visible || step !== 2) return;
141
+ const hasSelected = selectableNodes.some(
142
+ (entry) => entry.nodeId === String(selectedNodeId || "").trim(),
143
+ );
144
+ if (hasSelected) return;
145
+ const preferredNode =
146
+ pendingSelectableNodes.find((entry) => entry.paired === false) || selectableNodes[0];
147
+ if (!preferredNode) return;
148
+ setSelectedNodeId(preferredNode.nodeId);
149
+ }, [pendingSelectableNodes, selectableNodes, selectedNodeId, step, visible]);
150
+
99
151
  const approveSelectedNode = useCallback(async () => {
100
152
  const nodeId = String(selectedNodeId || "").trim();
101
153
  if (!nodeId || approvingNodeId) return false;
@@ -150,10 +202,12 @@ export const useSetupWizard = ({
150
202
  setSelectedNodeId,
151
203
  selectedNode,
152
204
  selectableNodes,
205
+ pendingSelectableNodes,
153
206
  approvingNodeId,
154
207
  configuring,
155
208
  connectCommand,
156
209
  refreshNodeList,
210
+ nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs,
157
211
  approveSelectedNode,
158
212
  applyGatewayNodeRouting,
159
213
  completeWizard,
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "https://esm.sh/preact/hooks";
1
+ import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
2
  import { fetchNodeConnectInfo } from "../../lib/api.js";
3
3
  import { showToast } from "../toast.js";
4
4
  import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js";
@@ -7,6 +7,7 @@ export const useNodesTab = () => {
7
7
  const connectedNodesState = useConnectedNodes({ enabled: true });
8
8
  const [wizardVisible, setWizardVisible] = useState(false);
9
9
  const [connectInfo, setConnectInfo] = useState(null);
10
+ const [refreshingNodes, setRefreshingNodes] = useState(false);
10
11
 
11
12
  useEffect(() => {
12
13
  fetchNodeConnectInfo()
@@ -18,19 +19,30 @@ export const useNodesTab = () => {
18
19
  });
19
20
  }, []);
20
21
 
22
+ const refreshNodes = useCallback(async () => {
23
+ if (refreshingNodes) return;
24
+ setRefreshingNodes(true);
25
+ try {
26
+ await connectedNodesState.refresh();
27
+ } finally {
28
+ setRefreshingNodes(false);
29
+ }
30
+ }, [connectedNodesState.refresh, refreshingNodes]);
31
+
21
32
  return {
22
33
  state: {
23
34
  wizardVisible,
24
35
  nodes: connectedNodesState.nodes,
25
36
  pending: connectedNodesState.pending,
26
37
  loadingNodes: connectedNodesState.loading,
38
+ refreshingNodes,
27
39
  nodesError: connectedNodesState.error,
28
40
  connectInfo,
29
41
  },
30
42
  actions: {
31
43
  openWizard: () => setWizardVisible(true),
32
44
  closeWizard: () => setWizardVisible(false),
33
- refreshNodes: connectedNodesState.refresh,
45
+ refreshNodes,
34
46
  },
35
47
  };
36
48
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.7.2-beta.3",
3
+ "version": "0.7.2-beta.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },