@gemboss/ui 0.21.0 → 0.30.2

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 (57) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/README.md +433 -24
  3. package/catalog/editor.html +426 -10
  4. package/catalog/index.html +907 -33
  5. package/gemboss-ui.css +3 -3
  6. package/package.json +23 -3
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/action-card.css +1 -0
  11. package/src/action-card.tsx +149 -0
  12. package/src/app-shell.css +1 -1
  13. package/src/assistant/chat-markdown.ts +279 -0
  14. package/src/assistant/chat-turns.ts +120 -0
  15. package/src/assistant/chat.tsx +871 -0
  16. package/src/assistant/engine-mark-art.ts +45 -0
  17. package/src/assistant/engine-mark.tsx +39 -0
  18. package/src/assistant/engine.tsx +697 -0
  19. package/src/assistant/host.tsx +3 -1
  20. package/src/assistant/index.ts +6 -0
  21. package/src/assistant-chat.css +1 -0
  22. package/src/assistant-engine.css +1 -0
  23. package/src/auth.css +1 -1
  24. package/src/auth.tsx +18 -2
  25. package/src/chat-composer.css +1 -1
  26. package/src/chat-plain.js +6 -0
  27. package/src/chat.css +1 -1
  28. package/src/collections.tsx +1 -0
  29. package/src/dialog.tsx +3 -135
  30. package/src/editor/controls.tsx +1 -0
  31. package/src/editor/frame.tsx +12 -4
  32. package/src/editor/index.ts +10 -1
  33. package/src/editor/layers.tsx +136 -0
  34. package/src/editor/panel.tsx +1 -0
  35. package/src/editor/panes.tsx +4 -3
  36. package/src/editor/sortable.tsx +79 -0
  37. package/src/editor/tree.tsx +61 -0
  38. package/src/editor-layers-plain.js +1 -0
  39. package/src/editor.css +1 -1
  40. package/src/engine-plain.js +1 -0
  41. package/src/index-table.tsx +1 -0
  42. package/src/index.ts +8 -3
  43. package/src/kit-plain.css +1 -1
  44. package/src/kit-plain.js +1 -1
  45. package/src/launcher.tsx +1 -0
  46. package/src/menu-layer.ts +9 -2
  47. package/src/minichart.css +1 -0
  48. package/src/minichart.tsx +165 -0
  49. package/src/portal.tsx +1 -0
  50. package/src/reactions.tsx +1 -0
  51. package/src/resource-list-select.tsx +1 -0
  52. package/src/search-dialog.tsx +146 -0
  53. package/src/selection.tsx +1 -0
  54. package/src/setup-guide.css +1 -0
  55. package/src/setup-guide.tsx +116 -0
  56. package/src/theme-script.ts +10 -0
  57. package/src/theme.tsx +5 -5
@@ -0,0 +1,697 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The assistant's ENGINE PICKER — @gemboss/ui (0.25.0, Chris 07/10/2026).
5
+ *
6
+ * One chip in the composer row: the engine's name, the model when it is not the default, and a dot for the bridge on the
7
+ * user's machine (green running, amber outdated, red not connected). A click opens a panel with up to three tabs:
8
+ * Engine — which engine answers, its model and its effort;
9
+ * Setup — install or update the bridge: the command with a Copy button, the pairing code, a few lines of how;
10
+ * Status — the bridge's state, its version against the latest, whether it starts at sign-in.
11
+ *
12
+ * Why it exists: content-radar's Radar Assistant spent close to half of its frame on an ENGINE / MODEL / EFFORT row, a
13
+ * yellow "The bridge on your machine is outdated…" block, the install command and a "Bridge on your machine: running"
14
+ * line, all above the conversation (Chris 07/10: "chèn hết vào không gian chat tốn không gian quá"). The admin already
15
+ * had the shape — a chip in the composer that opens a popup to pick Claude Code (apps/admin EnginePicker) — so this is
16
+ * that chip, with the setup and the status moved off the chat and into the same popup. A turn that FAILS because of the
17
+ * bridge says so in one line in the stream (`BridgeNotice`), whose "Open setup" opens this panel on the Setup tab.
18
+ *
19
+ * The package knows nothing about any bridge. The surface hands in the engines, the models, the efforts, the bridge's
20
+ * status, the command and the code, and hears every change through callbacks.
21
+ *
22
+ * The panel is a non-modal dialog in the browser's TOP LAYER (`popover="manual"`, placed like the kit's menus by
23
+ * `placeFloating`): no scrolling frame cuts it, it stays in the React tree (tokens resolve, events bubble). Escape and an
24
+ * outside click close it; Escape and every close from inside it give focus back to the chip. Tabs are a roving tablist
25
+ * (←/→/Home/End). Its look is `assistant-engine.css`, imported here: a surface that never draws the picker never loads it.
26
+ */
27
+ import "../assistant-engine.css"; // its own entry (0.25.0): not in gemboss-ui.css, not in the assistant frame's sheet
28
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
29
+ import { createPortal } from "react-dom";
30
+ import { canFloatMenus, placeFloating } from "../menu-layer";
31
+ import { EngineMark, isEngineMarkId } from "./engine-mark";
32
+
33
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
34
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
35
+
36
+ /** running = answers now · outdated = answers, but an update is due · offline = paired, not reachable · unpaired = never
37
+ * set up on this account · checking = not known yet (first poll in flight). */
38
+ export type BridgeState = "running" | "outdated" | "offline" | "unpaired" | "checking";
39
+ export type EnginePickerTab = "engine" | "setup" | "status";
40
+
41
+ export interface EngineChoice {
42
+ id: string;
43
+ label: string;
44
+ /** One quiet line under the name: who pays, or why it cannot be picked ("Install Codex on that machine"). */
45
+ note?: ReactNode;
46
+ /** A short badge beside the name ("not signed in", "your plan"). */
47
+ badge?: string;
48
+ badgeTone?: "success" | "warning" | "critical" | "info";
49
+ /** Cannot be picked right now. Say why in `note`. */
50
+ disabled?: boolean;
51
+ /** The engine's mark ("claude", "codex": `ENGINE_MARK_ART`). The chip shows it in place of the name when the composer row
52
+ * is too narrow for the name (`ChatComposer` density "icon"); an engine without one keeps its (cut) name. 0.29.0. */
53
+ mark?: string;
54
+ /** THIS engine's models. Give them on every engine and the Engine tab lists each engine with its own models, all at
55
+ * once, and one click picks the engine and the model together (`onPick`): the admin's popup. 0.29.0. */
56
+ models?: readonly Choice[];
57
+ }
58
+
59
+ export interface Choice {
60
+ id: string;
61
+ label: string;
62
+ /** Tooltip: what this model or effort means. */
63
+ title?: string;
64
+ /** A heading the choice sits under ("Opus", "Sonnet"). A list with groups, or more than six choices, is a select. */
65
+ group?: string;
66
+ }
67
+
68
+ export interface BridgeStatus {
69
+ state: BridgeState;
70
+ /** The bridge on the machine, and the one the server would install today. */
71
+ version?: string;
72
+ latest?: string;
73
+ /** Starts by itself when the user signs in to that machine. Undefined: not known. */
74
+ autostart?: boolean;
75
+ /** Which machine ("Iris's MacBook"). */
76
+ machine?: string;
77
+ /** Already formatted ("2 min ago"). */
78
+ lastSeen?: string;
79
+ /** One or two sentences: what is wrong and what it costs ("Codex cannot see the radar tools"). Shown on Status and Setup. */
80
+ detail?: ReactNode;
81
+ }
82
+
83
+ export interface BridgeSetup {
84
+ /** The install / update command, pasted into a terminal. */
85
+ command?: string;
86
+ /** The pairing code the command or the bridge asks for, when it is not already in the command. */
87
+ pairingCode?: string;
88
+ /** A few lines of how (an <ol>, a sentence). Default: "Paste the command into Terminal, then press Enter." */
89
+ steps?: ReactNode;
90
+ /** Quiet text under the command ("The pairing code stays the same: the panel reconnects in a few seconds."). */
91
+ note?: ReactNode;
92
+ }
93
+
94
+ /** Every word the picker says. English, like every surface's chrome (Chris 03/10/2026). */
95
+ export interface EnginePickerLabels {
96
+ chip: string; dialog: string; tabs: string;
97
+ engineTab: string; setupTab: string; statusTab: string;
98
+ engine: string; model: string; effort: string;
99
+ defaultChoice: string;
100
+ copy: string; copied: string; copyFailed: string; pairingCode: string; steps: string;
101
+ bridge: string; version: string; latest: string; startsAtSignIn: string; machine: string; lastSeen: string;
102
+ yes: string; no: string; unknown: string;
103
+ running: string; outdated: string; offline: string; unpaired: string; checking: string;
104
+ updateDue: string; close: string;
105
+ }
106
+ export const ENGINE_PICKER_EN: EnginePickerLabels = {
107
+ chip: "Engine", dialog: "Assistant engine", tabs: "Engine, setup and status",
108
+ engineTab: "Engine", setupTab: "Setup", statusTab: "Status",
109
+ engine: "Engine", model: "Model", effort: "Effort",
110
+ defaultChoice: "Default",
111
+ copy: "Copy command", copied: "Copied", copyFailed: "Select the command and copy it", pairingCode: "Pairing code", steps: "Paste the command into Terminal, then press Enter.",
112
+ bridge: "Bridge on your machine", version: "Version", latest: "Latest", startsAtSignIn: "Starts at sign-in", machine: "Machine", lastSeen: "Last seen",
113
+ yes: "Yes", no: "No", unknown: "Not known",
114
+ running: "Running", outdated: "Outdated", offline: "Not connected", unpaired: "Not set up", checking: "Checking",
115
+ updateDue: "Update available", close: "Close",
116
+ };
117
+
118
+ /** The dot's tone per state: one word each, so the CSS has one rule per tone and a test can read it. */
119
+ export const BRIDGE_TONE: Record<BridgeState, "ok" | "warn" | "down" | "idle"> = {
120
+ running: "ok", outdated: "warn", offline: "down", unpaired: "down", checking: "idle",
121
+ };
122
+ /** Literal class names, never a template like `gb-engine-dot--` + tone: a runtime-built class is a stem the admin's orphan
123
+ * gate (a-tap-target-rule-outlived-its-markup) would have to exempt wholesale, as host.tsx says of MODE_CLASS. */
124
+ const DOT_CLASS = { ok: "gb-engine-dot gb-engine-dot--ok", warn: "gb-engine-dot gb-engine-dot--warn", down: "gb-engine-dot gb-engine-dot--down", idle: "gb-engine-dot gb-engine-dot--idle" } as const;
125
+ const BADGE_CLASS = { success: "ui-badge ui-badge--success", warning: "ui-badge ui-badge--warning", critical: "ui-badge ui-badge--critical", info: "ui-badge ui-badge--info" } as const;
126
+ const NOTICE_CLASS = { warn: "gb-engine-notice gb-engine-notice--warn", down: "gb-engine-notice gb-engine-notice--down" } as const;
127
+ const STATE_LABEL: Record<BridgeState, keyof EnginePickerLabels> = {
128
+ running: "running", outdated: "outdated", offline: "offline", unpaired: "unpaired", checking: "checking",
129
+ };
130
+
131
+ export interface EnginePickerProps {
132
+ engines: readonly EngineChoice[];
133
+ engine: string;
134
+ onEngineChange?: (id: string) => void;
135
+ /** The models of the CURRENT engine. Omit (or pass []) and the row is not drawn. An id "" is "leave the CLI's own
136
+ * setting in charge" and reads as "Default" on the chip (it is not shown there). */
137
+ models?: readonly Choice[];
138
+ model?: string;
139
+ onModelChange?: (id: string) => void;
140
+ /** In place of the model choices: the surface's own control (Codex takes a typed model name). */
141
+ modelControl?: ReactNode;
142
+ efforts?: readonly Choice[];
143
+ effort?: string;
144
+ onEffortChange?: (id: string) => void;
145
+ /** Effort cannot be set right now; a string says why (its tooltip). */
146
+ effortDisabled?: boolean | string;
147
+ /** Quiet lines under the Engine tab's fields: who pays, what this bridge version cannot do yet. */
148
+ notes?: ReactNode;
149
+ /** With models per engine (`EngineChoice.models`): one click on a model picks both, and closes the panel. Without it the
150
+ * picker calls `onEngineChange` (when the engine changes) then `onModelChange`. 0.29.0. */
151
+ onPick?: (engine: string, model: string) => void;
152
+ /** A switch that did not go through ("Wait for the current answer to finish"): one red line on the Engine tab. 0.29.0. */
153
+ problem?: ReactNode;
154
+ /** The chip itself is disabled (a switch in flight). `locked` keeps the chip and disables the choices. 0.29.0. */
155
+ disabled?: boolean;
156
+ /** The bridge on the user's machine. Omit for an engine set that runs on the server only: no dot, no Status tab. */
157
+ bridge?: BridgeStatus;
158
+ /** The dot on the chip (default true when `bridge` is given). False when the current engine does not go through the
159
+ * bridge (a server engine): a red dot would say "broken" about something this engine does not use. */
160
+ showBridgeDot?: boolean;
161
+ /** Omit and there is no Setup tab. */
162
+ setup?: BridgeSetup;
163
+ /** Buttons under the status rows ("Forget this machine", "Restart"). */
164
+ statusActions?: ReactNode;
165
+ /** Controlled open state and tab (a `BridgeNotice` opens Setup through these: see `useEnginePicker`). */
166
+ open?: boolean;
167
+ onOpenChange?: (open: boolean) => void;
168
+ tab?: EnginePickerTab;
169
+ onTabChange?: (tab: EnginePickerTab) => void;
170
+ /** While a turn runs the surface may refuse a switch: the chip stays readable, the choices are disabled. */
171
+ locked?: boolean;
172
+ /** Align the panel's right edge with the chip's (a chip near the right edge of a narrow frame). */
173
+ end?: boolean;
174
+ labels?: Partial<EnginePickerLabels>;
175
+ className?: string;
176
+ }
177
+
178
+ /** The open state and tab of one picker, for a surface that also renders a `BridgeNotice`:
179
+ * `const p = useEnginePicker(); <EnginePicker {...p.props} …/> <BridgeNotice onOpenSetup={() => p.openTab("setup")} …/>` */
180
+ export function useEnginePicker(initial: EnginePickerTab = "engine") {
181
+ const [open, setOpen] = useState(false);
182
+ const [tab, setTab] = useState<EnginePickerTab>(initial);
183
+ const openTab = useCallback((t: EnginePickerTab) => { setTab(t); setOpen(true); }, []);
184
+ // Closing goes back to Engine: after "Open setup" the next click on the chip is about the engine again (review #3885).
185
+ const onOpenChange = useCallback((v: boolean) => { setOpen(v); if (!v) setTab(initial); }, [initial]);
186
+ return { open, tab, setOpen, setTab, openTab, props: { open, onOpenChange, tab, onTabChange: setTab } };
187
+ }
188
+
189
+ function availableTabs(p: Pick<EnginePickerProps, "bridge" | "setup">): EnginePickerTab[] {
190
+ return ["engine", ...(p.setup ? ["setup" as const] : []), ...(p.bridge ? ["status" as const] : [])];
191
+ }
192
+
193
+ /** The tab shown (controlled by `tab` or held here) among the tabs the props offer; one copy for the picker and the panel. */
194
+ function useTabs(p: Pick<EnginePickerProps, "bridge" | "setup" | "tab" | "onTabChange">) {
195
+ const tabs = availableTabs(p);
196
+ const [own, setOwn] = useState<EnginePickerTab>("engine");
197
+ const wanted = p.tab ?? own;
198
+ const tab = tabs.includes(wanted) ? wanted : "engine";
199
+ const { tab: controlled, onTabChange } = p;
200
+ const setTab = useCallback((t: EnginePickerTab) => {
201
+ if (controlled === undefined) setOwn(t);
202
+ onTabChange?.(t);
203
+ }, [controlled, onTabChange]);
204
+ return { tabs, tab, setTab, resetOwn: () => setOwn("engine") };
205
+ }
206
+
207
+ /** What the chip says out loud: the engine, the model, and the bridge when it is not fine. */
208
+ export function engineChipName(p: Pick<EnginePickerProps, "engines" | "engine" | "models" | "model" | "bridge">, L: EnginePickerLabels = ENGINE_PICKER_EN): string {
209
+ const cur = p.engines.find((e) => e.id === p.engine);
210
+ const eng = cur?.label ?? p.engine;
211
+ const model = p.model ? (cur?.models ?? p.models)?.find((m) => m.id === p.model)?.label : undefined;
212
+ const bridge = p.bridge && p.bridge.state !== "running" ? `. ${L.bridge}: ${L[STATE_LABEL[p.bridge.state]]}` : "";
213
+ return `${L.chip}: ${eng}${model ? ` · ${model}` : ""}${bridge}`;
214
+ }
215
+
216
+ export function EnginePicker(props: EnginePickerProps) {
217
+ const { engines, engine, models, model, bridge, end = false, className } = props;
218
+ const L = { ...ENGINE_PICKER_EN, ...props.labels };
219
+ const [ownOpen, setOwnOpen] = useState(false);
220
+ const open = props.open ?? ownOpen;
221
+ const { tabs, tab, setTab, resetOwn } = useTabs(props);
222
+ // Without the popover API (Safari < 17) a fixed layer inside the assistant's frame is cut by it (overflow, and the
223
+ // frame's backdrop-filter makes it the containing block): there the panel goes to <body> through a portal instead.
224
+ // Decided after mount, so the server and the first client render agree.
225
+ const [portal, setPortal] = useState(false);
226
+ useEffect(() => { setPortal(!canFloatMenus()); }, []);
227
+ const chipRef = useRef<HTMLButtonElement>(null);
228
+ const panelRef = useRef<HTMLDivElement>(null);
229
+ const panelId = useId();
230
+
231
+ const setOpen = useCallback((v: boolean) => {
232
+ if (props.open === undefined) setOwnOpen(v);
233
+ if (!v) resetOwn();
234
+ props.onOpenChange?.(v);
235
+ // eslint-disable-next-line react-hooks/exhaustive-deps
236
+ }, [props.open, props.onOpenChange]);
237
+ /** Close and put focus back on the chip: Escape, a pick, the close button. An outside click closes without it (the
238
+ * click already moved focus where the user wanted it). */
239
+ const closeToChip = useCallback(() => { setOpen(false); chipRef.current?.focus(); }, [setOpen]);
240
+
241
+ // Float / sink with `open`, before paint, so the panel is never drawn unplaced.
242
+ useBeforePaint(() => {
243
+ const panel = panelRef.current, chip = chipRef.current;
244
+ if (!panel || !chip) return;
245
+ if (!open) {
246
+ if (canFloatMenus() && panel.matches(":popover-open")) panel.hidePopover();
247
+ return;
248
+ }
249
+ if (canFloatMenus() && !panel.matches(":popover-open")) panel.showPopover();
250
+ placeFloating(panel, chip, end);
251
+ }, [open, end, tab]);
252
+
253
+ // While open: Escape, outside pointer, and keep it against the chip when the window or a frame around the chip moves.
254
+ useEffect(() => {
255
+ if (!open) return undefined;
256
+ const panel = panelRef.current, chip = chipRef.current;
257
+ if (!panel || !chip) return undefined;
258
+ // Escape with focus in the panel or on the chip closes the panel only: captured first and stopped, so the surface's
259
+ // own Escape (hide the assistant) does not fire on the same key. With focus elsewhere (the composer), it closes the
260
+ // panel and leaves focus alone, and marks the key handled (0.27.0): a surface whose "Escape hides the assistant"
261
+ // checks `defaultPrevented` waits for the next Escape. Before 0.27.0 the key went on unmarked, and React re-renders
262
+ // between this listener and the surface's, so every surface (gemlabs #922, gemfeedback #66) needed a window-capture
263
+ // marker to tell "this Escape closed the picker" from "this one is mine".
264
+ const onKey = (e: KeyboardEvent) => {
265
+ if (e.key !== "Escape" || e.defaultPrevented) return;
266
+ // Not ours: an IME composition (Escape cancels it; the kit's rule, kit-plain.js), or a modal opened from the panel.
267
+ if (e.isComposing || e.keyCode === 229) return;
268
+ const a = document.activeElement;
269
+ const modal = a?.closest?.('dialog[open], [aria-modal="true"]');
270
+ if (modal && !panel.contains(modal)) return;
271
+ e.preventDefault();
272
+ if (a && (panel.contains(a) || chip.contains(a))) { e.stopPropagation(); closeToChip(); }
273
+ else setOpen(false);
274
+ };
275
+ const onDown = (e: PointerEvent) => {
276
+ const t = e.target as Node;
277
+ if (!panel.contains(t) && !chip.contains(t)) setOpen(false);
278
+ };
279
+ // A modal opened from the panel (a confirm behind a Status action) takes the screen: the panel would stay above it in
280
+ // the top layer, over its buttons (review #3903). Close the panel when focus enters a modal that is not the panel.
281
+ const onFocusIn = (e: FocusEvent) => {
282
+ const m = (e.target as Element | null)?.closest?.('dialog[open], [aria-modal="true"]');
283
+ if (m && !panel.contains(m)) setOpen(false);
284
+ };
285
+ const replace = () => placeFloating(panel, chip, end);
286
+ // A scroll moves the panel's anchor only when it scrolls something that holds the chip (the page, a frame around the
287
+ // composer): a chat log streaming beside it does not. One placement per frame at most.
288
+ let frame = 0;
289
+ const onScroll = (e: Event) => {
290
+ const t = e.target as Node | null;
291
+ if (!t || panel.contains(t)) return;
292
+ if (t !== document && (t as Node).nodeType !== 9 && !t.contains(chip)) return;
293
+ if (!frame) frame = requestAnimationFrame(() => { frame = 0; replace(); });
294
+ };
295
+ // Its content changes while open (a poll turns "Allow connection" into the command): re-place on its own resize, or a
296
+ // taller panel runs off the bottom of the screen. placeFloating writes max-height, which the observer ignores (it
297
+ // only fires when the box changes size, and a clamp to the same room settles at once).
298
+ let lastH = -1;
299
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
300
+ if (panel.scrollHeight === lastH) return;
301
+ lastH = panel.scrollHeight;
302
+ replace();
303
+ });
304
+ // The panel (it grows while it fits) and its pane (content that grows past a clamped panel).
305
+ ro?.observe(panel);
306
+ const pane = panel.querySelector(".gb-engine-pane");
307
+ if (pane) ro?.observe(pane);
308
+ document.addEventListener("keydown", onKey, true);
309
+ document.addEventListener("pointerdown", onDown, true);
310
+ document.addEventListener("focusin", onFocusIn, true);
311
+ document.addEventListener("scroll", onScroll, true);
312
+ window.addEventListener("resize", replace);
313
+ return () => {
314
+ document.removeEventListener("keydown", onKey, true);
315
+ cancelAnimationFrame(frame);
316
+ document.removeEventListener("pointerdown", onDown, true);
317
+ document.removeEventListener("focusin", onFocusIn, true);
318
+ document.removeEventListener("scroll", onScroll, true);
319
+ window.removeEventListener("resize", replace);
320
+ ro?.disconnect();
321
+ };
322
+ }, [open, end, closeToChip, setOpen]);
323
+
324
+ // Focus goes INTO the panel on open (the selected tab), so a keyboard user lands where the choices are.
325
+ useEffect(() => {
326
+ if (!open) return;
327
+ const t = panelRef.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
328
+ ?? panelRef.current?.querySelector<HTMLElement>("button:not([disabled])");
329
+ t?.focus();
330
+ // Only on opening: a tab change moves focus by itself (the roving keys), a pick closes.
331
+ // eslint-disable-next-line react-hooks/exhaustive-deps
332
+ }, [open]);
333
+
334
+ const current = engines.find((e) => e.id === engine);
335
+ const modelLabel = model ? (current?.models ?? models)?.find((m) => m.id === model)?.label : undefined;
336
+ const mark = current?.mark && isEngineMarkId(current.mark) ? current.mark : null;
337
+ const tone = bridge && props.showBridgeDot !== false ? BRIDGE_TONE[bridge.state] : null;
338
+ const name = engineChipName(props.showBridgeDot === false ? { ...props, bridge: undefined } : props, L);
339
+
340
+ return (
341
+ <span className={cx("gb-engine", className)}>
342
+ <button
343
+ ref={chipRef}
344
+ type="button"
345
+ className={mark ? "gb-engine-chip has-mark" : "gb-engine-chip"}
346
+ disabled={props.disabled || undefined}
347
+ aria-haspopup="dialog"
348
+ aria-expanded={open}
349
+ aria-controls={panelId}
350
+ aria-label={name}
351
+ title={name}
352
+ onClick={() => setOpen(!open)}
353
+ >
354
+ {tone ? <span className={DOT_CLASS[tone]} aria-hidden /> : null}
355
+ <span className="gb-engine-chiplabel">
356
+ {current?.label ?? engine}
357
+ {modelLabel ? <span className="gb-engine-chipsub"> · {modelLabel}</span> : null}
358
+ </span>
359
+ {/* Shown instead of the name when the composer is too narrow for it (assistant-engine.css, ChatComposer density). */}
360
+ {mark ? <span className="gb-engine-chipmark"><EngineMark engine={mark} decorative /></span> : null}
361
+ </button>
362
+ {(() => { const panelEl = (<div
363
+ ref={panelRef}
364
+ id={panelId}
365
+ className="gb-engine-panel gemboss-popover-surface"
366
+ role="dialog"
367
+ aria-label={L.dialog}
368
+ // The top layer where the browser has one; otherwise a fixed layer in the tree (assistant-engine.css).
369
+ {...(portal ? {} : { popover: "manual" })}
370
+ hidden={!open}
371
+ data-open={open ? "" : undefined}
372
+ >
373
+ {open ? <PanelBody {...props} L={L} tab={tab} tabs={tabs} setTab={setTab} onPicked={closeToChip} /> : null}
374
+ </div>);
375
+ return portal && typeof document !== "undefined" ? createPortal(panelEl, document.body) : panelEl; })()}
376
+ </span>
377
+ );
378
+ }
379
+
380
+ /** The panel's content, also exported on its own (`EnginePanel`) for the catalog and for a settings page that shows it
381
+ * in place, without a chip. */
382
+ function PanelBody({ L, tab, tabs, setTab, onPicked, ...p }: EnginePickerProps & {
383
+ L: EnginePickerLabels; tab: EnginePickerTab; tabs: EnginePickerTab[]; setTab: (t: EnginePickerTab) => void; onPicked?: () => void;
384
+ }) {
385
+ const base = useId();
386
+ const tabRefs = useRef<Partial<Record<EnginePickerTab, HTMLButtonElement | null>>>({});
387
+ const tabLabel: Record<EnginePickerTab, string> = { engine: L.engineTab, setup: L.setupTab, status: L.statusTab };
388
+ const needsUpdate = p.bridge?.state === "outdated";
389
+ const onTabKey = (e: React.KeyboardEvent) => {
390
+ const i = tabs.indexOf(tab);
391
+ const to = e.key === "ArrowRight" ? tabs[(i + 1) % tabs.length]
392
+ : e.key === "ArrowLeft" ? tabs[(i - 1 + tabs.length) % tabs.length]
393
+ : e.key === "Home" ? tabs[0] : e.key === "End" ? tabs[tabs.length - 1] : null;
394
+ if (!to) return;
395
+ e.preventDefault();
396
+ setTab(to);
397
+ tabRefs.current[to]?.focus();
398
+ };
399
+ return (
400
+ <>
401
+ {tabs.length > 1 ? (
402
+ <div className="ui-tabs gb-engine-tabs" role="tablist" aria-label={L.tabs} onKeyDown={onTabKey}>
403
+ {tabs.map((t) => (
404
+ <button
405
+ key={t}
406
+ ref={(el) => { tabRefs.current[t] = el; }}
407
+ type="button"
408
+ role="tab"
409
+ id={`${base}-tab-${t}`}
410
+ aria-controls={`${base}-pane-${t}`}
411
+ aria-selected={tab === t}
412
+ tabIndex={tab === t ? 0 : -1}
413
+ onClick={() => setTab(t)}
414
+ >
415
+ {tabLabel[t]}
416
+ {/* The same warning the chip's dot gives, on the tab that fixes it. */}
417
+ {t === "setup" && needsUpdate ? <span className="gb-engine-dot gb-engine-dot--warn" role="img" aria-label={L.updateDue} /> : null}
418
+ </button>
419
+ ))}
420
+ </div>
421
+ ) : null}
422
+ <div
423
+ className="gb-engine-pane"
424
+ role={tabs.length > 1 ? "tabpanel" : undefined}
425
+ id={`${base}-pane-${tab}`}
426
+ aria-labelledby={tabs.length > 1 ? `${base}-tab-${tab}` : undefined}
427
+ >
428
+ {tab === "engine" ? <EngineTab {...p} L={L} onPicked={onPicked} /> : null}
429
+ {tab === "setup" ? <SetupTab setup={p.setup!} bridge={p.bridge} L={L} /> : null}
430
+ {tab === "status" ? <StatusTab bridge={p.bridge!} actions={p.statusActions} L={L} /> : null}
431
+ </div>
432
+ </>
433
+ );
434
+ }
435
+
436
+ /** The panel without the chip: the catalog, or a settings page that shows the same three tabs in place. */
437
+ export function EnginePanel(props: EnginePickerProps) {
438
+ const L = { ...ENGINE_PICKER_EN, ...props.labels };
439
+ const { tabs, tab, setTab } = useTabs(props);
440
+ return (
441
+ <div className={cx("gb-engine-panel gb-engine-panel--inline gemboss-popover-surface", props.className)} role="group" aria-label={L.dialog}>
442
+ <PanelBody {...props} L={L} tab={tab} tabs={tabs} setTab={setTab} />
443
+ </div>
444
+ );
445
+ }
446
+
447
+ function Options({ label, items, value, onChange, locked, onPicked, disabledReason }: {
448
+ label: string; items: readonly Choice[]; value: string | undefined; onChange?: (id: string) => void; locked?: boolean; onPicked?: () => void;
449
+ disabledReason?: boolean | string;
450
+ }) {
451
+ const id = useId();
452
+ const off = locked || !onChange || Boolean(disabledReason);
453
+ const why = typeof disabledReason === "string" ? disabledReason : undefined;
454
+ // Many choices (Claude Code knows ~20 models) or grouped ones: a select, not a wall of buttons.
455
+ if (items.length > 6 || items.some((m) => m.group)) {
456
+ const groups: Array<{ label?: string; items: Choice[] }> = [];
457
+ for (const m of items) {
458
+ const last = groups[groups.length - 1];
459
+ if (last && last.label === m.group) last.items.push(m); else groups.push({ label: m.group, items: [m] });
460
+ }
461
+ const opt = (m: Choice) => <option key={m.id || "default"} value={m.id} title={m.title}>{m.label}</option>;
462
+ return (
463
+ <label className="gb-engine-field">
464
+ <span className="gb-engine-label">{label}</span>
465
+ <select className="ui-select ui-select--slim gb-engine-select" value={value ?? ""} disabled={off} title={why}
466
+ onChange={(e) => { onChange?.(e.target.value); }}>
467
+ {groups.map((g, i) => (g.label ? <optgroup key={g.label + i} label={g.label}>{g.items.map(opt)}</optgroup> : g.items.map(opt)))}
468
+ </select>
469
+ </label>
470
+ );
471
+ }
472
+ return (
473
+ <div className="gb-engine-field">
474
+ <span className="gb-engine-label" id={id}>{label}</span>
475
+ <div className="gb-engine-opts" role="group" aria-labelledby={id}>
476
+ {items.map((m) => (
477
+ <button
478
+ key={m.id || "default"}
479
+ type="button"
480
+ className="gb-engine-opt"
481
+ aria-pressed={(value ?? "") === m.id}
482
+ disabled={off}
483
+ title={why ?? m.title}
484
+ onClick={() => { if ((value ?? "") !== m.id) onChange?.(m.id); onPicked?.(); }}
485
+ >
486
+ {m.label}
487
+ </button>
488
+ ))}
489
+ </div>
490
+ </div>
491
+ );
492
+ }
493
+
494
+ /** Every engine with its own models, all at once; one click picks both (the admin's popup, 0.29.0). */
495
+ function EngineGroups({ engines, engine, model, onPick, onEngineChange, onModelChange, locked, L, onPicked }: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
496
+ const id = useId();
497
+ const choose = (e: EngineChoice, m: Choice) => {
498
+ // The pick already in force: close, change nothing (a surface that starts a new conversation on a switch must not).
499
+ if (e.id === engine && (model ?? "") === m.id) { onPicked?.(); return; }
500
+ if (onPick) onPick(e.id, m.id);
501
+ else { if (e.id !== engine) onEngineChange?.(e.id); onModelChange?.(m.id); }
502
+ onPicked?.();
503
+ };
504
+ const canChoose = Boolean(onPick || onEngineChange || onModelChange);
505
+ return (
506
+ <div className="gb-engine-field">
507
+ <span className="gb-engine-label" id={id}>{L.engine}</span>
508
+ <div className="gb-engine-groups" role="group" aria-labelledby={id}>
509
+ {engines.map((e) => {
510
+ const chosen = e.id === engine;
511
+ const items = e.models && e.models.length ? e.models : [{ id: "", label: L.defaultChoice }];
512
+ return (
513
+ <div key={e.id} className="gb-engine-group" data-current={chosen ? "" : undefined}>
514
+ <span className="gb-engine-rowhead">
515
+ <span className="gb-engine-rowname">{e.label}</span>
516
+ {e.badge ? <span className={e.badgeTone ? BADGE_CLASS[e.badgeTone] : "ui-badge"}>{e.badge}</span> : null}
517
+ </span>
518
+ {e.note ? <span className="gb-engine-note">{e.note}</span> : null}
519
+ <div className="gb-engine-opts" role="group" aria-label={e.label}>
520
+ {items.map((m) => (
521
+ <button key={m.id || "default"} type="button" className="gb-engine-opt" title={m.title}
522
+ aria-pressed={chosen && (model ?? "") === m.id}
523
+ disabled={locked || e.disabled || !canChoose}
524
+ onClick={() => choose(e, m)}>
525
+ {m.label}
526
+ </button>
527
+ ))}
528
+ </div>
529
+ </div>
530
+ );
531
+ })}
532
+ </div>
533
+ </div>
534
+ );
535
+ }
536
+
537
+ function EngineTab(props: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
538
+ const { engines, engine, onEngineChange, models, model, onModelChange, modelControl, efforts, effort, onEffortChange, effortDisabled, notes, locked, L, onPicked } = props;
539
+ const id = useId();
540
+ if (engines.some((e) => e.models && e.models.length)) {
541
+ return (
542
+ <>
543
+ <EngineGroups {...props} />
544
+ {efforts && efforts.length ? <Options label={L.effort} items={efforts} value={effort} onChange={onEffortChange} locked={locked} onPicked={onPicked} disabledReason={effortDisabled} /> : null}
545
+ {props.problem ? <p className="gb-engine-problem" role="alert">{props.problem}</p> : null}
546
+ {notes ? <div className="gb-engine-notes">{notes}</div> : null}
547
+ </>
548
+ );
549
+ }
550
+ // The ARIA radio pattern: one Tab stop (the checked engine, else the first that can be picked); arrows move AND pick.
551
+ const can = (e: EngineChoice) => !(locked || e.disabled || !onEngineChange);
552
+ const stop = engines.find((e) => e.id === engine && can(e))?.id ?? engines.find(can)?.id;
553
+ const onKey = (ev: React.KeyboardEvent<HTMLDivElement>) => {
554
+ const dir = ev.key === "ArrowDown" || ev.key === "ArrowRight" ? 1 : ev.key === "ArrowUp" || ev.key === "ArrowLeft" ? -1 : 0;
555
+ if (!dir) return;
556
+ const open = engines.filter(can);
557
+ if (!open.length) return;
558
+ ev.preventDefault();
559
+ const from = open.findIndex((e) => e.id === (ev.target as HTMLElement).dataset.engine);
560
+ const next = open[(from + dir + open.length) % open.length];
561
+ if (next.id !== engine) onEngineChange?.(next.id);
562
+ ev.currentTarget.querySelector<HTMLElement>(`[data-engine="${CSS.escape(next.id)}"]`)?.focus();
563
+ };
564
+ return (
565
+ <>
566
+ <div className="gb-engine-field">
567
+ <span className="gb-engine-label" id={id}>{L.engine}</span>
568
+ <div className="gb-engine-list" role="radiogroup" aria-labelledby={id} onKeyDown={onKey}>
569
+ {engines.map((e) => (
570
+ <button
571
+ key={e.id}
572
+ type="button"
573
+ role="radio"
574
+ aria-checked={e.id === engine}
575
+ data-engine={e.id}
576
+ tabIndex={e.id === stop ? 0 : -1}
577
+ className="gb-engine-row"
578
+ disabled={!can(e)}
579
+ // A new engine keeps the panel open: its models and efforts are the next thing to look at.
580
+ onClick={() => { if (e.id !== engine) onEngineChange?.(e.id); }}
581
+ >
582
+ <span className="gb-engine-rowhead">
583
+ <span className="gb-engine-rowname">{e.label}</span>
584
+ {e.badge ? <span className={e.badgeTone ? BADGE_CLASS[e.badgeTone] : "ui-badge"}>{e.badge}</span> : null}
585
+ </span>
586
+ {e.note ? <span className="gb-engine-note">{e.note}</span> : null}
587
+ </button>
588
+ ))}
589
+ </div>
590
+ </div>
591
+ {modelControl ? (
592
+ <div className="gb-engine-field"><span className="gb-engine-label">{L.model}</span>{modelControl}</div>
593
+ ) : models && models.length ? <Options label={L.model} items={models} value={model} onChange={onModelChange} locked={locked} onPicked={onPicked} /> : null}
594
+ {efforts && efforts.length ? <Options label={L.effort} items={efforts} value={effort} onChange={onEffortChange} locked={locked} onPicked={onPicked} disabledReason={effortDisabled} /> : null}
595
+ {props.problem ? <p className="gb-engine-problem" role="alert">{props.problem}</p> : null}
596
+ {notes ? <div className="gb-engine-notes">{notes}</div> : null}
597
+ </>
598
+ );
599
+ }
600
+
601
+ /** Copies through the async clipboard, else through a selected textarea (an http:// page, an old Safari). */
602
+ export async function copyText(text: string): Promise<boolean> {
603
+ try {
604
+ if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
605
+ } catch { /* permission refused: try the old way */ }
606
+ try {
607
+ const ta = document.createElement("textarea");
608
+ ta.value = text;
609
+ ta.setAttribute("readonly", "");
610
+ ta.style.position = "fixed";
611
+ ta.style.opacity = "0";
612
+ document.body.appendChild(ta);
613
+ ta.select();
614
+ const ok = document.execCommand("copy");
615
+ ta.remove();
616
+ return ok;
617
+ } catch { return false; }
618
+ }
619
+
620
+ function SetupTab({ setup, bridge, L }: { setup: BridgeSetup; bridge?: BridgeStatus; L: EnginePickerLabels }) {
621
+ const [said, setSaid] = useState<"" | "copied" | "failed">("");
622
+ const timer = useRef<number | undefined>(undefined);
623
+ useEffect(() => () => window.clearTimeout(timer.current), []);
624
+ const copy = async () => {
625
+ if (!setup.command) return;
626
+ const ok = await copyText(setup.command);
627
+ setSaid(ok ? "copied" : "failed");
628
+ window.clearTimeout(timer.current);
629
+ timer.current = window.setTimeout(() => setSaid(""), 2000);
630
+ };
631
+ return (
632
+ <>
633
+ {bridge?.state === "outdated" || bridge?.state === "offline" || bridge?.state === "unpaired" ? (
634
+ <p className={cx("gb-engine-callout", bridge.state === "outdated" ? "gb-engine-callout--warn" : "gb-engine-callout--down")}>
635
+ <strong>{L.bridge}: {L[STATE_LABEL[bridge.state]].toLowerCase()}.</strong>
636
+ {bridge.detail ? <> {bridge.detail}</> : null}
637
+ </p>
638
+ ) : null}
639
+ {setup.steps || setup.command ? <div className="gb-engine-steps">{setup.steps ?? <p>{L.steps}</p>}</div> : null}
640
+ {setup.command ? (
641
+ <>
642
+ <pre className="gb-engine-cmd"><code>{setup.command}</code></pre>
643
+ <div className="gb-engine-actions">
644
+ <button type="button" className="ui-btn ui-btn--primary" onClick={() => void copy()}>
645
+ {said === "copied" ? L.copied : L.copy}
646
+ </button>
647
+ <span className="gb-engine-said" role="status">{said === "failed" ? L.copyFailed : said === "copied" ? L.copied : ""}</span>
648
+ </div>
649
+ </>
650
+ ) : null}
651
+ {setup.pairingCode ? (
652
+ <p className="gb-engine-code">{L.pairingCode}: <code>{setup.pairingCode}</code></p>
653
+ ) : null}
654
+ {setup.note ? <p className="gb-engine-note">{setup.note}</p> : null}
655
+ </>
656
+ );
657
+ }
658
+
659
+ function StatusTab({ bridge, actions, L }: { bridge: BridgeStatus; actions?: ReactNode; L: EnginePickerLabels }) {
660
+ const tone = BRIDGE_TONE[bridge.state];
661
+ const yesNo = (v: boolean | undefined) => (v === undefined ? L.unknown : v ? L.yes : L.no);
662
+ return (
663
+ <>
664
+ <dl className="gb-engine-rows">
665
+ <dt>{L.bridge}</dt>
666
+ <dd><span className={DOT_CLASS[tone]} aria-hidden /> {L[STATE_LABEL[bridge.state]]}</dd>
667
+ {bridge.version ? <><dt>{L.version}</dt><dd>{bridge.version}{bridge.latest && bridge.latest !== bridge.version ? ` · ${L.latest} ${bridge.latest}` : ""}</dd></> : null}
668
+ <dt>{L.startsAtSignIn}</dt><dd>{yesNo(bridge.autostart)}</dd>
669
+ {bridge.machine ? <><dt>{L.machine}</dt><dd>{bridge.machine}</dd></> : null}
670
+ {bridge.lastSeen ? <><dt>{L.lastSeen}</dt><dd>{bridge.lastSeen}</dd></> : null}
671
+ </dl>
672
+ {bridge.detail && bridge.state !== "running" ? <p className="gb-engine-note">{bridge.detail}</p> : null}
673
+ {actions ? <div className="gb-engine-actions">{actions}</div> : null}
674
+ </>
675
+ );
676
+ }
677
+
678
+ /**
679
+ * ONE line in the chat stream, for a turn that actually failed because of the bridge ("The bridge on your machine is
680
+ * not connected."), with "Open setup" that opens the picker on its Setup tab. Never a standing block: the chip's dot says
681
+ * "outdated" all the time, this says "this turn failed, here is why" once.
682
+ */
683
+ export function BridgeNotice({ children, onOpenSetup, action = "Open setup", tone = "down", className }: {
684
+ children: ReactNode;
685
+ onOpenSetup?: () => void;
686
+ action?: string;
687
+ tone?: "warn" | "down";
688
+ className?: string;
689
+ }) {
690
+ return (
691
+ <p className={cx(NOTICE_CLASS[tone], className)} role="status">
692
+ <span className={DOT_CLASS[tone]} aria-hidden />
693
+ <span className="gb-engine-notice-text">{children}</span>
694
+ {onOpenSetup ? <button type="button" className="gb-engine-notice-link" onClick={onOpenSetup}>{action}</button> : null}
695
+ </p>
696
+ );
697
+ }