@gemboss/ui 0.25.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +276 -0
- package/README.md +370 -24
- package/catalog/editor.html +1 -1
- package/catalog/index.html +912 -63
- package/gemboss-ui.css +3 -3
- package/package.json +14 -2
- package/parts/components.css +2 -2
- package/parts/core.css +1 -1
- package/parts/shell.css +1 -1
- package/src/action-card.css +1 -0
- package/src/action-card.tsx +149 -0
- package/src/assistant/bridge.tsx +636 -0
- package/src/assistant/chat-markdown.ts +280 -0
- package/src/assistant/chat-turns.ts +120 -0
- package/src/assistant/chat.tsx +909 -0
- package/src/assistant/engine-mark-art.ts +45 -0
- package/src/assistant/engine-mark.tsx +39 -0
- package/src/assistant/engine.tsx +119 -12
- package/src/assistant/host.tsx +115 -32
- package/src/assistant/index.ts +8 -1
- package/src/assistant-chat.css +1 -0
- package/src/assistant-engine.css +1 -1
- package/src/assistant.css +1 -1
- package/src/auth.css +1 -1
- package/src/auth.tsx +17 -2
- package/src/chat-composer.css +1 -1
- package/src/chat-plain.js +6 -0
- package/src/chat.css +1 -1
- package/src/editor/frame.tsx +11 -4
- package/src/editor/index.ts +1 -1
- package/src/editor.css +1 -1
- package/src/engine-plain.js +1 -0
- package/src/index.ts +4 -1
- package/src/kit-plain.js +1 -1
- package/src/minichart.css +1 -0
- package/src/minichart.tsx +165 -0
- package/src/portal.css +1 -1
- package/src/search-dialog.tsx +67 -13
- package/src/setup-guide.css +1 -0
- package/src/setup-guide.tsx +116 -0
- package/src/ui.css +1 -1
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The BRIDGE on the user's machine, seen from the browser — @gemboss/ui (0.32.0).
|
|
5
|
+
*
|
|
6
|
+
* A surface whose assistant can run Claude Code / Codex on the user's own subscription installs a small server on their
|
|
7
|
+
* machine (`scripts/assistant-bridge.mjs` in each toolkit) and talks to it at http://127.0.0.1:<port> straight from the
|
|
8
|
+
* page: the surface's server never sees that call. content-radar (8787), partner-radar (8789) and search-radar (8790)
|
|
9
|
+
* each carried the same ~300 lines for it, copied one from the other: find the bridge (behind Chrome's Local Network
|
|
10
|
+
* Access permission), pair this browser with it (a code the page makes and puts in the install command), hand it a short
|
|
11
|
+
* data token from the server, send a turn and read its stream, tell a bridge failure from a turn failure, poll while a
|
|
12
|
+
* teammate is installing, notice when it goes away, and say all of that in the engine picker's Setup and Status tabs.
|
|
13
|
+
* Every review fixed one copy and the next review found the same bug in the other two. This is that code, once:
|
|
14
|
+
*
|
|
15
|
+
* createBridgeClient({ port, codeKey, app }) the calls (no React): probe, detect, pair code, data token, turn, stop
|
|
16
|
+
* readTurnEvents(res, onEvent, { atBridge }) the turn's server-sent events, the bridge's or the server's
|
|
17
|
+
* useBridge(client, { open, wantsLocal }) the state: probes on open, the 3 s poll, focus / permission re-checks
|
|
18
|
+
* bridgePickerProps(b, { latest, … }) → EnginePicker's `bridge`, `setup` and `statusActions`
|
|
19
|
+
*
|
|
20
|
+
* What stays the surface's: which engines exist and what they cost, the request body, what each event does to its
|
|
21
|
+
* messages, its confirm dialog. Behaviour carried over as measured in the radars (each line names where):
|
|
22
|
+
* - the permission is asked from a click only (Chrome shows its dialog then); a fetch to loopback without it fails
|
|
23
|
+
* like "no bridge", so the permission is read before any conclusion about the bridge (content-radar 22/09);
|
|
24
|
+
* - a probe from that click waits 60 s: the fetch's clock runs while the dialog is open (review #393);
|
|
25
|
+
* - a state the user is in the middle of ("asking", a wrong code) is not overwritten by a background probe (#393);
|
|
26
|
+
* - probes continue once paired, so a bridge that stops is noticed (23/09);
|
|
27
|
+
* - only the call to the bridge is marked `atBridge`: a data token the server refused is the turn's error (#922).
|
|
28
|
+
*/
|
|
29
|
+
import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
30
|
+
import { atBridge } from "./chat-turns";
|
|
31
|
+
import type { BridgeSetup, BridgeStatus } from "./engine";
|
|
32
|
+
|
|
33
|
+
// ── The calls ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** What a probe found. `absent` = nothing answers · `unpaired` = a bridge answers, with another pairing code · `paired` =
|
|
36
|
+
* ours · `needs-permission` = the browser has not been allowed to call this machine yet (ask from a click) · `asking` =
|
|
37
|
+
* its dialog is open · `blocked` = the user said no (only the site settings can undo it). */
|
|
38
|
+
export type BridgeProbeState = "absent" | "unpaired" | "paired" | "needs-permission" | "asking" | "blocked";
|
|
39
|
+
|
|
40
|
+
/** One engine as the server or the bridge reports it. Surfaces add their own fields (`paid`, `capUsd`, `effort`). */
|
|
41
|
+
export interface BridgeEngineStatus {
|
|
42
|
+
label?: string;
|
|
43
|
+
installed?: boolean;
|
|
44
|
+
/** null: could not tell (the engine is still offered). */
|
|
45
|
+
loggedIn?: boolean | null;
|
|
46
|
+
account?: string | null;
|
|
47
|
+
/** Runs on the user's machine only: not found on the server is not an install problem. */
|
|
48
|
+
localOnly?: boolean;
|
|
49
|
+
/** Where it would run from this browser: "bridge" once merged with a paired bridge. */
|
|
50
|
+
via?: string;
|
|
51
|
+
[extra: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
export type BridgeEngines = Record<string, BridgeEngineStatus>;
|
|
54
|
+
|
|
55
|
+
export interface BridgeProbe {
|
|
56
|
+
state: BridgeProbeState;
|
|
57
|
+
engines?: BridgeEngines;
|
|
58
|
+
/** The bridge's own version (`/status`); 1 when it does not say. */
|
|
59
|
+
version?: number;
|
|
60
|
+
/** Installed under ~/.<app>-bridge (not run from a checkout). */
|
|
61
|
+
installed?: boolean;
|
|
62
|
+
/** Starts at sign-in (a LaunchAgent). */
|
|
63
|
+
autostart?: boolean;
|
|
64
|
+
dataToken?: unknown;
|
|
65
|
+
/** It was paired and stopped answering a call: said until a probe finds it again. */
|
|
66
|
+
lost?: boolean;
|
|
67
|
+
/** Stopped or removed from the panel. */
|
|
68
|
+
off?: "stop" | "remove";
|
|
69
|
+
/** The code typed in Setup is not the bridge's. */
|
|
70
|
+
wrong?: boolean;
|
|
71
|
+
/** The permission dialog was closed without a choice. */
|
|
72
|
+
dismissed?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BridgeClientOptions {
|
|
76
|
+
/** Its port on 127.0.0.1. One per surface, so several bridges run side by side. */
|
|
77
|
+
port: number;
|
|
78
|
+
/** localStorage key of this browser's pairing code ("pr-bridge-code"). */
|
|
79
|
+
codeKey: string;
|
|
80
|
+
/** The surface's name in what the user reads ("Sign in to partner-radar again"). */
|
|
81
|
+
app: string;
|
|
82
|
+
/** Where the server hands out a short-lived data token (POST). Default "/api/assistant/bridge-token"; null: none. */
|
|
83
|
+
tokenPath?: string | null;
|
|
84
|
+
/** The first bridge version that accepts the `x-data-token` header (content-radar: 2; an older bridge's CORS rejects
|
|
85
|
+
* it, and every turn would fail "Failed to fetch"). Default 0. */
|
|
86
|
+
tokenSince?: number;
|
|
87
|
+
/** Default "127.0.0.1". */
|
|
88
|
+
host?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BridgeClient {
|
|
92
|
+
readonly url: string;
|
|
93
|
+
readonly app: string;
|
|
94
|
+
readCode(): string;
|
|
95
|
+
writeCode(code: string): void;
|
|
96
|
+
/** This browser's code, made (8 characters, no 0/O/1/I) and kept the first time. */
|
|
97
|
+
ensureCode(): string;
|
|
98
|
+
/** The line a teammate pastes into Terminal: installs the bridge with this browser's code, starting at sign-in. */
|
|
99
|
+
installCommand(code: string, origin?: string): string;
|
|
100
|
+
/** A data token from the server, reused until it has less than an hour left. Errors are the server's, not the bridge's. */
|
|
101
|
+
dataToken(): Promise<string>;
|
|
102
|
+
/** One call to /status. Never throws. */
|
|
103
|
+
probe(code?: string, timeoutMs?: number): Promise<BridgeProbe>;
|
|
104
|
+
/** A probe that reads the browser's permission first. `fromClick`: may open Chrome's dialog, and waits for it. */
|
|
105
|
+
detect(opts?: { fromClick?: boolean }): Promise<BridgeProbe>;
|
|
106
|
+
/** Stop the bridge or remove it from the machine (bridge v3+). Throws (marked `atBridge`) when it does not answer. */
|
|
107
|
+
control(kind: "stop" | "remove"): Promise<{ autostart: boolean }>;
|
|
108
|
+
/**
|
|
109
|
+
* POST a turn to the bridge; the response is its event stream (read it with `readTurnEvents(res, fn, { atBridge: true })`).
|
|
110
|
+
* Adds the pairing code and a data token (`version`: the paired bridge's, for `tokenSince`). Throws marked `atBridge` when
|
|
111
|
+
* the bridge does not answer or answers an error (with its status: 401 is "another pairing code"); a data token the
|
|
112
|
+
* server refused is thrown unmarked. Do not wrap it in `atBridge` again.
|
|
113
|
+
*/
|
|
114
|
+
turn(body: Record<string, unknown>, opts?: { signal?: AbortSignal; version?: number }): Promise<Response>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const CODE_ABC = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
118
|
+
/** Chrome's hint for a public page calling loopback (Local Network Access). Browsers that do not know it ignore it. */
|
|
119
|
+
const LOOPBACK = { targetAddressSpace: "loopback" } as unknown as RequestInit;
|
|
120
|
+
|
|
121
|
+
function storage(): Storage | null {
|
|
122
|
+
try { return typeof localStorage === "undefined" ? null : localStorage; } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** granted | prompt | denied | unsupported. The permission's name is `local-network-access` (measured 22/09/2026,
|
|
126
|
+
* Chromium 148 / Chrome 152). A browser without it: probe straight away. */
|
|
127
|
+
export async function localNetworkPermission(): Promise<string> {
|
|
128
|
+
try {
|
|
129
|
+
const p = await navigator.permissions.query({ name: "local-network-access" as PermissionName });
|
|
130
|
+
return p.state;
|
|
131
|
+
} catch { return "unsupported"; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createBridgeClient(o: BridgeClientOptions): BridgeClient {
|
|
135
|
+
const url = `http://${o.host ?? "127.0.0.1"}:${o.port}`;
|
|
136
|
+
const tokenPath = o.tokenPath === undefined ? "/api/assistant/bridge-token" : o.tokenPath;
|
|
137
|
+
let tok: { token: string; exp: number } | null = null;
|
|
138
|
+
// Storage that refuses writes (blocked site data, a sandboxed frame): the code lives in memory for this page, so the
|
|
139
|
+
// command in Setup and the turns carry the SAME code (review #3952: a new code on every open, and turns sending "").
|
|
140
|
+
let memCode = "";
|
|
141
|
+
const client: BridgeClient = {
|
|
142
|
+
url,
|
|
143
|
+
app: o.app,
|
|
144
|
+
readCode: () => { let v = ""; try { v = storage()?.getItem(o.codeKey) || ""; } catch { /* blocked */ } return v || memCode; },
|
|
145
|
+
writeCode: (c) => { memCode = c; try { storage()?.setItem(o.codeKey, c); } catch { /* private mode */ } },
|
|
146
|
+
ensureCode() {
|
|
147
|
+
const have = client.readCode();
|
|
148
|
+
if (/^[A-Z0-9]{8}$/.test(have)) return have;
|
|
149
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
150
|
+
const code = Array.from(bytes, (b) => CODE_ABC[b % CODE_ABC.length]).join("");
|
|
151
|
+
client.writeCode(code);
|
|
152
|
+
return code;
|
|
153
|
+
},
|
|
154
|
+
installCommand: (code, origin = typeof window === "undefined" ? "" : window.location.origin) =>
|
|
155
|
+
`curl -fsSL ${origin}/bridge/install.sh | sh -s -- --code ${code} --autostart`,
|
|
156
|
+
async dataToken() {
|
|
157
|
+
if (!tokenPath) return "";
|
|
158
|
+
// Under an hour left: ask again. One turn (an article) uses ONE token throughout, and 10 minutes was too close for a
|
|
159
|
+
// long one (content-radar review #394).
|
|
160
|
+
if (tok && tok.exp - Date.now() > 3_600_000) return tok.token;
|
|
161
|
+
const r = await fetch(tokenPath, { method: "POST" });
|
|
162
|
+
if (!r.ok) throw new Error(`Couldn't get a data token (HTTP ${r.status}). Sign in to ${o.app} again`);
|
|
163
|
+
tok = await r.json();
|
|
164
|
+
return (tok as { token: string }).token;
|
|
165
|
+
},
|
|
166
|
+
// Short when only asking "is anything there" (nothing there fails at once; 1.5 s covers a slow machine), longer with a
|
|
167
|
+
// code: a bridge's first /status may still be running `auth status`.
|
|
168
|
+
async probe(code, timeoutMs = code ? 8000 : 1500) {
|
|
169
|
+
try {
|
|
170
|
+
const r = await fetch(`${url}/status`, { headers: code ? { "x-bridge-code": code } : {}, signal: AbortSignal.timeout(timeoutMs), ...LOOPBACK });
|
|
171
|
+
if (r.status === 401) return { state: "unpaired" };
|
|
172
|
+
if (!r.ok) return { state: "absent" };
|
|
173
|
+
const j = await r.json();
|
|
174
|
+
return { state: "paired", engines: j.engines || {}, dataToken: j.dataToken, version: Number(j.version) || 1, installed: !!j.installed, autostart: !!j.autostart };
|
|
175
|
+
} catch { return { state: "absent" }; }
|
|
176
|
+
},
|
|
177
|
+
async detect({ fromClick = false } = {}) {
|
|
178
|
+
const perm = await localNetworkPermission();
|
|
179
|
+
if (perm === "denied") return { state: "blocked" };
|
|
180
|
+
// Not asked yet: wait for the user's click, so Chrome's dialog comes when they want it.
|
|
181
|
+
if (perm === "prompt" && !fromClick) return { state: "needs-permission" };
|
|
182
|
+
// From the click the fetch waits up to 60 s: its clock runs while the dialog is open, and cancelling it can close
|
|
183
|
+
// the dialog (review #393).
|
|
184
|
+
const br = await client.probe(client.readCode(), fromClick ? 60_000 : undefined);
|
|
185
|
+
if (br.state === "absent") {
|
|
186
|
+
const after = await localNetworkPermission(); // the dialog: "Block", or closed
|
|
187
|
+
if (after === "denied") return { state: "blocked" };
|
|
188
|
+
if (after === "prompt") return { state: "needs-permission", dismissed: fromClick };
|
|
189
|
+
}
|
|
190
|
+
return br;
|
|
191
|
+
},
|
|
192
|
+
async control(kind) {
|
|
193
|
+
let r: Response;
|
|
194
|
+
try {
|
|
195
|
+
r = await fetch(`${url}/${kind}`, { method: "POST", headers: { "x-bridge-code": client.readCode() }, signal: AbortSignal.timeout(8000), ...LOOPBACK });
|
|
196
|
+
} catch (e) { throw atBridge(e); }
|
|
197
|
+
if (!r.ok) throw atBridge(new Error(`HTTP ${r.status}`), r.status);
|
|
198
|
+
const j = await r.json().catch(() => ({}));
|
|
199
|
+
return { autostart: !!j.autostart };
|
|
200
|
+
},
|
|
201
|
+
async turn(body, { signal, version } = {}) {
|
|
202
|
+
const token = tokenPath && (version ?? Number.POSITIVE_INFINITY) >= (o.tokenSince ?? 0) ? await client.dataToken() : "";
|
|
203
|
+
let res: Response;
|
|
204
|
+
try {
|
|
205
|
+
res = await fetch(`${url}/turn`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/json", "x-bridge-code": client.readCode(), ...(token ? { "x-data-token": token } : {}) },
|
|
208
|
+
body: JSON.stringify(body),
|
|
209
|
+
signal,
|
|
210
|
+
...LOOPBACK,
|
|
211
|
+
});
|
|
212
|
+
} catch (e) { throw atBridge(e); }
|
|
213
|
+
if (!res.ok || !res.body) {
|
|
214
|
+
const j = await res.json().catch(() => ({}));
|
|
215
|
+
throw atBridge(new Error(j.error || `HTTP ${res.status}`), res.status);
|
|
216
|
+
}
|
|
217
|
+
return res;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
return client;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** POST a turn to the surface's own server route; the response is its event stream. An error is the turn's (unmarked). */
|
|
224
|
+
export async function postTurn(url: string, body: Record<string, unknown>, { signal }: { signal?: AbortSignal } = {}): Promise<Response> {
|
|
225
|
+
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal });
|
|
226
|
+
if (!res.ok || !res.body) {
|
|
227
|
+
const j = await res.json().catch(() => ({}));
|
|
228
|
+
throw Object.assign(new Error(j.error || `HTTP ${res.status}`), { status: res.status });
|
|
229
|
+
}
|
|
230
|
+
return res;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One event of a turn's stream (`data: {…}` blocks): session / text / tool / tool_result / done, and the surface's own. */
|
|
234
|
+
export interface TurnEvent { type: string; [k: string]: unknown }
|
|
235
|
+
|
|
236
|
+
export const BRIDGE_CLOSED_EARLY = "The bridge closed the connection before the turn finished";
|
|
237
|
+
export const SERVER_CLOSED_EARLY = "The server closed the connection before the turn finished";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read a turn's server-sent events to its `done`, calling `onEvent` with each JSON event. A line that is not JSON (": ping",
|
|
241
|
+
* a heartbeat) is skipped. `atBridge`: the stream comes from the bridge, so a read that throws (the bridge died mid-turn,
|
|
242
|
+
* the machine slept) is the bridge's failure; the user's Stop (AbortError) stays a Stop either way.
|
|
243
|
+
* A stream that ENDS without `done` is a turn cut short, never a finished answer (both the bridge and the radars' routes
|
|
244
|
+
* always end with `done`, an error one included): it throws, the bridge's failure for the bridge's stream (content-radar's
|
|
245
|
+
* runBridgeTurn said so; the radars' panels showed half an answer as done, review #3952). On any early exit (`onEvent`
|
|
246
|
+
* threw, the stream broke) the reader is cancelled, so the bridge sees the client go and stops the engine.
|
|
247
|
+
*/
|
|
248
|
+
export async function readTurnEvents(res: Response, onEvent: (ev: TurnEvent) => void, { atBridge: fromBridge = false }: { atBridge?: boolean } = {}): Promise<void> {
|
|
249
|
+
const cut = () => (fromBridge ? atBridge(new TypeError(BRIDGE_CLOSED_EARLY)) : new Error(SERVER_CLOSED_EARLY));
|
|
250
|
+
if (!res.body) throw cut();
|
|
251
|
+
const reader = res.body.getReader();
|
|
252
|
+
const dec = new TextDecoder();
|
|
253
|
+
let buf = "";
|
|
254
|
+
let done = false;
|
|
255
|
+
try {
|
|
256
|
+
for (;;) {
|
|
257
|
+
let chunk: ReadableStreamReadResult<Uint8Array>;
|
|
258
|
+
try { chunk = await reader.read(); } catch (e) {
|
|
259
|
+
if (done) break; // the turn is whole: a connection that drops after its `done` changes nothing
|
|
260
|
+
throw fromBridge ? atBridge(e) : e;
|
|
261
|
+
}
|
|
262
|
+
if (chunk.done) break;
|
|
263
|
+
buf += dec.decode(chunk.value, { stream: true });
|
|
264
|
+
const parts = buf.split("\n\n");
|
|
265
|
+
buf = parts.pop() ?? "";
|
|
266
|
+
for (const part of parts) {
|
|
267
|
+
let ev: TurnEvent;
|
|
268
|
+
try { ev = JSON.parse(part.replace(/^data: /, "")); } catch { continue; }
|
|
269
|
+
if (!ev || typeof ev !== "object") continue;
|
|
270
|
+
if (ev.type === "done") done = true;
|
|
271
|
+
onEvent(ev);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
if (!done) reader.cancel().catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
if (!done) throw cut();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The engines usable FROM THIS BROWSER: a paired bridge's engines replace the server's "runs on your machine only". */
|
|
281
|
+
export function mergeBridgeEngines(status: BridgeEngines | null, bridge: BridgeProbe | null | undefined): BridgeEngines | null {
|
|
282
|
+
if (!status) return status;
|
|
283
|
+
if (bridge?.state !== "paired") return status;
|
|
284
|
+
const out: BridgeEngines = { ...status };
|
|
285
|
+
// `localOnly` goes: through the bridge and still missing means NOT INSTALLED on your machine, and "run the bridge" would
|
|
286
|
+
// point the wrong way (content-radar review #392).
|
|
287
|
+
for (const e of ["claude", "codex"]) if (bridge.engines?.[e]) out[e] = { ...bridge.engines[e], localOnly: false, via: "bridge" };
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The page is served from the dev machine: the app runs the engines itself, there is no bridge to find. */
|
|
292
|
+
export const isLocalPage = () => typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
|
|
293
|
+
|
|
294
|
+
// ── The state ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export interface UseBridgeOptions {
|
|
297
|
+
/** The assistant is open (popup, side, full screen): probes run only then. */
|
|
298
|
+
open: boolean;
|
|
299
|
+
/** The selected engine runs on the user's machine (not a server engine): while no bridge is found, poll every 3 s so
|
|
300
|
+
* the panel connects by itself once a teammate's install finishes. */
|
|
301
|
+
wantsLocal: boolean;
|
|
302
|
+
/** The engine picker is open: the pairing code is read again (another tab may have paired with another one), so the
|
|
303
|
+
* command in Setup carries the code the next turn will send. */
|
|
304
|
+
pickerOpen?: boolean;
|
|
305
|
+
/** GET for the server's own engines (`{ engines }`), probed with the bridge. Default "/api/assistant"; null: none. */
|
|
306
|
+
statusUrl?: string | null;
|
|
307
|
+
/** After each full check (open, Check again), with the merged engines: move off an engine that cannot run here. */
|
|
308
|
+
onDetected?: (engines: BridgeEngines, bridge: BridgeProbe) => void;
|
|
309
|
+
/** The page runs on the dev machine: no bridge, no probes. Default `isLocalPage()`. */
|
|
310
|
+
local?: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface BridgeApi {
|
|
314
|
+
client: BridgeClient;
|
|
315
|
+
bridge: BridgeProbe;
|
|
316
|
+
/** The server's engines; null until the first check answers (and again after `recheck`). */
|
|
317
|
+
status: BridgeEngines | null;
|
|
318
|
+
/** `status` with a paired bridge's engines merged in (`mergeBridgeEngines`). */
|
|
319
|
+
engines: BridgeEngines | null;
|
|
320
|
+
/** The page runs on the dev machine: no dot, no Setup / Status. Decided after mount (false in the server's markup). */
|
|
321
|
+
local: boolean;
|
|
322
|
+
paired: boolean;
|
|
323
|
+
/** The paired bridge's version (1 when it does not say), 0 when not paired: pass it to `turn` for `tokenSince`. */
|
|
324
|
+
version: number;
|
|
325
|
+
/** This browser's pairing code ("" until the assistant first opens on a page that is not local). */
|
|
326
|
+
pairCode: string;
|
|
327
|
+
/** Probe the server and the bridge again (Status › Check again). */
|
|
328
|
+
recheck(): void;
|
|
329
|
+
/** A call to the bridge failed: say "not responding" at once and probe again, instead of sending the next turn into
|
|
330
|
+
* the void. Call it when `turns.fail(...)` says `bridge`. */
|
|
331
|
+
lost(): void;
|
|
332
|
+
/** Pair with the code the terminal printed. True when it matched. */
|
|
333
|
+
pair(code: string): Promise<boolean>;
|
|
334
|
+
/** From the "Allow" click: lets Chrome show its permission dialog, then probes. */
|
|
335
|
+
allowAccess(): Promise<void>;
|
|
336
|
+
/** Stop / remove the bridge. Resolves with `autostart` (the note to show); on failure it probes again and throws. */
|
|
337
|
+
control(kind: "stop" | "remove"): Promise<{ autostart: boolean }>;
|
|
338
|
+
/** Replace the state (a surface's own case). */
|
|
339
|
+
setBridge: React.Dispatch<React.SetStateAction<BridgeProbe>>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Keep `cur` when the user is in the middle of it ("asking") or a probe found the same state (it may carry a note). */
|
|
343
|
+
const keepUnlessNew = (res: BridgeProbe) => (cur: BridgeProbe) => (cur.state === "asking" || cur.state === res.state ? cur : res);
|
|
344
|
+
|
|
345
|
+
export function useBridge(clientArg: BridgeClient, o: UseBridgeOptions): BridgeApi {
|
|
346
|
+
// The client of the FIRST render, for the hook's life: one built in render (`useBridge(createBridgeClient(…))`) would
|
|
347
|
+
// otherwise restart every effect on each streamed token (the 3 s poll never fires) and lose its token cache.
|
|
348
|
+
const client = useRef(clientArg).current;
|
|
349
|
+
// On the dev machine there is no bridge. The effects read it where they run (the browser); what is DRAWN switches after
|
|
350
|
+
// mount, so the server's markup (no window: "not local") and the first client render agree (no hydration mismatch).
|
|
351
|
+
const localNow = o.local ?? isLocalPage();
|
|
352
|
+
const [localDrawn, setLocalDrawn] = useState(o.local ?? false);
|
|
353
|
+
useEffect(() => { setLocalDrawn(o.local ?? isLocalPage()); }, [o.local]);
|
|
354
|
+
const statusUrl = o.statusUrl === undefined ? "/api/assistant" : o.statusUrl;
|
|
355
|
+
const [bridge, setBridgeRaw] = useState<BridgeProbe>({ state: "absent" });
|
|
356
|
+
const [status, setStatus] = useState<BridgeEngines | null>(null);
|
|
357
|
+
const [pairCode, setPairCode] = useState("");
|
|
358
|
+
const onDetected = useRef(o.onDetected);
|
|
359
|
+
onDetected.current = o.onDetected;
|
|
360
|
+
const { open, wantsLocal, pickerOpen } = o;
|
|
361
|
+
// Every user action (pair, Stop, Allow, a lost bridge) starts a new epoch. A background probe (the poll, focus, the
|
|
362
|
+
// permission) that began in an older epoch is dropped when it answers: a /status that left before Stop must not bring
|
|
363
|
+
// "running" back after it, nor a poll with the old code undo a pairing (review #3952).
|
|
364
|
+
const epoch = useRef(0);
|
|
365
|
+
const setBridge = useCallback<React.Dispatch<React.SetStateAction<BridgeProbe>>>((v) => { epoch.current += 1; setBridgeRaw(v); }, []);
|
|
366
|
+
const inflight = useRef(false);
|
|
367
|
+
const background = useCallback(() => {
|
|
368
|
+
if (inflight.current) return; // a probe with a code may wait 8 s: one at a time, not one per tick
|
|
369
|
+
inflight.current = true;
|
|
370
|
+
const at = epoch.current;
|
|
371
|
+
client.detect().then((res) => { if (at === epoch.current) setBridgeRaw(keepUnlessNew(res)); }).finally(() => { inflight.current = false; });
|
|
372
|
+
}, [client]);
|
|
373
|
+
|
|
374
|
+
// The server's engines and the bridge, when the assistant opens and after Check again (status → null).
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
if (!open || status) return undefined;
|
|
377
|
+
let cancelled = false;
|
|
378
|
+
const at = epoch.current;
|
|
379
|
+
Promise.all([
|
|
380
|
+
statusUrl ? fetch(statusUrl).then((r) => r.json()).then((j) => (j && j.engines) || {}).catch(() => ({})) : Promise.resolve({}),
|
|
381
|
+
localNow ? Promise.resolve<BridgeProbe>({ state: "absent" }) : client.detect(),
|
|
382
|
+
]).then(([st, br]) => {
|
|
383
|
+
if (cancelled) return;
|
|
384
|
+
setStatus(st);
|
|
385
|
+
// The user acted while it was out (paired, pressed Stop): keep what they did, the engines still arrive.
|
|
386
|
+
const now = at === epoch.current ? br : null;
|
|
387
|
+
if (now) setBridgeRaw(now);
|
|
388
|
+
onDetected.current?.(mergeBridgeEngines(st, now ?? br) ?? {}, now ?? br);
|
|
389
|
+
});
|
|
390
|
+
return () => { cancelled = true; };
|
|
391
|
+
}, [open, status, statusUrl, localNow, client]);
|
|
392
|
+
|
|
393
|
+
// The code is made when the assistant first opens on a public page (not on every page of everyone: the panel sits in
|
|
394
|
+
// the layout), and read again whenever the picker opens.
|
|
395
|
+
useEffect(() => { if (open && !localNow) setPairCode(client.ensureCode()); }, [open, pickerOpen, localNow, client]);
|
|
396
|
+
|
|
397
|
+
// A teammate is pasting the install command: probe every 3 s, so the panel switches to "through the bridge" as soon as
|
|
398
|
+
// it runs, with no click and no reload.
|
|
399
|
+
const polling = open && wantsLocal && !localNow && (bridge.state === "absent" || bridge.state === "unpaired");
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (!polling) return undefined;
|
|
402
|
+
const t = setInterval(background, 3000);
|
|
403
|
+
return () => clearInterval(t);
|
|
404
|
+
}, [polling, background]);
|
|
405
|
+
|
|
406
|
+
// Back to the tab, and a permission changed in the site settings: probe again. ALSO WHEN PAIRED: a bridge that stops
|
|
407
|
+
// (closed, the machine slept) must be noticed; probing stopped once paired, so one drop meant "connected" forever (23/09).
|
|
408
|
+
const asking = bridge.state === "asking";
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
if (!open || localNow || asking) return undefined;
|
|
411
|
+
let cancelled = false;
|
|
412
|
+
const again = () => {
|
|
413
|
+
if (cancelled || document.visibilityState !== "visible") return;
|
|
414
|
+
background();
|
|
415
|
+
};
|
|
416
|
+
window.addEventListener("focus", again);
|
|
417
|
+
document.addEventListener("visibilitychange", again);
|
|
418
|
+
let perm: PermissionStatus | null = null;
|
|
419
|
+
navigator.permissions?.query({ name: "local-network-access" as PermissionName }).then((p) => {
|
|
420
|
+
if (cancelled) return; // cleaned up before the query answered: no orphan listener
|
|
421
|
+
perm = p; p.onchange = again;
|
|
422
|
+
}).catch(() => {});
|
|
423
|
+
return () => {
|
|
424
|
+
cancelled = true;
|
|
425
|
+
window.removeEventListener("focus", again);
|
|
426
|
+
document.removeEventListener("visibilitychange", again);
|
|
427
|
+
if (perm) perm.onchange = null;
|
|
428
|
+
};
|
|
429
|
+
}, [open, localNow, asking, background]);
|
|
430
|
+
|
|
431
|
+
const lost = useCallback(() => {
|
|
432
|
+
setBridge({ state: "absent", lost: true });
|
|
433
|
+
const at = epoch.current;
|
|
434
|
+
client.detect().then((res) => { if (at === epoch.current) setBridgeRaw((cur) => (cur.state === "asking" ? cur : res.state === "absent" ? { ...res, lost: true } : res)); });
|
|
435
|
+
}, [client, setBridge]);
|
|
436
|
+
|
|
437
|
+
const pair = useCallback(async (raw: string) => {
|
|
438
|
+
const code = raw.trim().toUpperCase();
|
|
439
|
+
if (!code) return false;
|
|
440
|
+
epoch.current += 1; // a poll still holding the old code must not answer over this
|
|
441
|
+
const at = epoch.current;
|
|
442
|
+
const br = await client.probe(code);
|
|
443
|
+
if (br.state === "paired") { client.writeCode(code); setPairCode(code); }
|
|
444
|
+
if (at === epoch.current) setBridge(br.state === "unpaired" ? { state: "unpaired", wrong: true } : br);
|
|
445
|
+
return br.state === "paired";
|
|
446
|
+
}, [client, setBridge]);
|
|
447
|
+
|
|
448
|
+
const allowAccess = useCallback(async () => {
|
|
449
|
+
setBridge({ state: "asking" });
|
|
450
|
+
const at = epoch.current;
|
|
451
|
+
const res = await client.detect({ fromClick: true });
|
|
452
|
+
if (at === epoch.current) setBridge(res);
|
|
453
|
+
}, [client, setBridge]);
|
|
454
|
+
|
|
455
|
+
const control = useCallback(async (kind: "stop" | "remove") => {
|
|
456
|
+
epoch.current += 1;
|
|
457
|
+
try {
|
|
458
|
+
const r = await client.control(kind);
|
|
459
|
+
setBridge({ state: "absent", off: kind });
|
|
460
|
+
return r;
|
|
461
|
+
} catch (e) {
|
|
462
|
+
// It did not answer: gone. It answered 401: running, with another code (Setup's Pair form, not "stopped
|
|
463
|
+
// responding"). Another HTTP error (an old bridge without /stop): still there, the state stays (review #3952).
|
|
464
|
+
const status = (e as { status?: number } | null)?.status;
|
|
465
|
+
if (status === undefined) lost();
|
|
466
|
+
else if (status === 401) setBridge({ state: "unpaired" });
|
|
467
|
+
throw e;
|
|
468
|
+
}
|
|
469
|
+
}, [client, lost, setBridge]);
|
|
470
|
+
|
|
471
|
+
const recheck = useCallback(() => setStatus(null), []);
|
|
472
|
+
const engines = mergeBridgeEngines(status, bridge);
|
|
473
|
+
return {
|
|
474
|
+
client, bridge, status, engines, local: localDrawn, pairCode,
|
|
475
|
+
// The same version everywhere (turn's tokenSince, the picker's outdated): a paired bridge that does not say is v1.
|
|
476
|
+
paired: bridge.state === "paired", version: bridge.state === "paired" ? bridge.version ?? 1 : 0,
|
|
477
|
+
recheck, lost, pair, allowAccess, control, setBridge,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── What the engine picker shows ───────────────────────────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/** Every sentence the Setup / Status tabs say about the bridge. English, like every surface's chrome. */
|
|
484
|
+
export interface BridgeSetupLabels {
|
|
485
|
+
outdated: string; permission: string; blocked: string; unpaired: string; lost: string; stopped: string; removed: string;
|
|
486
|
+
allowHint: string; allowDismissed: string; allow: string; waiting: string;
|
|
487
|
+
blockedStep1: ReactNode; blockedStep2: ReactNode; blockedStep3: string; checkAgain: string;
|
|
488
|
+
pastePairing: ReactNode; pairingCode: string; pairingExample: string; pair: string; pairing: string; wrongCode: string; orReinstall: string;
|
|
489
|
+
update: string; reinstall: string; installStep1: ReactNode; installStep2: string;
|
|
490
|
+
pairedNote: string; installNote: string; stop: string; remove: string;
|
|
491
|
+
}
|
|
492
|
+
export const BRIDGE_SETUP_EN: BridgeSetupLabels = {
|
|
493
|
+
outdated: "This bridge is older than the one the panel expects: update it once (Setup).",
|
|
494
|
+
permission: "The browser needs permission to connect to this computer (localhost).",
|
|
495
|
+
blocked: "Chrome is blocking this page from connecting to your computer.",
|
|
496
|
+
unpaired: "The bridge is running on your machine but is not paired with this browser.",
|
|
497
|
+
lost: "The bridge stopped responding. If it autostarts on login it usually comes back in a few seconds; otherwise rerun the command.",
|
|
498
|
+
stopped: "Bridge stopped.", removed: "Bridge removed.",
|
|
499
|
+
allowHint: "Chrome will ask to “access devices on your local network”: choose Allow.",
|
|
500
|
+
allowDismissed: " (The dialog was closed; click the button again.)",
|
|
501
|
+
allow: "Allow connecting to this computer", waiting: "Waiting for your choice in the dialog…",
|
|
502
|
+
blockedStep1: <>Click the <strong>⚙︎ / ⓘ</strong> icon to the left of the address bar.</>,
|
|
503
|
+
blockedStep2: <>Find <strong>Local network access</strong> and choose <strong>Allow</strong>.</>,
|
|
504
|
+
blockedStep3: "Come back to this tab. The panel picks it up without a reload.",
|
|
505
|
+
checkAgain: "Check again",
|
|
506
|
+
pastePairing: <>Paste the <strong>pairing code</strong> shown in the terminal:</>,
|
|
507
|
+
pairingCode: "Pairing code", pairingExample: "e.g. 7KQ2M9XA", pair: "Pair", pairing: "Pairing\u2026",
|
|
508
|
+
wrongCode: "Wrong code. Check the pairing code line in the terminal.",
|
|
509
|
+
orReinstall: "Or reinstall with this command, which includes this browser’s code:",
|
|
510
|
+
update: "Update once: paste this command into Terminal and press Enter.",
|
|
511
|
+
reinstall: "Reinstall or update: paste this command into Terminal and press Enter.",
|
|
512
|
+
installStep1: <>Open <strong>Terminal</strong> (⌘ + Space, type Terminal).</>,
|
|
513
|
+
installStep2: "Paste this command and press Enter (about 1 minute).",
|
|
514
|
+
pairedNote: "The pairing code stays the same: the panel reconnects in a few seconds.",
|
|
515
|
+
installNote: "The panel connects as soon as the bridge starts, and the bridge starts with your computer after that. Requires macOS, Node.js, and Claude Code or Codex signed in.",
|
|
516
|
+
stop: "Stop bridge", remove: "Remove from this machine",
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** The line a Stop / Remove leaves in the chat (`turns.note(…, { bridge: true })`). */
|
|
520
|
+
export function bridgeControlNote(kind: "stop" | "remove", { autostart }: { autostart: boolean }): string {
|
|
521
|
+
return kind === "remove"
|
|
522
|
+
? "Bridge removed from this machine. To use it again, run the install command in Setup."
|
|
523
|
+
: `Bridge stopped.${autostart ? " It starts again the next time you log in to your computer." : ""} To start it now, rerun the install command in Setup.`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The pairing form on Setup: one field, Enter pairs (a one-field form submits by itself). The button is
|
|
527
|
+
* `type="button"`: the picker sits in the composer, and chat.css draws a submit button there as the round send button. */
|
|
528
|
+
export function BridgePairForm({ onPair, wrong, labels }: { onPair: (code: string) => Promise<boolean> | void; wrong?: boolean; labels?: Partial<BridgeSetupLabels> }) {
|
|
529
|
+
const L = { ...BRIDGE_SETUP_EN, ...labels };
|
|
530
|
+
const [code, setCode] = useState("");
|
|
531
|
+
// The probe with a code can take up to 8 s (a first /status runs `auth status`): one at a time, and it says so.
|
|
532
|
+
const [pending, setPending] = useState(false);
|
|
533
|
+
const go = async () => {
|
|
534
|
+
if (!code.trim() || pending) return;
|
|
535
|
+
setPending(true);
|
|
536
|
+
try { if (await onPair(code)) setCode(""); } finally { setPending(false); }
|
|
537
|
+
};
|
|
538
|
+
return (
|
|
539
|
+
<>
|
|
540
|
+
<form className="ui-toolbar" onSubmit={(e) => { e.preventDefault(); void go(); }} aria-busy={pending || undefined}>
|
|
541
|
+
<input className="gemboss-field" style={{ flex: "1 1 140px", minWidth: 0, textTransform: "uppercase", letterSpacing: ".08em" }}
|
|
542
|
+
value={code} onChange={(e) => setCode(e.target.value)} placeholder={L.pairingExample} aria-label={L.pairingCode} autoComplete="off" spellCheck={false} />
|
|
543
|
+
<button type="button" className="ui-btn ui-btn--primary" disabled={pending} onClick={() => void go()}>{pending ? L.pairing : L.pair}</button>
|
|
544
|
+
</form>
|
|
545
|
+
{wrong && !pending ? <p role="status">{L.wrongCode}</p> : null}
|
|
546
|
+
</>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface BridgePickerOptions {
|
|
551
|
+
/** The bridge version the server installs today: an older paired one is "outdated". */
|
|
552
|
+
latest: number;
|
|
553
|
+
/** A turn is running: Stop / Remove wait for it. */
|
|
554
|
+
busy?: boolean;
|
|
555
|
+
/** Offer Stop / Remove on Status (when the selected engine goes through the bridge, and the bridge has them: v3+). */
|
|
556
|
+
controls?: boolean;
|
|
557
|
+
/** Stop / Remove pressed: close the picker (`picker.close()`), confirm a removal, then `b.control(kind)`. */
|
|
558
|
+
onControl?: (kind: "stop" | "remove") => void;
|
|
559
|
+
/** What an old bridge gets wrong on this surface (default: `labels.outdated`). */
|
|
560
|
+
outdatedDetail?: ReactNode;
|
|
561
|
+
/** Setup's note under the install command (default: `labels.installNote`): the surface adds its own lines (another
|
|
562
|
+
* surface's bridge does not count; a server engine to pick instead). */
|
|
563
|
+
installNote?: ReactNode;
|
|
564
|
+
labels?: Partial<BridgeSetupLabels>;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The bridge state of `useBridge`, in the words of `EnginePicker` (`BridgeStatus.state`). A bridge that answers with
|
|
568
|
+
* another code is "Not connected", not "Not set up": that label is for a machine with no bridge found (review #920). */
|
|
569
|
+
export function pickerBridgeState(b: Pick<BridgeApi, "bridge" | "status">, latest: number): BridgeStatus["state"] {
|
|
570
|
+
const br = b.bridge;
|
|
571
|
+
if (br.state === "paired") return (br.version ?? 1) < latest ? "outdated" : "running";
|
|
572
|
+
if (br.state === "needs-permission" || br.state === "asking" || br.state === "blocked" || br.state === "unpaired" || br.lost || br.off) return "offline";
|
|
573
|
+
return b.status === null ? "checking" : "unpaired";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* `EnginePicker`'s `bridge`, `setup` and `statusActions` for the state `useBridge` holds: the dot, the Status rows, the
|
|
578
|
+
* Setup steps for each case (allow the permission, unblock it, pair, install, update) with the command carrying this
|
|
579
|
+
* browser's code, and Check again / Stop / Remove. Nothing on the dev machine (`local`): no dot, no Setup, no Status.
|
|
580
|
+
*/
|
|
581
|
+
export function bridgePickerProps(b: BridgeApi, o: BridgePickerOptions): { bridge?: BridgeStatus; setup?: BridgeSetup; statusActions?: ReactNode } {
|
|
582
|
+
if (b.local) return {};
|
|
583
|
+
const L = { ...BRIDGE_SETUP_EN, ...o.labels };
|
|
584
|
+
const br = b.bridge;
|
|
585
|
+
const outdated = br.state === "paired" && (br.version ?? 1) < o.latest;
|
|
586
|
+
const permission = br.state === "needs-permission" || br.state === "asking";
|
|
587
|
+
const detail = br.state === "paired" ? (outdated ? (o.outdatedDetail ?? L.outdated) : null)
|
|
588
|
+
: permission ? L.permission
|
|
589
|
+
: br.state === "blocked" ? L.blocked
|
|
590
|
+
: br.state === "unpaired" ? L.unpaired
|
|
591
|
+
: br.lost ? L.lost
|
|
592
|
+
: br.off ? (br.off === "remove" ? L.removed : L.stopped) : null;
|
|
593
|
+
const bridge: BridgeStatus = {
|
|
594
|
+
state: pickerBridgeState(b, o.latest), detail,
|
|
595
|
+
...(br.state === "paired" ? { version: String(br.version ?? 1), latest: String(o.latest), autostart: br.autostart } : {}),
|
|
596
|
+
};
|
|
597
|
+
const steps = permission ? (
|
|
598
|
+
<>
|
|
599
|
+
<p>{L.allowHint}{br.dismissed ? L.allowDismissed : ""}</p>
|
|
600
|
+
<div><button type="button" className="ui-btn ui-btn--primary" onClick={() => void b.allowAccess()} disabled={br.state === "asking"}>
|
|
601
|
+
{br.state === "asking" ? L.waiting : L.allow}</button></div>
|
|
602
|
+
</>
|
|
603
|
+
) : br.state === "blocked" ? (
|
|
604
|
+
<>
|
|
605
|
+
<ol><li>{L.blockedStep1}</li><li>{L.blockedStep2}</li><li>{L.blockedStep3}</li></ol>
|
|
606
|
+
<div><button type="button" className="ui-btn" onClick={b.recheck}>{L.checkAgain}</button></div>
|
|
607
|
+
</>
|
|
608
|
+
) : br.state === "unpaired" ? (
|
|
609
|
+
<>
|
|
610
|
+
<p>{L.pastePairing}</p>
|
|
611
|
+
<BridgePairForm onPair={b.pair} wrong={br.wrong} labels={o.labels} />
|
|
612
|
+
<p>{L.orReinstall}</p>
|
|
613
|
+
</>
|
|
614
|
+
) : br.state === "paired" ? (
|
|
615
|
+
<p>{outdated ? L.update : L.reinstall}</p>
|
|
616
|
+
) : (
|
|
617
|
+
<ol><li>{L.installStep1}</li><li>{L.installStep2}</li></ol>
|
|
618
|
+
);
|
|
619
|
+
// Waiting on the browser's permission: localhost cannot be called yet, so there is no command to run.
|
|
620
|
+
const needsCommand = !permission && br.state !== "blocked";
|
|
621
|
+
const setup: BridgeSetup = {
|
|
622
|
+
steps,
|
|
623
|
+
...(needsCommand && b.pairCode ? { command: b.client.installCommand(b.pairCode) } : {}),
|
|
624
|
+
note: br.state === "paired" ? L.pairedNote : needsCommand ? (o.installNote ?? L.installNote) : undefined,
|
|
625
|
+
};
|
|
626
|
+
const statusActions = (
|
|
627
|
+
<>
|
|
628
|
+
<button type="button" className="ui-btn" onClick={b.recheck}>{L.checkAgain}</button>
|
|
629
|
+
{o.controls && br.state === "paired" ? <>
|
|
630
|
+
<button type="button" className="ui-btn" disabled={o.busy} onClick={() => o.onControl?.("stop")}>{L.stop}</button>
|
|
631
|
+
<button type="button" className="ui-btn ui-btn--critical" disabled={o.busy} onClick={() => o.onControl?.("remove")}>{L.remove}</button>
|
|
632
|
+
</> : null}
|
|
633
|
+
</>
|
|
634
|
+
);
|
|
635
|
+
return { bridge, setup, statusActions };
|
|
636
|
+
}
|