@gemboss/ui 0.25.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +276 -0
- package/README.md +370 -24
- package/catalog/editor.html +1 -1
- package/catalog/index.html +912 -63
- package/gemboss-ui.css +3 -3
- package/package.json +14 -2
- package/parts/components.css +2 -2
- package/parts/core.css +1 -1
- package/parts/shell.css +1 -1
- package/src/action-card.css +1 -0
- package/src/action-card.tsx +149 -0
- package/src/assistant/bridge.tsx +636 -0
- package/src/assistant/chat-markdown.ts +280 -0
- package/src/assistant/chat-turns.ts +120 -0
- package/src/assistant/chat.tsx +909 -0
- package/src/assistant/engine-mark-art.ts +45 -0
- package/src/assistant/engine-mark.tsx +39 -0
- package/src/assistant/engine.tsx +119 -12
- package/src/assistant/host.tsx +115 -32
- package/src/assistant/index.ts +8 -1
- package/src/assistant-chat.css +1 -0
- package/src/assistant-engine.css +1 -1
- package/src/assistant.css +1 -1
- package/src/auth.css +1 -1
- package/src/auth.tsx +17 -2
- package/src/chat-composer.css +1 -1
- package/src/chat-plain.js +6 -0
- package/src/chat.css +1 -1
- package/src/editor/frame.tsx +11 -4
- package/src/editor/index.ts +1 -1
- package/src/editor.css +1 -1
- package/src/engine-plain.js +1 -0
- package/src/index.ts +4 -1
- package/src/kit-plain.js +1 -1
- package/src/minichart.css +1 -0
- package/src/minichart.tsx +165 -0
- package/src/portal.css +1 -1
- package/src/search-dialog.tsx +67 -13
- package/src/setup-guide.css +1 -0
- package/src/setup-guide.tsx +116 -0
- package/src/ui.css +1 -1
|
@@ -0,0 +1,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
|
+
}
|
package/src/assistant/engine.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
196
|
-
const
|
|
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
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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 */ }
|
package/src/assistant/host.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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"
|
package/src/assistant/index.ts
CHANGED
|
@@ -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}}
|
package/src/assistant-engine.css
CHANGED
|
@@ -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:
|
|
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}}
|