@gemboss/ui 0.21.0 → 0.25.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 +150 -0
- package/README.md +177 -6
- package/catalog/editor.html +426 -10
- package/catalog/index.html +184 -32
- package/gemboss-ui.css +3 -3
- package/package.json +11 -3
- package/parts/components.css +5 -0
- package/parts/core.css +5 -0
- package/parts/shell.css +5 -0
- package/src/app-shell.css +1 -1
- package/src/assistant/engine.tsx +607 -0
- package/src/assistant/host.tsx +1 -0
- package/src/assistant/index.ts +1 -0
- package/src/assistant-engine.css +1 -0
- package/src/auth.tsx +1 -0
- package/src/collections.tsx +1 -0
- package/src/dialog.tsx +3 -135
- package/src/editor/controls.tsx +1 -0
- package/src/editor/frame.tsx +1 -0
- package/src/editor/index.ts +9 -0
- package/src/editor/layers.tsx +136 -0
- package/src/editor/panel.tsx +1 -0
- package/src/editor/panes.tsx +4 -3
- package/src/editor/sortable.tsx +79 -0
- package/src/editor/tree.tsx +61 -0
- package/src/editor-layers-plain.js +1 -0
- package/src/editor.css +1 -1
- package/src/index-table.tsx +1 -0
- package/src/index.ts +4 -2
- package/src/kit-plain.css +1 -1
- package/src/launcher.tsx +1 -0
- package/src/menu-layer.ts +9 -2
- package/src/portal.tsx +1 -0
- package/src/reactions.tsx +1 -0
- package/src/resource-list-select.tsx +1 -0
- package/src/search-dialog.tsx +146 -0
- package/src/selection.tsx +1 -0
- package/src/theme-script.ts +10 -0
- package/src/theme.tsx +5 -5
|
@@ -0,0 +1,607 @@
|
|
|
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
|
+
|
|
32
|
+
const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
|
|
33
|
+
const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
34
|
+
|
|
35
|
+
/** running = answers now · outdated = answers, but an update is due · offline = paired, not reachable · unpaired = never
|
|
36
|
+
* set up on this account · checking = not known yet (first poll in flight). */
|
|
37
|
+
export type BridgeState = "running" | "outdated" | "offline" | "unpaired" | "checking";
|
|
38
|
+
export type EnginePickerTab = "engine" | "setup" | "status";
|
|
39
|
+
|
|
40
|
+
export interface EngineChoice {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
/** One quiet line under the name: who pays, or why it cannot be picked ("Install Codex on that machine"). */
|
|
44
|
+
note?: ReactNode;
|
|
45
|
+
/** A short badge beside the name ("not signed in", "your plan"). */
|
|
46
|
+
badge?: string;
|
|
47
|
+
badgeTone?: "success" | "warning" | "critical" | "info";
|
|
48
|
+
/** Cannot be picked right now. Say why in `note`. */
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Choice {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
/** Tooltip: what this model or effort means. */
|
|
56
|
+
title?: string;
|
|
57
|
+
/** A heading the choice sits under ("Opus", "Sonnet"). A list with groups, or more than six choices, is a select. */
|
|
58
|
+
group?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BridgeStatus {
|
|
62
|
+
state: BridgeState;
|
|
63
|
+
/** The bridge on the machine, and the one the server would install today. */
|
|
64
|
+
version?: string;
|
|
65
|
+
latest?: string;
|
|
66
|
+
/** Starts by itself when the user signs in to that machine. Undefined: not known. */
|
|
67
|
+
autostart?: boolean;
|
|
68
|
+
/** Which machine ("Iris's MacBook"). */
|
|
69
|
+
machine?: string;
|
|
70
|
+
/** Already formatted ("2 min ago"). */
|
|
71
|
+
lastSeen?: string;
|
|
72
|
+
/** One or two sentences: what is wrong and what it costs ("Codex cannot see the radar tools"). Shown on Status and Setup. */
|
|
73
|
+
detail?: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BridgeSetup {
|
|
77
|
+
/** The install / update command, pasted into a terminal. */
|
|
78
|
+
command?: string;
|
|
79
|
+
/** The pairing code the command or the bridge asks for, when it is not already in the command. */
|
|
80
|
+
pairingCode?: string;
|
|
81
|
+
/** A few lines of how (an <ol>, a sentence). Default: "Paste the command into Terminal, then press Enter." */
|
|
82
|
+
steps?: ReactNode;
|
|
83
|
+
/** Quiet text under the command ("The pairing code stays the same: the panel reconnects in a few seconds."). */
|
|
84
|
+
note?: ReactNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every word the picker says. English, like every surface's chrome (Chris 03/10/2026). */
|
|
88
|
+
export interface EnginePickerLabels {
|
|
89
|
+
chip: string; dialog: string; tabs: string;
|
|
90
|
+
engineTab: string; setupTab: string; statusTab: string;
|
|
91
|
+
engine: string; model: string; effort: string;
|
|
92
|
+
defaultChoice: string;
|
|
93
|
+
copy: string; copied: string; copyFailed: string; pairingCode: string; steps: string;
|
|
94
|
+
bridge: string; version: string; latest: string; startsAtSignIn: string; machine: string; lastSeen: string;
|
|
95
|
+
yes: string; no: string; unknown: string;
|
|
96
|
+
running: string; outdated: string; offline: string; unpaired: string; checking: string;
|
|
97
|
+
updateDue: string; close: string;
|
|
98
|
+
}
|
|
99
|
+
export const ENGINE_PICKER_EN: EnginePickerLabels = {
|
|
100
|
+
chip: "Engine", dialog: "Assistant engine", tabs: "Engine, setup and status",
|
|
101
|
+
engineTab: "Engine", setupTab: "Setup", statusTab: "Status",
|
|
102
|
+
engine: "Engine", model: "Model", effort: "Effort",
|
|
103
|
+
defaultChoice: "Default",
|
|
104
|
+
copy: "Copy command", copied: "Copied", copyFailed: "Select the command and copy it", pairingCode: "Pairing code", steps: "Paste the command into Terminal, then press Enter.",
|
|
105
|
+
bridge: "Bridge on your machine", version: "Version", latest: "Latest", startsAtSignIn: "Starts at sign-in", machine: "Machine", lastSeen: "Last seen",
|
|
106
|
+
yes: "Yes", no: "No", unknown: "Not known",
|
|
107
|
+
running: "Running", outdated: "Outdated", offline: "Not connected", unpaired: "Not set up", checking: "Checking",
|
|
108
|
+
updateDue: "Update available", close: "Close",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The dot's tone per state: one word each, so the CSS has one rule per tone and a test can read it. */
|
|
112
|
+
export const BRIDGE_TONE: Record<BridgeState, "ok" | "warn" | "down" | "idle"> = {
|
|
113
|
+
running: "ok", outdated: "warn", offline: "down", unpaired: "down", checking: "idle",
|
|
114
|
+
};
|
|
115
|
+
/** Literal class names, never a template like `gb-engine-dot--` + tone: a runtime-built class is a stem the admin's orphan
|
|
116
|
+
* gate (a-tap-target-rule-outlived-its-markup) would have to exempt wholesale, as host.tsx says of MODE_CLASS. */
|
|
117
|
+
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;
|
|
118
|
+
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;
|
|
119
|
+
const NOTICE_CLASS = { warn: "gb-engine-notice gb-engine-notice--warn", down: "gb-engine-notice gb-engine-notice--down" } as const;
|
|
120
|
+
const STATE_LABEL: Record<BridgeState, keyof EnginePickerLabels> = {
|
|
121
|
+
running: "running", outdated: "outdated", offline: "offline", unpaired: "unpaired", checking: "checking",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export interface EnginePickerProps {
|
|
125
|
+
engines: readonly EngineChoice[];
|
|
126
|
+
engine: string;
|
|
127
|
+
onEngineChange?: (id: string) => void;
|
|
128
|
+
/** The models of the CURRENT engine. Omit (or pass []) and the row is not drawn. An id "" is "leave the CLI's own
|
|
129
|
+
* setting in charge" and reads as "Default" on the chip (it is not shown there). */
|
|
130
|
+
models?: readonly Choice[];
|
|
131
|
+
model?: string;
|
|
132
|
+
onModelChange?: (id: string) => void;
|
|
133
|
+
/** In place of the model choices: the surface's own control (Codex takes a typed model name). */
|
|
134
|
+
modelControl?: ReactNode;
|
|
135
|
+
efforts?: readonly Choice[];
|
|
136
|
+
effort?: string;
|
|
137
|
+
onEffortChange?: (id: string) => void;
|
|
138
|
+
/** Effort cannot be set right now; a string says why (its tooltip). */
|
|
139
|
+
effortDisabled?: boolean | string;
|
|
140
|
+
/** Quiet lines under the Engine tab's fields: who pays, what this bridge version cannot do yet. */
|
|
141
|
+
notes?: ReactNode;
|
|
142
|
+
/** The bridge on the user's machine. Omit for an engine set that runs on the server only: no dot, no Status tab. */
|
|
143
|
+
bridge?: BridgeStatus;
|
|
144
|
+
/** The dot on the chip (default true when `bridge` is given). False when the current engine does not go through the
|
|
145
|
+
* bridge (a server engine): a red dot would say "broken" about something this engine does not use. */
|
|
146
|
+
showBridgeDot?: boolean;
|
|
147
|
+
/** Omit and there is no Setup tab. */
|
|
148
|
+
setup?: BridgeSetup;
|
|
149
|
+
/** Buttons under the status rows ("Forget this machine", "Restart"). */
|
|
150
|
+
statusActions?: ReactNode;
|
|
151
|
+
/** Controlled open state and tab (a `BridgeNotice` opens Setup through these: see `useEnginePicker`). */
|
|
152
|
+
open?: boolean;
|
|
153
|
+
onOpenChange?: (open: boolean) => void;
|
|
154
|
+
tab?: EnginePickerTab;
|
|
155
|
+
onTabChange?: (tab: EnginePickerTab) => void;
|
|
156
|
+
/** While a turn runs the surface may refuse a switch: the chip stays readable, the choices are disabled. */
|
|
157
|
+
locked?: boolean;
|
|
158
|
+
/** Align the panel's right edge with the chip's (a chip near the right edge of a narrow frame). */
|
|
159
|
+
end?: boolean;
|
|
160
|
+
labels?: Partial<EnginePickerLabels>;
|
|
161
|
+
className?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The open state and tab of one picker, for a surface that also renders a `BridgeNotice`:
|
|
165
|
+
* `const p = useEnginePicker(); <EnginePicker {...p.props} …/> <BridgeNotice onOpenSetup={() => p.openTab("setup")} …/>` */
|
|
166
|
+
export function useEnginePicker(initial: EnginePickerTab = "engine") {
|
|
167
|
+
const [open, setOpen] = useState(false);
|
|
168
|
+
const [tab, setTab] = useState<EnginePickerTab>(initial);
|
|
169
|
+
const openTab = useCallback((t: EnginePickerTab) => { setTab(t); setOpen(true); }, []);
|
|
170
|
+
// Closing goes back to Engine: after "Open setup" the next click on the chip is about the engine again (review #3885).
|
|
171
|
+
const onOpenChange = useCallback((v: boolean) => { setOpen(v); if (!v) setTab(initial); }, [initial]);
|
|
172
|
+
return { open, tab, setOpen, setTab, openTab, props: { open, onOpenChange, tab, onTabChange: setTab } };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function availableTabs(p: Pick<EnginePickerProps, "bridge" | "setup">): EnginePickerTab[] {
|
|
176
|
+
return ["engine", ...(p.setup ? ["setup" as const] : []), ...(p.bridge ? ["status" as const] : [])];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The tab shown (controlled by `tab` or held here) among the tabs the props offer; one copy for the picker and the panel. */
|
|
180
|
+
function useTabs(p: Pick<EnginePickerProps, "bridge" | "setup" | "tab" | "onTabChange">) {
|
|
181
|
+
const tabs = availableTabs(p);
|
|
182
|
+
const [own, setOwn] = useState<EnginePickerTab>("engine");
|
|
183
|
+
const wanted = p.tab ?? own;
|
|
184
|
+
const tab = tabs.includes(wanted) ? wanted : "engine";
|
|
185
|
+
const { tab: controlled, onTabChange } = p;
|
|
186
|
+
const setTab = useCallback((t: EnginePickerTab) => {
|
|
187
|
+
if (controlled === undefined) setOwn(t);
|
|
188
|
+
onTabChange?.(t);
|
|
189
|
+
}, [controlled, onTabChange]);
|
|
190
|
+
return { tabs, tab, setTab, resetOwn: () => setOwn("engine") };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What the chip says out loud: the engine, the model, and the bridge when it is not fine. */
|
|
194
|
+
export function engineChipName(p: Pick<EnginePickerProps, "engines" | "engine" | "models" | "model" | "bridge">, L: EnginePickerLabels = ENGINE_PICKER_EN): string {
|
|
195
|
+
const eng = p.engines.find((e) => e.id === p.engine)?.label ?? p.engine;
|
|
196
|
+
const model = p.model ? p.models?.find((m) => m.id === p.model)?.label : undefined;
|
|
197
|
+
const bridge = p.bridge && p.bridge.state !== "running" ? `. ${L.bridge}: ${L[STATE_LABEL[p.bridge.state]]}` : "";
|
|
198
|
+
return `${L.chip}: ${eng}${model ? ` · ${model}` : ""}${bridge}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function EnginePicker(props: EnginePickerProps) {
|
|
202
|
+
const { engines, engine, models, model, bridge, end = false, className } = props;
|
|
203
|
+
const L = { ...ENGINE_PICKER_EN, ...props.labels };
|
|
204
|
+
const [ownOpen, setOwnOpen] = useState(false);
|
|
205
|
+
const open = props.open ?? ownOpen;
|
|
206
|
+
const { tabs, tab, setTab, resetOwn } = useTabs(props);
|
|
207
|
+
// Without the popover API (Safari < 17) a fixed layer inside the assistant's frame is cut by it (overflow, and the
|
|
208
|
+
// frame's backdrop-filter makes it the containing block): there the panel goes to <body> through a portal instead.
|
|
209
|
+
// Decided after mount, so the server and the first client render agree.
|
|
210
|
+
const [portal, setPortal] = useState(false);
|
|
211
|
+
useEffect(() => { setPortal(!canFloatMenus()); }, []);
|
|
212
|
+
const chipRef = useRef<HTMLButtonElement>(null);
|
|
213
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
214
|
+
const panelId = useId();
|
|
215
|
+
|
|
216
|
+
const setOpen = useCallback((v: boolean) => {
|
|
217
|
+
if (props.open === undefined) setOwnOpen(v);
|
|
218
|
+
if (!v) resetOwn();
|
|
219
|
+
props.onOpenChange?.(v);
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, [props.open, props.onOpenChange]);
|
|
222
|
+
/** Close and put focus back on the chip: Escape, a pick, the close button. An outside click closes without it (the
|
|
223
|
+
* click already moved focus where the user wanted it). */
|
|
224
|
+
const closeToChip = useCallback(() => { setOpen(false); chipRef.current?.focus(); }, [setOpen]);
|
|
225
|
+
|
|
226
|
+
// Float / sink with `open`, before paint, so the panel is never drawn unplaced.
|
|
227
|
+
useBeforePaint(() => {
|
|
228
|
+
const panel = panelRef.current, chip = chipRef.current;
|
|
229
|
+
if (!panel || !chip) return;
|
|
230
|
+
if (!open) {
|
|
231
|
+
if (canFloatMenus() && panel.matches(":popover-open")) panel.hidePopover();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (canFloatMenus() && !panel.matches(":popover-open")) panel.showPopover();
|
|
235
|
+
placeFloating(panel, chip, end);
|
|
236
|
+
}, [open, end, tab]);
|
|
237
|
+
|
|
238
|
+
// While open: Escape, outside pointer, and keep it against the chip when the window or a frame around the chip moves.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!open) return undefined;
|
|
241
|
+
const panel = panelRef.current, chip = chipRef.current;
|
|
242
|
+
if (!panel || !chip) return undefined;
|
|
243
|
+
// Escape with focus in the panel or on the chip closes the panel only: captured first and stopped, so the surface's
|
|
244
|
+
// own Escape (hide the assistant) does not fire on the same key. With focus elsewhere (the composer), it closes the
|
|
245
|
+
// panel and leaves focus and the key alone.
|
|
246
|
+
const onKey = (e: KeyboardEvent) => {
|
|
247
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
248
|
+
const a = document.activeElement;
|
|
249
|
+
if (a && (panel.contains(a) || chip.contains(a))) { e.preventDefault(); e.stopPropagation(); closeToChip(); }
|
|
250
|
+
else setOpen(false);
|
|
251
|
+
};
|
|
252
|
+
const onDown = (e: PointerEvent) => {
|
|
253
|
+
const t = e.target as Node;
|
|
254
|
+
if (!panel.contains(t) && !chip.contains(t)) setOpen(false);
|
|
255
|
+
};
|
|
256
|
+
const replace = () => placeFloating(panel, chip, end);
|
|
257
|
+
// A scroll moves the panel's anchor only when it scrolls something that holds the chip (the page, a frame around the
|
|
258
|
+
// composer): a chat log streaming beside it does not. One placement per frame at most.
|
|
259
|
+
let frame = 0;
|
|
260
|
+
const onScroll = (e: Event) => {
|
|
261
|
+
const t = e.target as Node | null;
|
|
262
|
+
if (!t || panel.contains(t)) return;
|
|
263
|
+
if (t !== document && (t as Node).nodeType !== 9 && !t.contains(chip)) return;
|
|
264
|
+
if (!frame) frame = requestAnimationFrame(() => { frame = 0; replace(); });
|
|
265
|
+
};
|
|
266
|
+
// Its content changes while open (a poll turns "Allow connection" into the command): re-place on its own resize, or a
|
|
267
|
+
// taller panel runs off the bottom of the screen. placeFloating writes max-height, which the observer ignores (it
|
|
268
|
+
// only fires when the box changes size, and a clamp to the same room settles at once).
|
|
269
|
+
let lastH = -1;
|
|
270
|
+
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
|
|
271
|
+
if (panel.scrollHeight === lastH) return;
|
|
272
|
+
lastH = panel.scrollHeight;
|
|
273
|
+
replace();
|
|
274
|
+
});
|
|
275
|
+
// The panel (it grows while it fits) and its pane (content that grows past a clamped panel).
|
|
276
|
+
ro?.observe(panel);
|
|
277
|
+
const pane = panel.querySelector(".gb-engine-pane");
|
|
278
|
+
if (pane) ro?.observe(pane);
|
|
279
|
+
document.addEventListener("keydown", onKey, true);
|
|
280
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
281
|
+
document.addEventListener("scroll", onScroll, true);
|
|
282
|
+
window.addEventListener("resize", replace);
|
|
283
|
+
return () => {
|
|
284
|
+
document.removeEventListener("keydown", onKey, true);
|
|
285
|
+
cancelAnimationFrame(frame);
|
|
286
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
287
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
288
|
+
window.removeEventListener("resize", replace);
|
|
289
|
+
ro?.disconnect();
|
|
290
|
+
};
|
|
291
|
+
}, [open, end, closeToChip, setOpen]);
|
|
292
|
+
|
|
293
|
+
// Focus goes INTO the panel on open (the selected tab), so a keyboard user lands where the choices are.
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!open) return;
|
|
296
|
+
const t = panelRef.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
|
297
|
+
?? panelRef.current?.querySelector<HTMLElement>("button:not([disabled])");
|
|
298
|
+
t?.focus();
|
|
299
|
+
// Only on opening: a tab change moves focus by itself (the roving keys), a pick closes.
|
|
300
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
301
|
+
}, [open]);
|
|
302
|
+
|
|
303
|
+
const current = engines.find((e) => e.id === engine);
|
|
304
|
+
const modelLabel = model ? models?.find((m) => m.id === model)?.label : undefined;
|
|
305
|
+
const tone = bridge && props.showBridgeDot !== false ? BRIDGE_TONE[bridge.state] : null;
|
|
306
|
+
const name = engineChipName(props.showBridgeDot === false ? { ...props, bridge: undefined } : props, L);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<span className={cx("gb-engine", className)}>
|
|
310
|
+
<button
|
|
311
|
+
ref={chipRef}
|
|
312
|
+
type="button"
|
|
313
|
+
className="gb-engine-chip"
|
|
314
|
+
aria-haspopup="dialog"
|
|
315
|
+
aria-expanded={open}
|
|
316
|
+
aria-controls={panelId}
|
|
317
|
+
aria-label={name}
|
|
318
|
+
title={name}
|
|
319
|
+
onClick={() => setOpen(!open)}
|
|
320
|
+
>
|
|
321
|
+
{tone ? <span className={DOT_CLASS[tone]} aria-hidden /> : null}
|
|
322
|
+
<span className="gb-engine-chiplabel">
|
|
323
|
+
{current?.label ?? engine}
|
|
324
|
+
{modelLabel ? <span className="gb-engine-chipsub"> · {modelLabel}</span> : null}
|
|
325
|
+
</span>
|
|
326
|
+
</button>
|
|
327
|
+
{(() => { const panelEl = (<div
|
|
328
|
+
ref={panelRef}
|
|
329
|
+
id={panelId}
|
|
330
|
+
className="gb-engine-panel gemboss-popover-surface"
|
|
331
|
+
role="dialog"
|
|
332
|
+
aria-label={L.dialog}
|
|
333
|
+
// The top layer where the browser has one; otherwise a fixed layer in the tree (assistant-engine.css).
|
|
334
|
+
{...(portal ? {} : { popover: "manual" })}
|
|
335
|
+
hidden={!open}
|
|
336
|
+
data-open={open ? "" : undefined}
|
|
337
|
+
>
|
|
338
|
+
{open ? <PanelBody {...props} L={L} tab={tab} tabs={tabs} setTab={setTab} onPicked={closeToChip} /> : null}
|
|
339
|
+
</div>);
|
|
340
|
+
return portal && typeof document !== "undefined" ? createPortal(panelEl, document.body) : panelEl; })()}
|
|
341
|
+
</span>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The panel's content, also exported on its own (`EnginePanel`) for the catalog and for a settings page that shows it
|
|
346
|
+
* in place, without a chip. */
|
|
347
|
+
function PanelBody({ L, tab, tabs, setTab, onPicked, ...p }: EnginePickerProps & {
|
|
348
|
+
L: EnginePickerLabels; tab: EnginePickerTab; tabs: EnginePickerTab[]; setTab: (t: EnginePickerTab) => void; onPicked?: () => void;
|
|
349
|
+
}) {
|
|
350
|
+
const base = useId();
|
|
351
|
+
const tabRefs = useRef<Partial<Record<EnginePickerTab, HTMLButtonElement | null>>>({});
|
|
352
|
+
const tabLabel: Record<EnginePickerTab, string> = { engine: L.engineTab, setup: L.setupTab, status: L.statusTab };
|
|
353
|
+
const needsUpdate = p.bridge?.state === "outdated";
|
|
354
|
+
const onTabKey = (e: React.KeyboardEvent) => {
|
|
355
|
+
const i = tabs.indexOf(tab);
|
|
356
|
+
const to = e.key === "ArrowRight" ? tabs[(i + 1) % tabs.length]
|
|
357
|
+
: e.key === "ArrowLeft" ? tabs[(i - 1 + tabs.length) % tabs.length]
|
|
358
|
+
: e.key === "Home" ? tabs[0] : e.key === "End" ? tabs[tabs.length - 1] : null;
|
|
359
|
+
if (!to) return;
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
setTab(to);
|
|
362
|
+
tabRefs.current[to]?.focus();
|
|
363
|
+
};
|
|
364
|
+
return (
|
|
365
|
+
<>
|
|
366
|
+
{tabs.length > 1 ? (
|
|
367
|
+
<div className="ui-tabs gb-engine-tabs" role="tablist" aria-label={L.tabs} onKeyDown={onTabKey}>
|
|
368
|
+
{tabs.map((t) => (
|
|
369
|
+
<button
|
|
370
|
+
key={t}
|
|
371
|
+
ref={(el) => { tabRefs.current[t] = el; }}
|
|
372
|
+
type="button"
|
|
373
|
+
role="tab"
|
|
374
|
+
id={`${base}-tab-${t}`}
|
|
375
|
+
aria-controls={`${base}-pane-${t}`}
|
|
376
|
+
aria-selected={tab === t}
|
|
377
|
+
tabIndex={tab === t ? 0 : -1}
|
|
378
|
+
onClick={() => setTab(t)}
|
|
379
|
+
>
|
|
380
|
+
{tabLabel[t]}
|
|
381
|
+
{/* The same warning the chip's dot gives, on the tab that fixes it. */}
|
|
382
|
+
{t === "setup" && needsUpdate ? <span className="gb-engine-dot gb-engine-dot--warn" role="img" aria-label={L.updateDue} /> : null}
|
|
383
|
+
</button>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
) : null}
|
|
387
|
+
<div
|
|
388
|
+
className="gb-engine-pane"
|
|
389
|
+
role={tabs.length > 1 ? "tabpanel" : undefined}
|
|
390
|
+
id={`${base}-pane-${tab}`}
|
|
391
|
+
aria-labelledby={tabs.length > 1 ? `${base}-tab-${tab}` : undefined}
|
|
392
|
+
>
|
|
393
|
+
{tab === "engine" ? <EngineTab {...p} L={L} onPicked={onPicked} /> : null}
|
|
394
|
+
{tab === "setup" ? <SetupTab setup={p.setup!} bridge={p.bridge} L={L} /> : null}
|
|
395
|
+
{tab === "status" ? <StatusTab bridge={p.bridge!} actions={p.statusActions} L={L} /> : null}
|
|
396
|
+
</div>
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The panel without the chip: the catalog, or a settings page that shows the same three tabs in place. */
|
|
402
|
+
export function EnginePanel(props: EnginePickerProps) {
|
|
403
|
+
const L = { ...ENGINE_PICKER_EN, ...props.labels };
|
|
404
|
+
const { tabs, tab, setTab } = useTabs(props);
|
|
405
|
+
return (
|
|
406
|
+
<div className={cx("gb-engine-panel gb-engine-panel--inline gemboss-popover-surface", props.className)} role="group" aria-label={L.dialog}>
|
|
407
|
+
<PanelBody {...props} L={L} tab={tab} tabs={tabs} setTab={setTab} />
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function Options({ label, items, value, onChange, locked, onPicked, disabledReason }: {
|
|
413
|
+
label: string; items: readonly Choice[]; value: string | undefined; onChange?: (id: string) => void; locked?: boolean; onPicked?: () => void;
|
|
414
|
+
disabledReason?: boolean | string;
|
|
415
|
+
}) {
|
|
416
|
+
const id = useId();
|
|
417
|
+
const off = locked || !onChange || Boolean(disabledReason);
|
|
418
|
+
const why = typeof disabledReason === "string" ? disabledReason : undefined;
|
|
419
|
+
// Many choices (Claude Code knows ~20 models) or grouped ones: a select, not a wall of buttons.
|
|
420
|
+
if (items.length > 6 || items.some((m) => m.group)) {
|
|
421
|
+
const groups: Array<{ label?: string; items: Choice[] }> = [];
|
|
422
|
+
for (const m of items) {
|
|
423
|
+
const last = groups[groups.length - 1];
|
|
424
|
+
if (last && last.label === m.group) last.items.push(m); else groups.push({ label: m.group, items: [m] });
|
|
425
|
+
}
|
|
426
|
+
const opt = (m: Choice) => <option key={m.id || "default"} value={m.id} title={m.title}>{m.label}</option>;
|
|
427
|
+
return (
|
|
428
|
+
<label className="gb-engine-field">
|
|
429
|
+
<span className="gb-engine-label">{label}</span>
|
|
430
|
+
<select className="ui-select ui-select--slim gb-engine-select" value={value ?? ""} disabled={off} title={why}
|
|
431
|
+
onChange={(e) => { onChange?.(e.target.value); }}>
|
|
432
|
+
{groups.map((g, i) => (g.label ? <optgroup key={g.label + i} label={g.label}>{g.items.map(opt)}</optgroup> : g.items.map(opt)))}
|
|
433
|
+
</select>
|
|
434
|
+
</label>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return (
|
|
438
|
+
<div className="gb-engine-field">
|
|
439
|
+
<span className="gb-engine-label" id={id}>{label}</span>
|
|
440
|
+
<div className="gb-engine-opts" role="group" aria-labelledby={id}>
|
|
441
|
+
{items.map((m) => (
|
|
442
|
+
<button
|
|
443
|
+
key={m.id || "default"}
|
|
444
|
+
type="button"
|
|
445
|
+
className="gb-engine-opt"
|
|
446
|
+
aria-pressed={(value ?? "") === m.id}
|
|
447
|
+
disabled={off}
|
|
448
|
+
title={why ?? m.title}
|
|
449
|
+
onClick={() => { if ((value ?? "") !== m.id) onChange?.(m.id); onPicked?.(); }}
|
|
450
|
+
>
|
|
451
|
+
{m.label}
|
|
452
|
+
</button>
|
|
453
|
+
))}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function EngineTab({ engines, engine, onEngineChange, models, model, onModelChange, modelControl, efforts, effort, onEffortChange, effortDisabled, notes, locked, L, onPicked }: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
|
|
460
|
+
const id = useId();
|
|
461
|
+
// The ARIA radio pattern: one Tab stop (the checked engine, else the first that can be picked); arrows move AND pick.
|
|
462
|
+
const can = (e: EngineChoice) => !(locked || e.disabled || !onEngineChange);
|
|
463
|
+
const stop = engines.find((e) => e.id === engine && can(e))?.id ?? engines.find(can)?.id;
|
|
464
|
+
const onKey = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
|
465
|
+
const dir = ev.key === "ArrowDown" || ev.key === "ArrowRight" ? 1 : ev.key === "ArrowUp" || ev.key === "ArrowLeft" ? -1 : 0;
|
|
466
|
+
if (!dir) return;
|
|
467
|
+
const open = engines.filter(can);
|
|
468
|
+
if (!open.length) return;
|
|
469
|
+
ev.preventDefault();
|
|
470
|
+
const from = open.findIndex((e) => e.id === (ev.target as HTMLElement).dataset.engine);
|
|
471
|
+
const next = open[(from + dir + open.length) % open.length];
|
|
472
|
+
if (next.id !== engine) onEngineChange?.(next.id);
|
|
473
|
+
ev.currentTarget.querySelector<HTMLElement>(`[data-engine="${CSS.escape(next.id)}"]`)?.focus();
|
|
474
|
+
};
|
|
475
|
+
return (
|
|
476
|
+
<>
|
|
477
|
+
<div className="gb-engine-field">
|
|
478
|
+
<span className="gb-engine-label" id={id}>{L.engine}</span>
|
|
479
|
+
<div className="gb-engine-list" role="radiogroup" aria-labelledby={id} onKeyDown={onKey}>
|
|
480
|
+
{engines.map((e) => (
|
|
481
|
+
<button
|
|
482
|
+
key={e.id}
|
|
483
|
+
type="button"
|
|
484
|
+
role="radio"
|
|
485
|
+
aria-checked={e.id === engine}
|
|
486
|
+
data-engine={e.id}
|
|
487
|
+
tabIndex={e.id === stop ? 0 : -1}
|
|
488
|
+
className="gb-engine-row"
|
|
489
|
+
disabled={!can(e)}
|
|
490
|
+
// A new engine keeps the panel open: its models and efforts are the next thing to look at.
|
|
491
|
+
onClick={() => { if (e.id !== engine) onEngineChange?.(e.id); }}
|
|
492
|
+
>
|
|
493
|
+
<span className="gb-engine-rowhead">
|
|
494
|
+
<span className="gb-engine-rowname">{e.label}</span>
|
|
495
|
+
{e.badge ? <span className={e.badgeTone ? BADGE_CLASS[e.badgeTone] : "ui-badge"}>{e.badge}</span> : null}
|
|
496
|
+
</span>
|
|
497
|
+
{e.note ? <span className="gb-engine-note">{e.note}</span> : null}
|
|
498
|
+
</button>
|
|
499
|
+
))}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
{modelControl ? (
|
|
503
|
+
<div className="gb-engine-field"><span className="gb-engine-label">{L.model}</span>{modelControl}</div>
|
|
504
|
+
) : models && models.length ? <Options label={L.model} items={models} value={model} onChange={onModelChange} locked={locked} onPicked={onPicked} /> : null}
|
|
505
|
+
{efforts && efforts.length ? <Options label={L.effort} items={efforts} value={effort} onChange={onEffortChange} locked={locked} onPicked={onPicked} disabledReason={effortDisabled} /> : null}
|
|
506
|
+
{notes ? <div className="gb-engine-notes">{notes}</div> : null}
|
|
507
|
+
</>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Copies through the async clipboard, else through a selected textarea (an http:// page, an old Safari). */
|
|
512
|
+
async function copyText(text: string): Promise<boolean> {
|
|
513
|
+
try {
|
|
514
|
+
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
|
|
515
|
+
} catch { /* permission refused: try the old way */ }
|
|
516
|
+
try {
|
|
517
|
+
const ta = document.createElement("textarea");
|
|
518
|
+
ta.value = text;
|
|
519
|
+
ta.setAttribute("readonly", "");
|
|
520
|
+
ta.style.position = "fixed";
|
|
521
|
+
ta.style.opacity = "0";
|
|
522
|
+
document.body.appendChild(ta);
|
|
523
|
+
ta.select();
|
|
524
|
+
const ok = document.execCommand("copy");
|
|
525
|
+
ta.remove();
|
|
526
|
+
return ok;
|
|
527
|
+
} catch { return false; }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function SetupTab({ setup, bridge, L }: { setup: BridgeSetup; bridge?: BridgeStatus; L: EnginePickerLabels }) {
|
|
531
|
+
const [said, setSaid] = useState<"" | "copied" | "failed">("");
|
|
532
|
+
const timer = useRef<number | undefined>(undefined);
|
|
533
|
+
useEffect(() => () => window.clearTimeout(timer.current), []);
|
|
534
|
+
const copy = async () => {
|
|
535
|
+
if (!setup.command) return;
|
|
536
|
+
const ok = await copyText(setup.command);
|
|
537
|
+
setSaid(ok ? "copied" : "failed");
|
|
538
|
+
window.clearTimeout(timer.current);
|
|
539
|
+
timer.current = window.setTimeout(() => setSaid(""), 2000);
|
|
540
|
+
};
|
|
541
|
+
return (
|
|
542
|
+
<>
|
|
543
|
+
{bridge?.state === "outdated" || bridge?.state === "offline" || bridge?.state === "unpaired" ? (
|
|
544
|
+
<p className={cx("gb-engine-callout", bridge.state === "outdated" ? "gb-engine-callout--warn" : "gb-engine-callout--down")}>
|
|
545
|
+
<strong>{L.bridge}: {L[STATE_LABEL[bridge.state]].toLowerCase()}.</strong>
|
|
546
|
+
{bridge.detail ? <> {bridge.detail}</> : null}
|
|
547
|
+
</p>
|
|
548
|
+
) : null}
|
|
549
|
+
{setup.steps || setup.command ? <div className="gb-engine-steps">{setup.steps ?? <p>{L.steps}</p>}</div> : null}
|
|
550
|
+
{setup.command ? (
|
|
551
|
+
<>
|
|
552
|
+
<pre className="gb-engine-cmd"><code>{setup.command}</code></pre>
|
|
553
|
+
<div className="gb-engine-actions">
|
|
554
|
+
<button type="button" className="ui-btn ui-btn--primary" onClick={() => void copy()}>
|
|
555
|
+
{said === "copied" ? L.copied : L.copy}
|
|
556
|
+
</button>
|
|
557
|
+
<span className="gb-engine-said" role="status">{said === "failed" ? L.copyFailed : said === "copied" ? L.copied : ""}</span>
|
|
558
|
+
</div>
|
|
559
|
+
</>
|
|
560
|
+
) : null}
|
|
561
|
+
{setup.pairingCode ? (
|
|
562
|
+
<p className="gb-engine-code">{L.pairingCode}: <code>{setup.pairingCode}</code></p>
|
|
563
|
+
) : null}
|
|
564
|
+
{setup.note ? <p className="gb-engine-note">{setup.note}</p> : null}
|
|
565
|
+
</>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function StatusTab({ bridge, actions, L }: { bridge: BridgeStatus; actions?: ReactNode; L: EnginePickerLabels }) {
|
|
570
|
+
const tone = BRIDGE_TONE[bridge.state];
|
|
571
|
+
const yesNo = (v: boolean | undefined) => (v === undefined ? L.unknown : v ? L.yes : L.no);
|
|
572
|
+
return (
|
|
573
|
+
<>
|
|
574
|
+
<dl className="gb-engine-rows">
|
|
575
|
+
<dt>{L.bridge}</dt>
|
|
576
|
+
<dd><span className={DOT_CLASS[tone]} aria-hidden /> {L[STATE_LABEL[bridge.state]]}</dd>
|
|
577
|
+
{bridge.version ? <><dt>{L.version}</dt><dd>{bridge.version}{bridge.latest && bridge.latest !== bridge.version ? ` · ${L.latest} ${bridge.latest}` : ""}</dd></> : null}
|
|
578
|
+
<dt>{L.startsAtSignIn}</dt><dd>{yesNo(bridge.autostart)}</dd>
|
|
579
|
+
{bridge.machine ? <><dt>{L.machine}</dt><dd>{bridge.machine}</dd></> : null}
|
|
580
|
+
{bridge.lastSeen ? <><dt>{L.lastSeen}</dt><dd>{bridge.lastSeen}</dd></> : null}
|
|
581
|
+
</dl>
|
|
582
|
+
{bridge.detail && bridge.state !== "running" ? <p className="gb-engine-note">{bridge.detail}</p> : null}
|
|
583
|
+
{actions ? <div className="gb-engine-actions">{actions}</div> : null}
|
|
584
|
+
</>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* ONE line in the chat stream, for a turn that actually failed because of the bridge ("The bridge on your machine is
|
|
590
|
+
* not connected."), with "Open setup" that opens the picker on its Setup tab. Never a standing block: the chip's dot says
|
|
591
|
+
* "outdated" all the time, this says "this turn failed, here is why" once.
|
|
592
|
+
*/
|
|
593
|
+
export function BridgeNotice({ children, onOpenSetup, action = "Open setup", tone = "down", className }: {
|
|
594
|
+
children: ReactNode;
|
|
595
|
+
onOpenSetup?: () => void;
|
|
596
|
+
action?: string;
|
|
597
|
+
tone?: "warn" | "down";
|
|
598
|
+
className?: string;
|
|
599
|
+
}) {
|
|
600
|
+
return (
|
|
601
|
+
<p className={cx(NOTICE_CLASS[tone], className)} role="status">
|
|
602
|
+
<span className={DOT_CLASS[tone]} aria-hidden />
|
|
603
|
+
<span className="gb-engine-notice-text">{children}</span>
|
|
604
|
+
{onOpenSetup ? <button type="button" className="gb-engine-notice-link" onClick={onOpenSetup}>{action}</button> : null}
|
|
605
|
+
</p>
|
|
606
|
+
);
|
|
607
|
+
}
|
package/src/assistant/host.tsx
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* assistant is unmounted by the next menu click, and unmounting aborts the run. Geometry only changes
|
|
13
13
|
* class names; the subtree is never re-parented (that would remount the chat and abort the run too).
|
|
14
14
|
*/
|
|
15
|
+
import "../assistant.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
15
16
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
16
17
|
|
|
17
18
|
import { DOCK_MIN_HEIGHT, SIDE_MIN_WIDTH, clampDockHeight, clampSideWidth } from "./dock";
|
package/src/assistant/index.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { AssistantHost, type AssistantHostLabels, type AssistantSecondTab } from
|
|
|
4
4
|
export { AssistantProvider, SIDE_WIDTH_VAR, useAssistant, useAssistantPageFacts, type AssistantDockApi, type AssistantOptions } from "./provider";
|
|
5
5
|
export * from "./dock";
|
|
6
6
|
export { AssistantWorkspaceLayout, OpenChatTabs, SessionRail, SESSION_RAIL_EN, groupSessions, normalizeForSearch, relativeTime, type AssistantSession, type SessionGroup, type SessionRailIcons, type SessionRailLabels } from "./sessions";
|
|
7
|
+
export { BRIDGE_TONE, BridgeNotice, ENGINE_PICKER_EN, EnginePanel, EnginePicker, engineChipName, useEnginePicker, type BridgeSetup, type BridgeState, type BridgeStatus, type Choice, type EngineChoice, type EnginePickerLabels, type EnginePickerProps, type EnginePickerTab } from "./engine";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.gb-engine{display:inline-flex;min-width:0;max-width:100%}.gb-engine-chip{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%;box-sizing:border-box;height:var(--ui-btn-h, 28px);padding:0 24px 0 8px;font:inherit;font-size:var(--ui-fs-caption, 12px);font-weight:500;line-height:20px;white-space:nowrap;color:var(--ui-text-muted);background-color:transparent;border:1px solid transparent;border-radius:var(--ui-radius-md, 8px);cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23686b71' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center}@media (hover: hover){.gb-engine-chip:hover{color:var(--ui-text);background-color:var(--gemboss-hover, rgba(0,0,0,.05))}}@media (hover: none){.gb-engine-chip{color:var(--ui-text)}}.gb-engine-chip[aria-expanded=true]{color:var(--ui-text);background-color:var(--gemboss-hover, rgba(0,0,0,.05))}.gb-engine-chip:focus-visible{color:var(--ui-text);outline:2px solid var(--ui-focus);outline-offset:2px}.gb-engine-chiplabel{min-width:0;overflow:hidden;text-overflow:ellipsis}.gb-engine-chipsub{font-weight:450}.gb-engine-dot{flex:none;display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ui-text-muted);vertical-align:middle}.gb-engine-dot--ok{background:var(--gemboss-success, #007f5f)}.gb-engine-dot--warn{background:#e8a10a;box-shadow:0 0 0 1px var(--gemboss-warning, #9a6700) inset}.gb-engine-dot--down{background:var(--gemboss-danger, #d72c0d)}.gb-engine-dot--idle{background:transparent;box-shadow:0 0 0 1.5px var(--ui-text-muted) inset}.gb-engine-panel{position:fixed;z-index:1000;inset:auto;margin:0;width:min(360px,calc(100vw - 16px));box-sizing:border-box;overflow:auto;overscroll-behavior:contain;padding:0 0 var(--ui-space-3, 12px);color:var(--ui-text);font-size:var(--ui-fs-caption, 12px);line-height:16px}.gb-engine-panel[hidden],.gb-engine-panel[popover]:not(:popover-open):not([data-open]){display:none}:where(.gb-engine-panel[popover]){border:0;background:none;color:inherit}.gb-engine-panel--inline{position:static;z-index:auto;overflow:visible}.gb-engine-tabs{padding:0 var(--ui-space-2, 8px)}.gb-engine-tabs>button{gap:6px}.gb-engine-pane{display:grid;gap:var(--ui-space-3, 12px);padding:var(--ui-space-3, 12px) var(--ui-space-3, 12px) 0}.gb-engine-pane p{margin:0}.gb-engine-field{display:grid;gap:6px}.gb-engine-label{font-weight:550;color:var(--ui-text-strong, var(--ui-text))}.gb-engine-note{color:var(--ui-text-muted)}.gb-engine-list{display:grid;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md, 8px);overflow:hidden}.gb-engine-row{display:grid;gap:2px;min-height:44px;padding:6px 10px 6px 32px;box-sizing:border-box;position:relative;font:inherit;text-align:left;color:inherit;background:transparent;border:0;cursor:pointer}.gb-engine-row+.gb-engine-row{border-top:1px solid var(--ui-border)}.gb-engine-row:before{content:"";position:absolute;left:10px;top:50%;width:14px;height:14px;margin-top:-7px;box-sizing:border-box;border:1.5px solid var(--ui-text-muted);border-radius:50%}.gb-engine-row[aria-checked=true]{background:var(--gemboss-active-bg, #f0f1f4)}.gb-engine-row[aria-checked=true]:before{border:4.5px solid var(--ui-accent)}.gb-engine-row[aria-checked=true] .gb-engine-rowname{font-weight:600}@media (hover: hover){.gb-engine-row:hover:not(:disabled){background:var(--ui-surface-2)}}.gb-engine-row:focus-visible{outline:2px solid var(--ui-focus);outline-offset:-2px}.gb-engine-row:disabled{cursor:not-allowed}.gb-engine-row:disabled .gb-engine-rowname{color:var(--ui-text-muted)}.gb-engine-rowhead{display:flex;align-items:center;gap:var(--ui-space-2, 8px);min-width:0}.gb-engine-rowname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:450}.gb-engine-opts{display:flex;flex-wrap:wrap;gap:var(--ui-space-2, 8px)}.gb-engine-opt{height:var(--ui-btn-h, 28px);padding:0 10px;box-sizing:border-box;font:inherit;font-weight:500;color:var(--ui-text);background:transparent;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md, 8px);cursor:pointer}@media (hover: hover){.gb-engine-opt:hover:not(:disabled){background:var(--ui-surface-2)}}.gb-engine-opt[aria-pressed=true]{background:var(--gemboss-active-bg, #f0f1f4);border-color:var(--ui-text);font-weight:600}.gb-engine-opt:focus-visible{outline:2px solid var(--ui-focus);outline-offset:2px}.gb-engine-opt:disabled{opacity:.5;cursor:not-allowed}.gb-engine-select{width:100%}.gb-engine-notes{display:grid;gap:4px;color:var(--ui-text-muted)}.gb-engine-notes p{margin:0}.gb-engine-callout{padding:8px 10px;border-radius:var(--ui-radius-md, 8px);border:1px solid}.gb-engine-callout--warn{background:var(--gemboss-warning-soft, #fff8e0);border-color:var(--gemboss-warning-border, #f5d569);color:var(--gemboss-warning, #9a6700)}.gb-engine-callout--down{background:var(--gemboss-danger-soft, #fff0e9);border-color:var(--gemboss-danger-border, #f5c9bc);color:var(--gemboss-danger-strong, #b42318)}.gb-engine-steps ol,.gb-engine-steps ul{margin:0;padding-left:18px;display:grid;gap:4px}.gb-engine-cmd{margin:0;padding:8px 10px;border-radius:var(--ui-radius-md, 8px);background:var(--ui-surface-2);border:1px solid var(--ui-border);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:16px;white-space:pre-wrap;word-break:break-all;user-select:all;max-height:96px;overflow:auto}.gb-engine-cmd code{padding:0;border:0;background:none;font:inherit;color:inherit}.gb-engine-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--ui-space-2, 8px)}.gb-engine-said{color:var(--ui-text-muted)}.gb-engine-said:empty{display:none}.gb-engine-code code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600;letter-spacing:.04em;user-select:all}.gb-engine-rows{display:grid;grid-template-columns:auto 1fr;gap:6px 12px;margin:0}.gb-engine-rows dt{color:var(--ui-text-muted)}.gb-engine-rows dd{margin:0;display:flex;align-items:center;gap:6px;min-width:0;overflow-wrap:anywhere}.gb-engine-notice{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;margin:0;padding:4px 0;font-size:var(--ui-fs-caption, 12px);line-height:16px;color:var(--ui-text-muted)}.gb-engine-notice>.gb-engine-dot{margin-right:-2px}.gb-engine-notice--down .gb-engine-notice-text,.gb-engine-notice--warn .gb-engine-notice-text{color:var(--ui-text)}.gb-engine-notice-text{min-width:0;flex:0 1 auto}.gb-engine-notice-link{flex:none;position:relative;padding:0;border:0;background:none;font:inherit;font-weight:550;color:var(--ui-accent);text-decoration:underline;text-underline-offset:2px;cursor:pointer}.gb-engine-notice-link:after{content:"";position:absolute;inset:-4px}.gb-engine-notice-link:focus-visible{outline:2px solid var(--ui-focus);outline-offset:2px;border-radius:2px}@media (pointer: coarse),(max-width: 47.9975em){.gb-engine-chip{min-height:44px}.gb-engine-opt{min-height:44px;min-width:44px}.gb-engine-notice-link:after{inset:-14px -8px}}@media (prefers-reduced-motion: no-preference){.gb-engine-chip,.gb-engine-opt,.gb-engine-row{transition:background-color .12s ease,color .12s ease}}
|
package/src/auth.tsx
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* the dark theme. Re-theme it by redefining the `--gb-*` tokens on `.gb-auth` (see auth.css), never
|
|
14
14
|
* by restyling its classes.
|
|
15
15
|
*/
|
|
16
|
+
import "./auth.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
16
17
|
import React, { useEffect, useRef, useState } from "react";
|
|
17
18
|
|
|
18
19
|
import { GemMark } from "./GemMark";
|
package/src/collections.tsx
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Polaris; same reasoning as ./page). The markup is the same as the plain-HTML contract in the catalog, so a surface
|
|
16
16
|
* without React writes these classes by hand and gets the same thing.
|
|
17
17
|
*/
|
|
18
|
+
import "./collections.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
18
19
|
import React from "react";
|
|
19
20
|
import { attachBoardMove } from "./board-move";
|
|
20
21
|
|