@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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Each engine's MARK, the artwork only (0.29.0), moved from the admin (apps/admin/src/lib/engine-mark-art.ts, which now
3
+ * re-exports this file). No JSX and no stylesheet, so a node test and a server module can import it
4
+ * (`@gemboss/ui/assistant/engine-mark-art`); `EngineMark` (engine-mark.tsx) draws it.
5
+ *
6
+ * One shape per engine, never a shared one: a mark that is right for one engine and wrong for the other reads as evidence
7
+ * while naming the wrong thing.
8
+ */
9
+
10
+ /** The Claude asterisk: eight tapered arms from one centre, generated (eight hand-typed arms are eight chances to be off). */
11
+ function claudeAsterisk(): string {
12
+ const c = 12, root = 1.7, tip = 0.5, len = 9.6;
13
+ const f = (n: number) => Math.round(n * 100) / 100;
14
+ const p = (x: number, y: number) => `${f(x)} ${f(y)}`;
15
+ return Array.from({ length: 8 }, (_, i) => {
16
+ const a = (i * Math.PI) / 4;
17
+ const [dx, dy] = [Math.cos(a), Math.sin(a)];
18
+ const [px, py] = [-dy, dx];
19
+ return [
20
+ `M${p(c + px * root, c + py * root)}`,
21
+ `L${p(c + dx * len + px * tip, c + dy * len + py * tip)}`,
22
+ `Q${p(c + dx * (len + 0.6), c + dy * (len + 0.6))} ${p(c + dx * len - px * tip, c + dy * len - py * tip)}`,
23
+ `L${p(c - px * root, c - py * root)}`,
24
+ "Z",
25
+ ].join("");
26
+ }).join(" ");
27
+ }
28
+
29
+ /** Codex: a hexagonal ring. Not OpenAI's knot (no official asset here; one drawn from memory would be a wrong logo). */
30
+ const CODEX_HEX = "M12 2.6 20.1 7.3V16.7L12 21.4 3.9 16.7V7.3ZM12 6.9 7.6 9.45v5.1L12 17.1l4.4-2.55v-5.1Z";
31
+
32
+ /** NONZERO: the asterisk's eight subpaths all start at the centre, and under evenodd the overlaps cancel (measured in the
33
+ * admin: the centre came back 0/400 inked, a hollow compass rose). */
34
+ export const ENGINE_MARK_FILL_RULE = "nonzero" as const;
35
+
36
+ export type EngineMarkId = "claude" | "codex";
37
+ export interface EngineMarkArt { path: string; label: string }
38
+ export const ENGINE_MARK_ART: Record<EngineMarkId, EngineMarkArt> = {
39
+ claude: { path: claudeAsterisk(), label: "Claude Code" },
40
+ codex: { path: CODEX_HEX, label: "Codex" },
41
+ };
42
+
43
+ export function isEngineMarkId(v: unknown): v is EngineMarkId {
44
+ return typeof v === "string" && Object.prototype.hasOwnProperty.call(ENGINE_MARK_ART, v);
45
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Each engine's MARK (0.29.0), moved from the admin (apps/admin/src/lib/engine-mark-art.ts + components/chat/EngineMark.tsx).
3
+ *
4
+ * Where it shows: on the engine chip when the composer row is too narrow for the name (a name cut to "Clau…" says
5
+ * nothing; a mark still says whose model answers), and in a narration row that stands for the turn itself.
6
+ *
7
+ * One shape per engine, never a shared one: a mark that is right for one engine and wrong for the other reads as evidence
8
+ * while naming the wrong thing. An engine with no artwork draws NOTHING (silence rather than somebody else's logo), and
9
+ * the chip then keeps its name.
10
+ */
11
+ import "../assistant-engine.css"; // the mark's size (`.gemboss-engine-mark`), with the chip that shows it
12
+ import React from "react";
13
+
14
+ import { ENGINE_MARK_ART, ENGINE_MARK_FILL_RULE, isEngineMarkId } from "./engine-mark-art";
15
+ export { ENGINE_MARK_ART, ENGINE_MARK_FILL_RULE, isEngineMarkId, type EngineMarkArt, type EngineMarkId } from "./engine-mark-art";
16
+
17
+
18
+ /** The mark as an image with a NAME (the hover text and the accessible name), or nothing when the engine has no artwork. */
19
+ export function EngineMark({ engine, title, className = "gemboss-engine-mark", decorative = false }: {
20
+ engine: string;
21
+ title?: string;
22
+ className?: string;
23
+ /** Inside a control that already names the engine (the chip): hidden from assistive tech, no hover title. */
24
+ decorative?: boolean;
25
+ }) {
26
+ if (!isEngineMarkId(engine)) return null;
27
+ const art = ENGINE_MARK_ART[engine];
28
+ const name = title || art.label;
29
+ return decorative ? (
30
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
31
+ <path d={art.path} fillRule={ENGINE_MARK_FILL_RULE} />
32
+ </svg>
33
+ ) : (
34
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor" role="img" aria-label={name}>
35
+ <title>{name}</title>
36
+ <path d={art.path} fillRule={ENGINE_MARK_FILL_RULE} />
37
+ </svg>
38
+ );
39
+ }
@@ -28,6 +28,7 @@ import "../assistant-engine.css"; // its own entry (0.25.0): not in gemboss-ui.c
28
28
  import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
29
29
  import { createPortal } from "react-dom";
30
30
  import { canFloatMenus, placeFloating } from "../menu-layer";
31
+ import { EngineMark, isEngineMarkId } from "./engine-mark";
31
32
 
32
33
  const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
33
34
  const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -47,6 +48,12 @@ export interface EngineChoice {
47
48
  badgeTone?: "success" | "warning" | "critical" | "info";
48
49
  /** Cannot be picked right now. Say why in `note`. */
49
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[];
50
57
  }
51
58
 
52
59
  export interface Choice {
@@ -139,6 +146,13 @@ export interface EnginePickerProps {
139
146
  effortDisabled?: boolean | string;
140
147
  /** Quiet lines under the Engine tab's fields: who pays, what this bridge version cannot do yet. */
141
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;
142
156
  /** The bridge on the user's machine. Omit for an engine set that runs on the server only: no dot, no Status tab. */
143
157
  bridge?: BridgeStatus;
144
158
  /** The dot on the chip (default true when `bridge` is given). False when the current engine does not go through the
@@ -157,19 +171,30 @@ export interface EnginePickerProps {
157
171
  locked?: boolean;
158
172
  /** Align the panel's right edge with the chip's (a chip near the right edge of a narrow frame). */
159
173
  end?: boolean;
174
+ /** The chip button, for the surface (`useEnginePicker` passes its own: `close()` puts focus back on it). 0.32.0. */
175
+ chipRef?: React.Ref<HTMLButtonElement>;
160
176
  labels?: Partial<EnginePickerLabels>;
161
177
  className?: string;
162
178
  }
163
179
 
164
180
  /** 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")} …/>` */
181
+ * `const p = useEnginePicker(); <EnginePicker {...p.props} …/> <BridgeNotice onOpenSetup={() => p.openTab("setup")} …/>`
182
+ *
183
+ * `close()` (0.32.0) is the surface's close: a button inside the panel that acts and then shuts it (Stop bridge, Remove
184
+ * from this machine, before a confirm opens). The button that had focus goes away with the panel, so focus would fall to
185
+ * <body> and the confirm would record <body> as where to return; `close()` puts it on the chip first. Every surface
186
+ * wrote that by hand (`querySelector(".gb-engine-chip").focus()`: partner-radar, search-radar, gemwatcher #922). An
187
+ * outside click still closes without moving focus (`props.onOpenChange(false)`). */
166
188
  export function useEnginePicker(initial: EnginePickerTab = "engine") {
167
189
  const [open, setOpen] = useState(false);
168
190
  const [tab, setTab] = useState<EnginePickerTab>(initial);
191
+ const chipRef = useRef<HTMLButtonElement | null>(null);
169
192
  const openTab = useCallback((t: EnginePickerTab) => { setTab(t); setOpen(true); }, []);
170
193
  // Closing goes back to Engine: after "Open setup" the next click on the chip is about the engine again (review #3885).
171
194
  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 } };
195
+ const focusChip = useCallback(() => { chipRef.current?.focus(); }, []);
196
+ const close = useCallback(() => { onOpenChange(false); focusChip(); }, [onOpenChange, focusChip]);
197
+ return { open, tab, setOpen, setTab, openTab, close, focusChip, chipRef, props: { open, onOpenChange, tab, onTabChange: setTab, chipRef } };
173
198
  }
174
199
 
175
200
  function availableTabs(p: Pick<EnginePickerProps, "bridge" | "setup">): EnginePickerTab[] {
@@ -192,8 +217,9 @@ function useTabs(p: Pick<EnginePickerProps, "bridge" | "setup" | "tab" | "onTabC
192
217
 
193
218
  /** What the chip says out loud: the engine, the model, and the bridge when it is not fine. */
194
219
  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;
220
+ const cur = p.engines.find((e) => e.id === p.engine);
221
+ const eng = cur?.label ?? p.engine;
222
+ const model = p.model ? (cur?.models ?? p.models)?.find((m) => m.id === p.model)?.label : undefined;
197
223
  const bridge = p.bridge && p.bridge.state !== "running" ? `. ${L.bridge}: ${L[STATE_LABEL[p.bridge.state]]}` : "";
198
224
  return `${L.chip}: ${eng}${model ? ` · ${model}` : ""}${bridge}`;
199
225
  }
@@ -209,7 +235,13 @@ export function EnginePicker(props: EnginePickerProps) {
209
235
  // Decided after mount, so the server and the first client render agree.
210
236
  const [portal, setPortal] = useState(false);
211
237
  useEffect(() => { setPortal(!canFloatMenus()); }, []);
212
- const chipRef = useRef<HTMLButtonElement>(null);
238
+ const chipRef = useRef<HTMLButtonElement | null>(null);
239
+ const outerChipRef = props.chipRef;
240
+ const setChip = useCallback((el: HTMLButtonElement | null) => {
241
+ chipRef.current = el;
242
+ if (typeof outerChipRef === "function") outerChipRef(el);
243
+ else if (outerChipRef) (outerChipRef as { current: HTMLButtonElement | null }).current = el;
244
+ }, [outerChipRef]);
213
245
  const panelRef = useRef<HTMLDivElement>(null);
214
246
  const panelId = useId();
215
247
 
@@ -242,17 +274,31 @@ export function EnginePicker(props: EnginePickerProps) {
242
274
  if (!panel || !chip) return undefined;
243
275
  // Escape with focus in the panel or on the chip closes the panel only: captured first and stopped, so the surface's
244
276
  // 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.
277
+ // panel and leaves focus alone, and marks the key handled (0.27.0): a surface whose "Escape hides the assistant"
278
+ // checks `defaultPrevented` waits for the next Escape. Before 0.27.0 the key went on unmarked, and React re-renders
279
+ // between this listener and the surface's, so every surface (gemlabs #922, gemfeedback #66) needed a window-capture
280
+ // marker to tell "this Escape closed the picker" from "this one is mine".
246
281
  const onKey = (e: KeyboardEvent) => {
247
282
  if (e.key !== "Escape" || e.defaultPrevented) return;
283
+ // Not ours: an IME composition (Escape cancels it; the kit's rule, kit-plain.js), or a modal opened from the panel.
284
+ if (e.isComposing || e.keyCode === 229) return;
248
285
  const a = document.activeElement;
249
- if (a && (panel.contains(a) || chip.contains(a))) { e.preventDefault(); e.stopPropagation(); closeToChip(); }
286
+ const modal = a?.closest?.('dialog[open], [aria-modal="true"]');
287
+ if (modal && !panel.contains(modal)) return;
288
+ e.preventDefault();
289
+ if (a && (panel.contains(a) || chip.contains(a))) { e.stopPropagation(); closeToChip(); }
250
290
  else setOpen(false);
251
291
  };
252
292
  const onDown = (e: PointerEvent) => {
253
293
  const t = e.target as Node;
254
294
  if (!panel.contains(t) && !chip.contains(t)) setOpen(false);
255
295
  };
296
+ // A modal opened from the panel (a confirm behind a Status action) takes the screen: the panel would stay above it in
297
+ // the top layer, over its buttons (review #3903). Close the panel when focus enters a modal that is not the panel.
298
+ const onFocusIn = (e: FocusEvent) => {
299
+ const m = (e.target as Element | null)?.closest?.('dialog[open], [aria-modal="true"]');
300
+ if (m && !panel.contains(m)) setOpen(false);
301
+ };
256
302
  const replace = () => placeFloating(panel, chip, end);
257
303
  // A scroll moves the panel's anchor only when it scrolls something that holds the chip (the page, a frame around the
258
304
  // composer): a chat log streaming beside it does not. One placement per frame at most.
@@ -278,12 +324,14 @@ export function EnginePicker(props: EnginePickerProps) {
278
324
  if (pane) ro?.observe(pane);
279
325
  document.addEventListener("keydown", onKey, true);
280
326
  document.addEventListener("pointerdown", onDown, true);
327
+ document.addEventListener("focusin", onFocusIn, true);
281
328
  document.addEventListener("scroll", onScroll, true);
282
329
  window.addEventListener("resize", replace);
283
330
  return () => {
284
331
  document.removeEventListener("keydown", onKey, true);
285
332
  cancelAnimationFrame(frame);
286
333
  document.removeEventListener("pointerdown", onDown, true);
334
+ document.removeEventListener("focusin", onFocusIn, true);
287
335
  document.removeEventListener("scroll", onScroll, true);
288
336
  window.removeEventListener("resize", replace);
289
337
  ro?.disconnect();
@@ -301,16 +349,18 @@ export function EnginePicker(props: EnginePickerProps) {
301
349
  }, [open]);
302
350
 
303
351
  const current = engines.find((e) => e.id === engine);
304
- const modelLabel = model ? models?.find((m) => m.id === model)?.label : undefined;
352
+ const modelLabel = model ? (current?.models ?? models)?.find((m) => m.id === model)?.label : undefined;
353
+ const mark = current?.mark && isEngineMarkId(current.mark) ? current.mark : null;
305
354
  const tone = bridge && props.showBridgeDot !== false ? BRIDGE_TONE[bridge.state] : null;
306
355
  const name = engineChipName(props.showBridgeDot === false ? { ...props, bridge: undefined } : props, L);
307
356
 
308
357
  return (
309
358
  <span className={cx("gb-engine", className)}>
310
359
  <button
311
- ref={chipRef}
360
+ ref={setChip}
312
361
  type="button"
313
- className="gb-engine-chip"
362
+ className={mark ? "gb-engine-chip has-mark" : "gb-engine-chip"}
363
+ disabled={props.disabled || undefined}
314
364
  aria-haspopup="dialog"
315
365
  aria-expanded={open}
316
366
  aria-controls={panelId}
@@ -323,6 +373,8 @@ export function EnginePicker(props: EnginePickerProps) {
323
373
  {current?.label ?? engine}
324
374
  {modelLabel ? <span className="gb-engine-chipsub"> · {modelLabel}</span> : null}
325
375
  </span>
376
+ {/* Shown instead of the name when the composer is too narrow for it (assistant-engine.css, ChatComposer density). */}
377
+ {mark ? <span className="gb-engine-chipmark"><EngineMark engine={mark} decorative /></span> : null}
326
378
  </button>
327
379
  {(() => { const panelEl = (<div
328
380
  ref={panelRef}
@@ -456,8 +508,62 @@ function Options({ label, items, value, onChange, locked, onPicked, disabledReas
456
508
  );
457
509
  }
458
510
 
459
- function EngineTab({ engines, engine, onEngineChange, models, model, onModelChange, modelControl, efforts, effort, onEffortChange, effortDisabled, notes, locked, L, onPicked }: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
511
+ /** Every engine with its own models, all at once; one click picks both (the admin's popup, 0.29.0). */
512
+ function EngineGroups({ engines, engine, model, onPick, onEngineChange, onModelChange, locked, L, onPicked }: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
460
513
  const id = useId();
514
+ const choose = (e: EngineChoice, m: Choice) => {
515
+ // The pick already in force: close, change nothing (a surface that starts a new conversation on a switch must not).
516
+ if (e.id === engine && (model ?? "") === m.id) { onPicked?.(); return; }
517
+ if (onPick) onPick(e.id, m.id);
518
+ else { if (e.id !== engine) onEngineChange?.(e.id); onModelChange?.(m.id); }
519
+ onPicked?.();
520
+ };
521
+ const canChoose = Boolean(onPick || onEngineChange || onModelChange);
522
+ return (
523
+ <div className="gb-engine-field">
524
+ <span className="gb-engine-label" id={id}>{L.engine}</span>
525
+ <div className="gb-engine-groups" role="group" aria-labelledby={id}>
526
+ {engines.map((e) => {
527
+ const chosen = e.id === engine;
528
+ const items = e.models && e.models.length ? e.models : [{ id: "", label: L.defaultChoice }];
529
+ return (
530
+ <div key={e.id} className="gb-engine-group" data-current={chosen ? "" : undefined}>
531
+ <span className="gb-engine-rowhead">
532
+ <span className="gb-engine-rowname">{e.label}</span>
533
+ {e.badge ? <span className={e.badgeTone ? BADGE_CLASS[e.badgeTone] : "ui-badge"}>{e.badge}</span> : null}
534
+ </span>
535
+ {e.note ? <span className="gb-engine-note">{e.note}</span> : null}
536
+ <div className="gb-engine-opts" role="group" aria-label={e.label}>
537
+ {items.map((m) => (
538
+ <button key={m.id || "default"} type="button" className="gb-engine-opt" title={m.title}
539
+ aria-pressed={chosen && (model ?? "") === m.id}
540
+ disabled={locked || e.disabled || !canChoose}
541
+ onClick={() => choose(e, m)}>
542
+ {m.label}
543
+ </button>
544
+ ))}
545
+ </div>
546
+ </div>
547
+ );
548
+ })}
549
+ </div>
550
+ </div>
551
+ );
552
+ }
553
+
554
+ function EngineTab(props: EnginePickerProps & { L: EnginePickerLabels; onPicked?: () => void }) {
555
+ const { engines, engine, onEngineChange, models, model, onModelChange, modelControl, efforts, effort, onEffortChange, effortDisabled, notes, locked, L, onPicked } = props;
556
+ const id = useId();
557
+ if (engines.some((e) => e.models && e.models.length)) {
558
+ return (
559
+ <>
560
+ <EngineGroups {...props} />
561
+ {efforts && efforts.length ? <Options label={L.effort} items={efforts} value={effort} onChange={onEffortChange} locked={locked} onPicked={onPicked} disabledReason={effortDisabled} /> : null}
562
+ {props.problem ? <p className="gb-engine-problem" role="alert">{props.problem}</p> : null}
563
+ {notes ? <div className="gb-engine-notes">{notes}</div> : null}
564
+ </>
565
+ );
566
+ }
461
567
  // The ARIA radio pattern: one Tab stop (the checked engine, else the first that can be picked); arrows move AND pick.
462
568
  const can = (e: EngineChoice) => !(locked || e.disabled || !onEngineChange);
463
569
  const stop = engines.find((e) => e.id === engine && can(e))?.id ?? engines.find(can)?.id;
@@ -503,13 +609,14 @@ function EngineTab({ engines, engine, onEngineChange, models, model, onModelChan
503
609
  <div className="gb-engine-field"><span className="gb-engine-label">{L.model}</span>{modelControl}</div>
504
610
  ) : models && models.length ? <Options label={L.model} items={models} value={model} onChange={onModelChange} locked={locked} onPicked={onPicked} /> : null}
505
611
  {efforts && efforts.length ? <Options label={L.effort} items={efforts} value={effort} onChange={onEffortChange} locked={locked} onPicked={onPicked} disabledReason={effortDisabled} /> : null}
612
+ {props.problem ? <p className="gb-engine-problem" role="alert">{props.problem}</p> : null}
506
613
  {notes ? <div className="gb-engine-notes">{notes}</div> : null}
507
614
  </>
508
615
  );
509
616
  }
510
617
 
511
618
  /** Copies through the async clipboard, else through a selected textarea (an http:// page, an old Safari). */
512
- async function copyText(text: string): Promise<boolean> {
619
+ export async function copyText(text: string): Promise<boolean> {
513
620
  try {
514
621
  if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
515
622
  } catch { /* permission refused: try the old way */ }
@@ -39,6 +39,7 @@ export interface AssistantHostLabels {
39
39
  close: string; closeBusy: string; closeBusyTitle: string;
40
40
  shrinkToPopup: string; tablist: string; primaryTab: string;
41
41
  pinSide: string; floatPopup: string; sideGrip: string; backToSide: string;
42
+ more: string; clearHistory: string;
42
43
  }
43
44
  const EN: AssistantHostLabels = {
44
45
  ready: "Ready", working: "Working", done: "Done — tap to see it",
@@ -52,8 +53,88 @@ const EN: AssistantHostLabels = {
52
53
  shrinkToPopup: "Shrink to popup", tablist: "Assistant or a person", primaryTab: "Assistant",
53
54
  pinSide: "Pin to the side", floatPopup: "Float as a popup", sideGrip: "Drag to resize the assistant; left and right arrows work too",
54
55
  backToSide: "Back to the side panel",
56
+ more: "More", clearHistory: "Clear history",
55
57
  };
56
58
 
59
+ /**
60
+ * One row of the header's ⋯ menu (0.31.0). The fleet's menu row (`ui-menu__item`, shell.css, which every surface with
61
+ * the shell loads, the admin too). A surface's own rows go in `menuItems`.
62
+ * It closes the menu and puts focus on ⋯ BEFORE its handler (review #3941): closing after it hid the focused row, so focus
63
+ * fell to <body>, and a confirm the handler opened (content-radar's Clear history) saved that hidden row as the place to
64
+ * return to. No `role="menuitem"`: like the kit's Menu, a disclosure of buttons; the menu role promises arrow keys.
65
+ */
66
+ export function AssistantMenuItem({ danger, disabled, onClick, className, children }: {
67
+ danger?: boolean;
68
+ disabled?: boolean;
69
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
70
+ className?: string;
71
+ children?: ReactNode;
72
+ }) {
73
+ return (
74
+ <button
75
+ type="button"
76
+ className={["ui-menu__item", danger ? "ui-menu__item--danger" : "", className ?? ""].filter(Boolean).join(" ")}
77
+ disabled={disabled}
78
+ onClick={(e) => {
79
+ const menu = e.currentTarget.closest("details");
80
+ menu?.removeAttribute("open");
81
+ menu?.querySelector("summary")?.focus();
82
+ onClick?.(e);
83
+ }}
84
+ >
85
+ {children}
86
+ </button>
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The header's ⋯ (0.31.0, Chris 08/10/2026: "clear history lên topbar … nhiều icon rồi nên cần gom nhóm"). A `<details>`
92
+ * like the kit's Menu, but its position and trigger are the host's (assistant.css): the kit's `.ui-menu` rules live in
93
+ * kit-plain.css, which the admin does not load, while the row (`ui-menu__item`) and the surface
94
+ * (`gemboss-popover-surface`) are shell.css, which it does. Escape and an outside press close it, the way the engine
95
+ * picker's popup closes (engine.tsx): Escape with focus inside goes back to ⋯ and is stopped, so the surface's own
96
+ * "Escape hides the assistant" does not fire on the same key; an Escape meant for a modal elsewhere is left to it. It also
97
+ * closes when the window loses focus (a click into an iframe), like the kit's plain menus (kit-plain.js initMenus).
98
+ */
99
+ function HostMenu({ label, trigger = "gb-dock-icon", children }: { label: string; trigger?: string; children: ReactNode }) {
100
+ const ref = useRef<HTMLDetailsElement>(null);
101
+ // Listening all the time and asking `el.open`, like the kit's Menu: gating the listeners on an `open` state left the
102
+ // render between the toggle and the effect unguarded, and a press outside in that window kept the menu open (measured
103
+ // in the browser test under load).
104
+ useEffect(() => {
105
+ const el = ref.current;
106
+ if (!el) return undefined;
107
+ const onKey = (e: KeyboardEvent) => {
108
+ if (!el.open || e.key !== "Escape" || e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
109
+ const modal = (document.activeElement as Element | null)?.closest?.('dialog[open], [aria-modal="true"]');
110
+ if (modal && !el.contains(modal)) return;
111
+ e.preventDefault();
112
+ if (el.contains(document.activeElement)) { e.stopPropagation(); el.open = false; el.querySelector("summary")?.focus(); }
113
+ else el.open = false;
114
+ };
115
+ const onDown = (e: PointerEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
116
+ const onBlur = () => { if (el.open) el.open = false; };
117
+ window.addEventListener("keydown", onKey, true);
118
+ document.addEventListener("pointerdown", onDown, true);
119
+ window.addEventListener("blur", onBlur);
120
+ return () => {
121
+ window.removeEventListener("keydown", onKey, true);
122
+ document.removeEventListener("pointerdown", onDown, true);
123
+ window.removeEventListener("blur", onBlur);
124
+ };
125
+ }, []);
126
+ return (
127
+ <details ref={ref} className="gb-dock-menu">
128
+ <summary className={trigger} title={label} aria-label={label}>
129
+ <svg viewBox="0 0 20 20" aria-hidden><circle cx="5" cy="10" r="1.5" fill="currentColor"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/><circle cx="15" cy="10" r="1.5" fill="currentColor"/></svg>
130
+ </summary>
131
+ {/* title="": the popup's header carries "Drag this bar…" as its title, and a browser shows an ancestor's title on
132
+ any child without one of its own, so every row said it (review #3941). */}
133
+ <div className="gemboss-popover-surface gb-dock-menu-list" title="">{children}</div>
134
+ </details>
135
+ );
136
+ }
137
+
57
138
  /** An optional second tab in the popup (the admin: a person on the Gem team, through gemcare). */
58
139
  export interface AssistantSecondTab {
59
140
  label: ReactNode;
@@ -88,14 +169,23 @@ function useSideFits(): boolean {
88
169
  }
89
170
 
90
171
  export function AssistantHost({
91
- title, labels, onNewChat, onExpand, onShrink, fullActions, secondTab, view = "primary", onViewChange,
92
- miniHint, allowSide = true, children,
172
+ title, labels, onNewChat, onClearHistory, canClearHistory = true, menuItems, onExpand, onShrink, fullActions, secondTab,
173
+ view = "primary", onViewChange, miniHint, allowSide = true, children,
93
174
  }: {
94
175
  /** "Gem assistant" in the admin. Names the region, the popup header and the status bar. */
95
176
  title: string;
96
177
  labels?: Partial<AssistantHostLabels>;
97
178
  /** The popup header's +. Omit it and the button is not drawn. */
98
179
  onNewChat?: () => void;
180
+ /**
181
+ * "Clear history" in the ⋯ menu (0.31.0), in the panel's header and in full screen's chrome. The surface confirms (it
182
+ * knows what is cleared: this browser's chats, an engine's session). Omit it and the row is not drawn.
183
+ */
184
+ onClearHistory?: () => void;
185
+ /** False greys "Clear history" out (nothing to clear). */
186
+ canClearHistory?: boolean;
187
+ /** The surface's own rows at the top of the ⋯ menu, as `AssistantMenuItem`s. */
188
+ menuItems?: ReactNode;
99
189
  /** Popup → full screen. The surface decides what that means (the admin navigates to its page). */
100
190
  onExpand: () => void;
101
191
  /** Full screen → popup, e.g. open the dock and go back to the screen they came from. */
@@ -196,8 +286,8 @@ export function AssistantHost({
196
286
  return { min: 8, max: Math.max(8, Math.floor(track - width - 8)) };
197
287
  }, []);
198
288
  const onHeaderDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
199
- // Buttons in the header keep their clicks; only the bare strip drags.
200
- if ((e.target as HTMLElement).closest("button")) return;
289
+ // Buttons in the header keep their clicks — the ⋯ menu's <summary> and its rows too; only the bare strip drags.
290
+ if ((e.target as HTMLElement).closest("button, summary, .gb-dock-menu")) return;
201
291
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
202
292
  setDragging(true);
203
293
  slideRef.current = { startX: e.clientX, startOffset: dock.dockOffsetRight, moved: false };
@@ -291,6 +381,11 @@ export function AssistantHost({
291
381
  // real ceiling for this window, which changes when the window does.
292
382
  const maxDockHeight = clampDockHeight(Number.MAX_SAFE_INTEGER, typeof window === "undefined" ? 900 : window.innerHeight);
293
383
  const statusText = dock.busy ? L.working : dock.unread ? L.done : L.ready;
384
+ // The surface's rows of the ⋯ menu, in the panel's header and in full screen's chrome alike.
385
+ const clearRow = onClearHistory ? (
386
+ <AssistantMenuItem danger disabled={!canClearHistory} onClick={onClearHistory}>{L.clearHistory}</AssistantMenuItem>
387
+ ) : null;
388
+ const surfaceRows = menuItems || clearRow ? <>{menuItems}{clearRow}</> : null;
294
389
  // Full screen is the assistant's own page and has no tab strip; the second tab lives in the popup.
295
390
  const secondVisible = isPanel && !!secondTab && view === "secondary";
296
391
  const secondBadge = secondTab?.badge || 0;
@@ -305,8 +400,10 @@ export function AssistantHost({
305
400
  aria-label={mode === "hidden" ? undefined : title}
306
401
  >
307
402
  {mode === "full" ? (
308
- <div className="gb-dock-fullchrome">
403
+ // data-gb-dock-chrome: what AssistantChat measures to keep its first line clear of these buttons (0.29.0).
404
+ <div className="gb-dock-fullchrome" data-gb-dock-chrome="">
309
405
  {fullActions}
406
+ {surfaceRows ? <HostMenu label={L.more} trigger="gb-dock-shrink gb-dock-menu-pill">{surfaceRows}</HostMenu> : null}
310
407
  <button type="button" className="gb-dock-shrink" onClick={onShrink}>
311
408
  {/* Two arrows pulled INTO the centre. The first attempt drew two corner brackets, which at 16px
312
409
  reads as a plus sign — the opposite of "make this smaller". */}
@@ -405,29 +502,6 @@ export function AssistantHost({
405
502
  <span className="gb-dock-sr">{L.newChat}</span>
406
503
  </button>
407
504
  ) : null}
408
- {/* On `dock.sideAllowed`, not the prop: it already folds in the window width (SIDE_FITS_QUERY), so the pin
409
- never shows where pressing it would only drop the working shape to "dock" (review #3744). */}
410
- {isDock && dock.sideAllowed ? (
411
- <button type="button" className="gb-dock-icon gb-dock-action gb-dock-pinside" title={L.pinSide} onClick={dock.openSide}>
412
- {/* A window with its right third filled: the panel it will become. */}
413
- <svg viewBox="0 0 20 20" aria-hidden>
414
- <rect x="3.5" y="4.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none"/>
415
- <path d="M12 4.5v11" stroke="currentColor" strokeWidth="1.5"/>
416
- <path d="M12.75 5.25h3v9.5h-3z" fill="currentColor"/>
417
- </svg>
418
- <span className="gb-dock-sr">{L.pinSide}</span>
419
- </button>
420
- ) : null}
421
- {isSide ? (
422
- <button type="button" className="gb-dock-icon gb-dock-action" title={L.floatPopup} onClick={dock.openDock}>
423
- {/* The same window with a small box floating in its bottom-right corner: the popup. */}
424
- <svg viewBox="0 0 20 20" aria-hidden>
425
- <rect x="3.5" y="4.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none"/>
426
- <rect x="10" y="10" width="5" height="4" rx="0.75" fill="currentColor"/>
427
- </svg>
428
- <span className="gb-dock-sr">{L.floatPopup}</span>
429
- </button>
430
- ) : null}
431
505
  <button
432
506
  type="button"
433
507
  className="gb-dock-icon"
@@ -441,10 +515,19 @@ export function AssistantHost({
441
515
  </svg>
442
516
  <span className="gb-dock-sr">{L.openFull}</span>
443
517
  </button>
444
- <button type="button" className="gb-dock-icon" title={L.minimizeTitle} onClick={dock.minimize}>
445
- <svg viewBox="0 0 20 20" aria-hidden><path d="M5 13h10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>
446
- <span className="gb-dock-sr">{L.minimize}</span>
447
- </button>
518
+ {/* ⋯ (0.31.0): what is used once in a while — the surface's rows, Clear history, and the two window controls
519
+ that were icons (pin / float, minimise). Four controls stay in sight: +, full screen, ⋯, ×. On
520
+ `dock.sideAllowed`, not the prop, for the pin: it already folds in the window width (SIDE_FITS_QUERY),
521
+ so it is never offered where pressing it would only drop the working shape to "dock" (review #3744). */}
522
+ <HostMenu label={L.more}>
523
+ {surfaceRows}
524
+ {surfaceRows ? <hr className="gb-dock-menu-sep" /> : null}
525
+ {isDock && dock.sideAllowed ? (
526
+ <AssistantMenuItem className="gb-dock-pinside" onClick={dock.openSide}>{L.pinSide}</AssistantMenuItem>
527
+ ) : null}
528
+ {isSide ? <AssistantMenuItem onClick={dock.openDock}>{L.floatPopup}</AssistantMenuItem> : null}
529
+ <AssistantMenuItem onClick={dock.minimize}>{L.minimizeTitle}</AssistantMenuItem>
530
+ </HostMenu>
448
531
  <button
449
532
  type="button"
450
533
  className="gb-dock-icon"
@@ -1,7 +1,14 @@
1
1
  // The assistant's frame: popup, status bar, full screen — and the rules for when each shows.
2
2
  // A separate entry from the shell/auth barrel so a surface that never opens an assistant pays nothing for it.
3
- export { AssistantHost, type AssistantHostLabels, type AssistantSecondTab } from "./host";
3
+ export { AssistantHost, AssistantMenuItem, type AssistantHostLabels, type AssistantSecondTab } from "./host";
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
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";
8
+ // 0.29.0: the chat BODY as components (the surface hands in messages and actions), its markdown, and each engine's mark.
9
+ export { AssistantChat, ChatAttach, ChatBubble, ChatComposer, ChatError, ChatLabelsProvider, ChatMarkdown, ChatMessage, ChatMeta, ChatNote, ChatPending, ChatStarters, ChatSteps, ChatThread, CHAT_EN, PaperclipIcon, useComposerDensity, useFullChromeRoom, type AssistantChatProps, type ChatComposerProps, type ChatFix, type ChatLabels, type ChatMarkdownProps, type ChatMessageData, type ChatStarter, type ChatStep, type ComposerDensity } from "./chat";
10
+ export { bareUrl, CODE_FOLD_LINES, FLEET_IMAGE_HOSTS, imageSource, linkTarget, parseBlocks, parseInline, type Block, type ImageHost, type Inline, type InlineOptions } from "./chat-markdown";
11
+ export { ENGINE_MARK_ART, ENGINE_MARK_FILL_RULE, EngineMark, isEngineMarkId, type EngineMarkArt, type EngineMarkId } from "./engine-mark";
12
+ export { atBridge, BRIDGE_GONE, bridgeTurnError, PAIRING_REJECTED, useChatTurns, type ChatTurns, type TurnError } from "./chat-turns";
13
+ // 0.32.0: the bridge on the user's machine, seen from the browser (the radars' copies, once): client, state, picker props.
14
+ export { BRIDGE_CLOSED_EARLY, BRIDGE_SETUP_EN, BridgePairForm, SERVER_CLOSED_EARLY, bridgeControlNote, bridgePickerProps, createBridgeClient, isLocalPage, localNetworkPermission, mergeBridgeEngines, pickerBridgeState, postTurn, readTurnEvents, useBridge, type BridgeApi, type BridgeClient, type BridgeClientOptions, type BridgeEngineStatus, type BridgeEngines, type BridgePickerOptions, type BridgeProbe, type BridgeProbeState, type BridgeSetupLabels, type TurnEvent, type UseBridgeOptions } from "./bridge";
@@ -0,0 +1 @@
1
+ .gb-chat{display:flex;flex-direction:column;min-width:0;min-height:0;height:100%;background:var(--gemboss-surface, #fff)}.gemboss-assistant-layout:not(.is-dock) .gb-chat{background:transparent}.gb-chat>.gemboss-ec-scroll{flex:1 1 auto}.gb-dock:not(.gb-dock-full) .gb-chat-head{display:none}.gb-chat-head{flex:none;box-sizing:border-box;justify-content:center}.gb-chat[data-chrome]>.gb-chat-head{padding-right:var(--gb-chat-chrome-w);min-height:var(--gb-chat-chrome-h)}.gb-chat[data-chrome]:not(:has(>.gb-chat-head))>.gemboss-ec-scroll{padding-top:var(--gb-chat-chrome-h)}.gemboss-ec-starter.gb-chat-starter{white-space:normal;border-radius:12px;text-align:left;overflow-wrap:anywhere}.gb-chat-starter-hint{display:block;margin-bottom:4px;color:var(--gemboss-text-muted);font-size:var(--gemboss-fs-caption, 12px)}.gb-chat-label{margin:0;color:var(--gemboss-text-muted);font-size:var(--gemboss-fs-caption, 12px)}.gb-chat-target{color:var(--gemboss-text-muted)}.gb-chat-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;color:var(--gemboss-text-muted);font-size:var(--gemboss-fs-caption, 12px)}.gb-chat-copy{flex:none}.gb-chat-note{margin:0;align-self:center;text-align:center;color:var(--gemboss-text-muted);font-size:var(--gemboss-fs-caption, 12px)}.gb-chat-notice{margin:0}.gb-chat-error{display:grid;gap:8px;margin:0;overflow-wrap:anywhere}.gb-chat-cmd{display:block;padding:6px 8px;border-radius:8px;background:#0000000d;color:var(--gemboss-text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:16px;overflow-wrap:anywhere;user-select:all}.gb-chat-original>summary{cursor:pointer;min-height:24px;display:flex;align-items:center}.gemboss-ec-input .gemboss-ec-attach-btn{flex:0 0 auto;width:44px;height:44px;border-radius:50%;padding:0;display:inline-flex;align-items:center;justify-content:center;border:0;background-color:transparent;color:var(--gemboss-text-muted);cursor:pointer}.gemboss-ec-input .gemboss-ec-attach-btn:hover:not(:disabled){background-color:var(--gemboss-hover, rgba(0, 0, 0, .05));color:var(--gemboss-text)}.gemboss-ec-input .gemboss-ec-attach-btn:focus-visible{outline:2px solid var(--gemboss-focus, var(--ui-focus, #5661f5));outline-offset:2px}.gemboss-ec-input .gemboss-ec-attach-btn:disabled{opacity:.5;cursor:default}.gemboss-ec-input .gemboss-ec-attach-btn svg{width:24px;height:24px}@media (pointer: fine){.gemboss-ec-input .gb-engine-chip{position:relative}.gemboss-ec-input .gb-engine-chip:after{content:"";position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:44px;z-index:1}}@media (pointer: coarse),(max-width: 47.9975em){.gb-chat-original>summary{min-height:44px}}@media (max-width: 560px){.gb-dock-fullchrome .gb-dock-shrink:not(:has(svg)){padding:0 12px;width:auto}.gb-dock-fullchrome .gb-dock-shrink:not(:has(svg)) span{position:static;width:auto;height:auto;overflow:visible;clip:auto}}
@@ -1 +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}}
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-chip:disabled{cursor:default;opacity:.55}.gemboss-engine-mark{display:block;width:14px;height:14px;flex:0 0 auto}.gb-engine-chipmark{display:none}.gb-engine-chipmark .gemboss-engine-mark{width:16px;height:16px}.gemboss-ec-input:not([data-density=full]) .gb-engine-chipsub{display:none}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gb-engine-chip.has-mark .gb-engine-chiplabel{display:none}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gb-engine-chip.has-mark .gb-engine-chipmark{display:block}.gemboss-ec-input:is([data-density=icon],[data-density=tight]) .gb-engine:has(.has-mark){flex-shrink:0}.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-groups{display:grid;gap:var(--ui-space-3, 12px)}.gb-engine-group{display:grid;gap:6px}.gb-engine-group+.gb-engine-group{padding-top:var(--ui-space-3, 12px);border-top:1px solid var(--ui-border)}.gb-engine-group[data-current] .gb-engine-rowname{font-weight:600}.gb-engine-problem{color:var(--gemboss-danger-strong, #b42318)}.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:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:4px 6px;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-top:4px}.gb-engine-notice>.gb-engine-notice-link{grid-column:2;justify-self:start}.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;overflow-wrap:anywhere}.gb-engine-notice-link{flex:none;display:inline-flex;align-items:center;min-height:24px;margin:-4px 0 -4px -4px;padding:0 4px;border:0;border-radius:4px;background:none;font:inherit;font-weight:550;color:var(--ui-accent);text-decoration:underline;text-underline-offset:2px;cursor:pointer}.gb-engine-notice-link:focus-visible{outline:2px solid var(--ui-focus);outline-offset:0}@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{min-height:44px;margin:-14px 0 -14px -8px;padding:0 8px}}@media (prefers-reduced-motion: no-preference){.gb-engine-chip,.gb-engine-opt,.gb-engine-row{transition:background-color .12s ease,color .12s ease}}