@gemboss/ui 0.21.0 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/README.md +433 -24
  3. package/catalog/editor.html +426 -10
  4. package/catalog/index.html +907 -33
  5. package/gemboss-ui.css +3 -3
  6. package/package.json +23 -3
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/action-card.css +1 -0
  11. package/src/action-card.tsx +149 -0
  12. package/src/app-shell.css +1 -1
  13. package/src/assistant/chat-markdown.ts +279 -0
  14. package/src/assistant/chat-turns.ts +120 -0
  15. package/src/assistant/chat.tsx +871 -0
  16. package/src/assistant/engine-mark-art.ts +45 -0
  17. package/src/assistant/engine-mark.tsx +39 -0
  18. package/src/assistant/engine.tsx +697 -0
  19. package/src/assistant/host.tsx +3 -1
  20. package/src/assistant/index.ts +6 -0
  21. package/src/assistant-chat.css +1 -0
  22. package/src/assistant-engine.css +1 -0
  23. package/src/auth.css +1 -1
  24. package/src/auth.tsx +18 -2
  25. package/src/chat-composer.css +1 -1
  26. package/src/chat-plain.js +6 -0
  27. package/src/chat.css +1 -1
  28. package/src/collections.tsx +1 -0
  29. package/src/dialog.tsx +3 -135
  30. package/src/editor/controls.tsx +1 -0
  31. package/src/editor/frame.tsx +12 -4
  32. package/src/editor/index.ts +10 -1
  33. package/src/editor/layers.tsx +136 -0
  34. package/src/editor/panel.tsx +1 -0
  35. package/src/editor/panes.tsx +4 -3
  36. package/src/editor/sortable.tsx +79 -0
  37. package/src/editor/tree.tsx +61 -0
  38. package/src/editor-layers-plain.js +1 -0
  39. package/src/editor.css +1 -1
  40. package/src/engine-plain.js +1 -0
  41. package/src/index-table.tsx +1 -0
  42. package/src/index.ts +8 -3
  43. package/src/kit-plain.css +1 -1
  44. package/src/kit-plain.js +1 -1
  45. package/src/launcher.tsx +1 -0
  46. package/src/menu-layer.ts +9 -2
  47. package/src/minichart.css +1 -0
  48. package/src/minichart.tsx +165 -0
  49. package/src/portal.tsx +1 -0
  50. package/src/reactions.tsx +1 -0
  51. package/src/resource-list-select.tsx +1 -0
  52. package/src/search-dialog.tsx +146 -0
  53. package/src/selection.tsx +1 -0
  54. package/src/setup-guide.css +1 -0
  55. package/src/setup-guide.tsx +116 -0
  56. package/src/theme-script.ts +10 -0
  57. package/src/theme.tsx +5 -5
@@ -0,0 +1,871 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The assistant's CHAT BODY as components — @gemboss/ui 0.29.0 (Chris 08/10/2026: "đồng bộ hết giao diện của AI
5
+ * assistant… mọi service có panel này đồng bộ style + code dùng gemboss ui").
6
+ *
7
+ * Until 0.29.0 the package drew the FRAME (AssistantHost), the conversation list (SessionRail) and the engine picker, and
8
+ * shipped the body's CSS (chat.css, chat-composer.css), but no component drew the body: every surface wrote the bubbles,
9
+ * the steps, the starters and the composer by hand on those classes, and each wrote a markdown renderer of its own. Five
10
+ * surfaces, five composers (a form or a div, rows 1 or 2, "↑" or "Send", with or without an IME guard), three ways to
11
+ * show a step running. These are the pieces, taken from the two that had them right (see README "The chat body"):
12
+ *
13
+ * from the admin (apps/admin GemChat): the composer (one bordered shell, one action slot that is send or stop, the text
14
+ * box that grows to 84px, the toolbar that says less as it narrows: `data-density`), the markdown, the step rows
15
+ * that print and stop (one heartbeat moves, not one spinner per row), the working heartbeat with its clock;
16
+ * from partner-radar: the composer as a DIV (the engine popup lives inside it and has a form of its own), the IME guard
17
+ * on Enter, starters that wrap, the line under an answer (Copy · engine · seconds · cost), the error that says how to
18
+ * fix it (a sentence, the command, Copy, the original error folded).
19
+ *
20
+ * The surface hands in data and actions (messages, send, stop, the engine picker element) and keeps its behaviour:
21
+ * streaming, the bridge, sessions. `AssistantChat` puts the pieces together; each piece is exported for a surface that
22
+ * needs its own arrangement (a proposal card under an answer, a picker of its own beside the composer).
23
+ */
24
+ import "../chat.css";
25
+ import "../chat-composer.css";
26
+ import "../assistant-chat.css";
27
+ import "../assistant-engine.css"; // BridgeNotice and the engine chip's density rules (engine.tsx brings it too)
28
+ import React, {
29
+ createContext, Fragment, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,
30
+ type ClipboardEventHandler, type KeyboardEventHandler, type ReactNode, type Ref, type RefObject,
31
+ } from "react";
32
+ import { BridgeNotice, copyText } from "./engine";
33
+ import { EngineMark } from "./engine-mark";
34
+ import { isEngineMarkId } from "./engine-mark-art";
35
+ import { CODE_FOLD_LINES, parseBlocks, parseInline, type Block, type ImageHost } from "./chat-markdown";
36
+
37
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
38
+ const useBeforePaint = typeof window === "undefined" ? useEffect : useLayoutEffect;
39
+
40
+ /** Every word the chat says. English, like every surface's chrome (Chris 03/10/2026). */
41
+ export interface ChatLabels {
42
+ thread: string; message: string; send: string; stop: string; attach: string;
43
+ copy: string; copied: string; copyCommand: string; originalError: string; stopped: string;
44
+ steps: string; thought: string; thinking: string; working: string; showLess: string; showAll: string; openImage: string;
45
+ }
46
+ export const CHAT_EN: ChatLabels = {
47
+ thread: "Conversation", message: "Message", send: "Send", stop: "Stop", attach: "Attach a file",
48
+ copy: "Copy", copied: "Copied", copyCommand: "Copy command", originalError: "Original error", stopped: "Stopped",
49
+ steps: "What it is doing", thought: "Thought", thinking: "Thinking", working: "Working", showLess: "Show less", showAll: "Show all {n} lines",
50
+ openImage: "Open image",
51
+ };
52
+ const LabelsContext = createContext<ChatLabels>(CHAT_EN);
53
+ const useLabels = () => useContext(LabelsContext);
54
+
55
+ // ── The thread ─────────────────────────────────────────────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * The message list: `.gemboss-ec-scroll`, a `role="log"`, and it FOLLOWS the end while the reader is at the end (within
59
+ * 24px). Scrolled up to read, it stays put while the answer streams below. `aria-busy` while a turn runs, so a screen
60
+ * reader reads the answer once it is whole instead of token by token.
61
+ */
62
+ export function ChatThread({ children, busy, label, className, scrollRef, follow = true, pinKey }: {
63
+ children?: ReactNode;
64
+ busy?: boolean;
65
+ label?: string;
66
+ className?: string;
67
+ scrollRef?: Ref<HTMLDivElement>;
68
+ /** Keep the end in view while the reader is at the end (default). */
69
+ follow?: boolean;
70
+ /** Change it to go back to the end and follow again (the user sent a message; another conversation was opened). */
71
+ pinKey?: unknown;
72
+ }) {
73
+ const L = useLabels();
74
+ const own = useRef<HTMLDivElement | null>(null);
75
+ const pinned = useRef(true);
76
+ const toEndRef = useRef<() => void>(() => {});
77
+ const setRef = useCallback((el: HTMLDivElement | null) => {
78
+ own.current = el;
79
+ if (typeof scrollRef === "function") scrollRef(el);
80
+ else if (scrollRef) (scrollRef as { current: HTMLDivElement | null }).current = el;
81
+ }, [scrollRef]);
82
+ useEffect(() => {
83
+ const el = own.current;
84
+ if (!el || !follow) return undefined;
85
+ let frame = 0;
86
+ let writtenTop: number | null = null;
87
+ const writeEnd = () => { el.scrollTop = el.scrollHeight; writtenTop = el.scrollTop; };
88
+ // The reader's own move up (wheel, finger, keys) unpins AT ONCE: a 2px trackpad step is still "at the end" by distance,
89
+ // and the next streamed token would pull it back (review #3910). Scrolling back to the end pins again.
90
+ let upAt = 0, goingUp = false;
91
+ const up = () => { upAt = performance.now(); goingUp = true; pinned.current = false; };
92
+ const down = () => { goingUp = false; };
93
+ const dist = () => el.scrollHeight - el.clientHeight - el.scrollTop;
94
+ const toEnd = () => {
95
+ if (!pinned.current || frame) return;
96
+ frame = requestAnimationFrame(() => { frame = 0; if (pinned.current) writeEnd(); });
97
+ };
98
+ toEndRef.current = () => { pinned.current = true; writeEnd(); };
99
+ const onScroll = () => {
100
+ const d = dist();
101
+ // A scroll from our own write can arrive after new content grows the gap. It is not a reader moving up.
102
+ const ownWrite = writtenTop !== null && el.scrollTop === writtenTop;
103
+ writtenTop = null;
104
+ if (d > 24 && !ownWrite) pinned.current = false;
105
+ // Back at the end pins again, at once when the reader was moving down; after a move UP, only once it has settled
106
+ // (the scroll that a 2px step up produces is itself within 4px of the end).
107
+ else if (d <= 4 && (!goingUp || performance.now() - upAt > 400)) pinned.current = true;
108
+ };
109
+ const onWheel = (e: WheelEvent) => { if (e.deltaY < 0) up(); else if (e.deltaY > 0) down(); };
110
+ let touchY = 0;
111
+ const onTouchStart = (e: TouchEvent) => { touchY = e.touches[0]?.clientY ?? 0; };
112
+ const onTouchMove = (e: TouchEvent) => { const y = e.touches[0]?.clientY ?? 0; if (y > touchY + 2) up(); else if (y < touchY - 2) down(); };
113
+ const onKey = (e: KeyboardEvent) => { if (["ArrowUp", "PageUp", "Home"].includes(e.key)) up(); else if (["ArrowDown", "PageDown", "End"].includes(e.key)) down(); };
114
+ el.addEventListener("scroll", onScroll, { passive: true });
115
+ el.addEventListener("wheel", onWheel, { passive: true });
116
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
117
+ el.addEventListener("touchmove", onTouchMove, { passive: true });
118
+ el.addEventListener("keydown", onKey);
119
+ // New content, streamed text, a picture that finished loading (it grows the list without a mutation), a resize.
120
+ const mo = new MutationObserver(toEnd);
121
+ mo.observe(el, { childList: true, subtree: true, characterData: true });
122
+ el.addEventListener("load", toEnd, true);
123
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(toEnd);
124
+ ro?.observe(el);
125
+ writeEnd();
126
+ return () => {
127
+ cancelAnimationFrame(frame);
128
+ el.removeEventListener("scroll", onScroll);
129
+ el.removeEventListener("wheel", onWheel);
130
+ el.removeEventListener("touchstart", onTouchStart);
131
+ el.removeEventListener("touchmove", onTouchMove);
132
+ el.removeEventListener("keydown", onKey);
133
+ el.removeEventListener("load", toEnd, true);
134
+ mo.disconnect();
135
+ ro?.disconnect();
136
+ };
137
+ }, [follow]);
138
+ // Back to the end when the surface says so (a send, another conversation), whatever the reader did before.
139
+ const first = useRef(true);
140
+ useEffect(() => {
141
+ if (first.current) { first.current = false; return; }
142
+ toEndRef.current();
143
+ }, [pinKey]);
144
+ return (
145
+ <div ref={setRef} className={cx("gemboss-ec-scroll", className)} role="log" aria-label={label ?? L.thread} aria-busy={busy || undefined}>
146
+ {children}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ /** One message: the user's (right, filled) or the assistant's (left, outlined), and everything under it. */
152
+ export function ChatMessage({ role, children, className, lang }: { role: "user" | "assistant"; children?: ReactNode; className?: string; lang?: string }) {
153
+ return <div className={cx("gemboss-ec-msg", role, className)} lang={lang}>{children}</div>;
154
+ }
155
+
156
+ /** The bubble. Text keeps its line breaks (`pre-wrap`); markdown goes in through `ChatMarkdown`. */
157
+ export function ChatBubble({ children, streaming, className, lang }: { children?: ReactNode; streaming?: boolean; className?: string; lang?: string }) {
158
+ return (
159
+ <div className={cx("gemboss-ec-bubble", className)} lang={lang}>
160
+ {children}
161
+ {streaming ? <span className="gemboss-ec-cursor" aria-hidden="true" /> : null}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // ── Markdown ───────────────────────────────────────────────────────────────────────────────────────────────────────────
167
+
168
+ export interface ChatMarkdownProps {
169
+ text: string;
170
+ /** An in-app link (`/partners/12`): called instead of a page load. */
171
+ onNavigate?: (href: string) => void;
172
+ /** More hosts whose pictures may be drawn (added to the fleet's). */
173
+ imageHosts?: readonly ImageHost[];
174
+ /** A link scheme of the surface's own (`customer:12`): `ownLink` says which, `renderLink` draws it. */
175
+ ownLink?: (href: string) => boolean;
176
+ renderLink?: (href: string, label: string) => ReactNode;
177
+ }
178
+
179
+ function Inline({ text, p }: { text: string; p: ChatMarkdownProps }) {
180
+ const L = useLabels();
181
+ const toks = parseInline(text, { imageHosts: p.imageHosts, ownLink: p.ownLink });
182
+ return (
183
+ <>
184
+ {toks.map((tok, key) => {
185
+ switch (tok.t) {
186
+ case "text": return <Fragment key={key}>{tok.text}</Fragment>;
187
+ case "code": return <code key={key} className="gemboss-md-code">{tok.text}</code>;
188
+ case "bold": return <strong key={key}>{tok.text}</strong>;
189
+ case "italic": return <em key={key}>{tok.text}</em>;
190
+ case "link": {
191
+ if (p.ownLink?.(tok.href) && p.renderLink) return <Fragment key={key}>{p.renderLink(tok.href, tok.label)}</Fragment>;
192
+ const { href, internal, label } = tok;
193
+ return (
194
+ <a key={key} className="gemboss-ec-md-link" href={href}
195
+ target={internal ? undefined : "_blank"} rel={internal ? undefined : "noopener noreferrer"}
196
+ onClick={(e) => { if (internal && p.onNavigate && !e.metaKey && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); p.onNavigate(href); } }}>
197
+ {label}
198
+ </a>
199
+ );
200
+ }
201
+ case "image":
202
+ return (
203
+ <a key={key} className="gemboss-md-imglink" href={tok.src} target="_blank" rel="noopener noreferrer"
204
+ {...(tok.alt ? {} : { "aria-label": L.openImage })}>
205
+ <img className="gemboss-md-img" src={tok.src} alt={tok.alt} loading="lazy" referrerPolicy="no-referrer" />
206
+ </a>
207
+ );
208
+ default: return null;
209
+ }
210
+ })}
211
+ </>
212
+ );
213
+ }
214
+
215
+ function Lines({ lines, p }: { lines: string[]; p: ChatMarkdownProps }) {
216
+ return <>{lines.map((l, j) => <Fragment key={j}><Inline text={l} p={p} />{j < lines.length - 1 ? <br /> : null}</Fragment>)}</>;
217
+ }
218
+
219
+ function CodeBlock({ lang, body }: { lang: string; body: string }) {
220
+ const L = useLabels();
221
+ const lines = body.split("\n");
222
+ const long = lines.length > CODE_FOLD_LINES;
223
+ const [open, setOpen] = useState(false);
224
+ return (
225
+ <div className="gemboss-md-codeblock">
226
+ {lang ? <span className="gemboss-md-codelang">{lang}</span> : null}
227
+ <pre><code>{long && !open ? lines.slice(0, CODE_FOLD_LINES).join("\n") : body}</code></pre>
228
+ {long ? (
229
+ <button type="button" className="gemboss-md-codemore" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
230
+ {open ? L.showLess : L.showAll.replace("{n}", String(lines.length))}
231
+ </button>
232
+ ) : null}
233
+ </div>
234
+ );
235
+ }
236
+
237
+ // Headings are demoted one level (h1 → h3): the bubble sits under the page's own headings. The size keeps the hierarchy.
238
+ const HEADING_TAG = { 1: "h3", 2: "h4", 3: "h5" } as const;
239
+ const HEADING_CLASS = { 1: "gemboss-md-h gemboss-md-h1", 2: "gemboss-md-h gemboss-md-h2", 3: "gemboss-md-h gemboss-md-h3" } as const;
240
+
241
+ function BlockView({ b, p }: { b: Block; p: ChatMarkdownProps }) {
242
+ switch (b.t) {
243
+ case "heading": { const H = HEADING_TAG[b.level]; return <H className={HEADING_CLASS[b.level]}><Inline text={b.text} p={p} /></H>; }
244
+ case "code": return <CodeBlock lang={b.lang} body={b.body} />;
245
+ case "table":
246
+ return (
247
+ <div className="gemboss-md-tablewrap">
248
+ <table className="gemboss-md-table">
249
+ <thead><tr>{b.head.map((h, j) => <th key={j}><Inline text={h} p={p} /></th>)}</tr></thead>
250
+ <tbody>{b.rows.map((r, ri) => <tr key={ri}>{b.head.map((_, ci) => <td key={ci}><Inline text={r[ci] ?? ""} p={p} /></td>)}</tr>)}</tbody>
251
+ </table>
252
+ </div>
253
+ );
254
+ case "list": {
255
+ const items = b.items.map((it, j) => <li key={j}><Inline text={it} p={p} /></li>);
256
+ return b.ordered
257
+ ? <ol className="gemboss-md-list" start={b.start}>{items}</ol>
258
+ : <ul className="gemboss-md-list">{items}</ul>;
259
+ }
260
+ case "quote": return <blockquote className="gemboss-md-quote"><Lines lines={b.lines} p={p} /></blockquote>;
261
+ case "rule": return <hr className="gemboss-md-rule" />;
262
+ default: return <p className="gemboss-md-p"><Lines lines={b.lines} p={p} /></p>;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * What a model writes, drawn with the `gemboss-md-*` classes. No raw HTML path: every node is built. Memoised on the text
268
+ * (a streaming transcript re-renders on every frame).
269
+ */
270
+ export function ChatMarkdown(props: ChatMarkdownProps) {
271
+ const blocks = useMemo(() => parseBlocks(props.text), [props.text]);
272
+ return <>{blocks.map((b, i) => <BlockView key={i} b={b} p={props} />)}</>;
273
+ }
274
+
275
+ // ── Steps (narration) ──────────────────────────────────────────────────────────────────────────────────────────────────
276
+
277
+ export interface ChatStep {
278
+ id: string | number;
279
+ /** step (default): a tool or an action, in words · thinking: the model's reasoning · trace: a raw tool call, its
280
+ * payload folded · steer: a line the user typed into the running turn. */
281
+ kind?: "step" | "thinking" | "trace" | "steer";
282
+ /** running · ok · failed. Rows print and STOP: nothing on a row spins (one heartbeat, `ChatPending`, moves). */
283
+ state?: "running" | "ok" | "failed";
284
+ /** What it did, in words ("Search partners"); for a trace the call; for thinking the text so far. */
285
+ label: ReactNode;
286
+ /** What it did it to, muted after the label ("Wiser"). */
287
+ target?: ReactNode;
288
+ /** The trace's payload (folded), or a finished thought. */
289
+ detail?: string;
290
+ /** Why it failed: the row's hover text. */
291
+ error?: string;
292
+ /** This row stands for the turn: the engine's mark in the dot's place. */
293
+ engine?: string;
294
+ }
295
+
296
+ const STEP_CLASS = { running: "gemboss-narr-row running", ok: "gemboss-narr-row ok", failed: "gemboss-narr-row failed" } as const;
297
+ const THINK_CLASS = { running: "gemboss-narr-row thinking running", ok: "gemboss-narr-row thinking ok", failed: "gemboss-narr-row thinking failed" } as const;
298
+ const TRACE_CLASS = { running: "gemboss-narr-row trace running", ok: "gemboss-narr-row trace ok", failed: "gemboss-narr-row trace failed" } as const;
299
+ const STEER_CLASS = { running: "gemboss-narr-row steer running", ok: "gemboss-narr-row steer ok", failed: "gemboss-narr-row steer failed" } as const;
300
+
301
+ /** What the assistant did on the way to its answer, one row per step. */
302
+ export function ChatSteps({ steps, label }: { steps: readonly ChatStep[]; label?: string }) {
303
+ const L = useLabels();
304
+ if (!steps.length) return null;
305
+ return (
306
+ <ul className="gemboss-narr" aria-label={label ?? L.steps}>
307
+ {steps.map((s) => {
308
+ const state = s.state ?? "ok";
309
+ if (s.kind === "thinking") {
310
+ return (
311
+ <li key={s.id} className={THINK_CLASS[state]}>
312
+ <span className="gemboss-narr-dot" aria-hidden="true">✳</span>
313
+ {state === "running" || !s.detail ? (
314
+ <span className="gemboss-narr-text gemboss-narr-thinking-live">{s.label || L.thinking}</span>
315
+ ) : (
316
+ <details className="gemboss-narr-thinking">
317
+ <summary>{s.label || L.thought}</summary>
318
+ <div className="gemboss-narr-thinking-body">{s.detail}</div>
319
+ </details>
320
+ )}
321
+ </li>
322
+ );
323
+ }
324
+ if (s.kind === "steer") {
325
+ return (
326
+ <li key={s.id} className={STEER_CLASS[state]}>
327
+ <span className="gemboss-narr-dot" aria-hidden="true">❯</span>
328
+ <span className="gemboss-narr-text gemboss-narr-steer">{s.label}</span>
329
+ </li>
330
+ );
331
+ }
332
+ if (s.kind === "trace") {
333
+ return (
334
+ <li key={s.id} className={TRACE_CLASS[state]} title={s.error || undefined}>
335
+ <span className="gemboss-narr-dot" aria-hidden="true">{state === "failed" ? "✕" : "⏺"}</span>
336
+ {s.detail ? (
337
+ <details className="gemboss-narr-trace">
338
+ <summary><code>{s.label}</code></summary>
339
+ <div className="gemboss-narr-trace-body"><code>{s.detail}</code></div>
340
+ </details>
341
+ ) : <code className="gemboss-narr-text gemboss-narr-trace-call">{s.label}</code>}
342
+ </li>
343
+ );
344
+ }
345
+ return (
346
+ <li key={s.id} className={STEP_CLASS[state]} title={s.error || undefined}>
347
+ {s.engine && isEngineMarkId(s.engine)
348
+ ? <span className="gemboss-narr-dot gemboss-narr-dot-engine"><EngineMark engine={s.engine} /></span>
349
+ : <span className="gemboss-narr-dot" aria-hidden="true">{state === "ok" ? "✓" : state === "failed" ? "✕" : "·"}</span>}
350
+ <span className="gemboss-narr-text">{s.label}{s.target ? <span className="gb-chat-target"> {s.target}</span> : null}</span>
351
+ </li>
352
+ );
353
+ })}
354
+ </ul>
355
+ );
356
+ }
357
+
358
+ // ── Working, the heartbeat ─────────────────────────────────────────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * The one thing that moves while a turn runs and nothing is written yet: a pulsing ✳, a verb that changes every 2s, the
362
+ * seconds (and tokens, when known). For a screen reader it says its label ONCE (role="status"): the clock and the verbs
363
+ * are hidden from it, or it would announce every second.
364
+ */
365
+ export function ChatPending({ verbs, tokens, compact, label }: { verbs?: readonly string[]; tokens?: number; compact?: boolean; label?: string }) {
366
+ const L = useLabels();
367
+ const list = verbs && verbs.length ? verbs : [L.working];
368
+ const [idx, setIdx] = useState(0);
369
+ const [elapsed, setElapsed] = useState(0);
370
+ useEffect(() => {
371
+ const t0 = performance.now();
372
+ const id = setInterval(() => setElapsed(Math.floor((performance.now() - t0) / 1000)), 250);
373
+ return () => clearInterval(id);
374
+ }, []);
375
+ useEffect(() => {
376
+ if (list.length <= 1) return undefined;
377
+ const id = setInterval(() => setIdx((i) => (i + 1) % list.length), 2000);
378
+ return () => clearInterval(id);
379
+ }, [list.length]);
380
+ return (
381
+ <div className="gemboss-ec-pending" role="status">
382
+ <span className="gemboss-assistant-sronly">{label ?? list[0]}</span>
383
+ <span className="gemboss-ec-pending-star" aria-hidden="true">✳</span>
384
+ {!compact ? <span className="gemboss-ec-pending-verb" key={idx} aria-hidden="true">{list[idx % list.length]}…</span> : null}
385
+ <span className="gemboss-ec-pending-meta" aria-hidden="true">{tokens && tokens > 0 ? `${tokens} token${tokens === 1 ? "" : "s"} · ` : ""}{elapsed}s</span>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ // ── Starters, the line under an answer, errors, notes ──────────────────────────────────────────────────────────────────
391
+
392
+ export interface ChatStarter {
393
+ /** What the button says. */
394
+ label: string;
395
+ /** What is sent (default: the label). A long prompt behind a short label. */
396
+ prompt?: string;
397
+ /** A quiet line above the label ("Example, click to fill in"). */
398
+ hint?: string;
399
+ /** Put the prompt in the composer instead of sending it (an example the user edits first). */
400
+ fill?: boolean;
401
+ }
402
+
403
+ /** Suggested first questions. They WRAP (a long prompt as a nowrap pill was cut in a 400px panel). */
404
+ export function ChatStarters({ items, onPick, title }: { items: readonly ChatStarter[]; onPick: (s: ChatStarter) => void; title?: ReactNode }) {
405
+ if (!items.length) return null;
406
+ return (
407
+ <>
408
+ {title ? <p className="gb-chat-label">{title}</p> : null}
409
+ <div className="gemboss-ec-starters">
410
+ {items.map((s, i) => (
411
+ <button key={`${i}:${s.label}`} type="button" className="gemboss-ec-starter gb-chat-starter" onClick={() => onPick(s)}>
412
+ {s.hint ? <span className="gb-chat-starter-hint">{s.hint}</span> : null}
413
+ {s.label}
414
+ </button>
415
+ ))}
416
+ </div>
417
+ </>
418
+ );
419
+ }
420
+
421
+ function CopyButton({ text, label, done }: { text: string; label: string; done: string }) {
422
+ const [said, setSaid] = useState(false);
423
+ const timer = useRef<number | undefined>(undefined);
424
+ useEffect(() => () => window.clearTimeout(timer.current), []);
425
+ return (
426
+ <button type="button" className="ui-btn ui-btn--ghost gb-chat-copy" onClick={async () => {
427
+ if (!(await copyText(text))) return;
428
+ setSaid(true);
429
+ window.clearTimeout(timer.current);
430
+ timer.current = window.setTimeout(() => setSaid(false), 1500);
431
+ }}>{said ? done : label}</button>
432
+ );
433
+ }
434
+
435
+ /** The line under a finished answer: Copy, then what the surface wants said (engine, seconds, model, cost). */
436
+ export function ChatMeta({ copyText: text, items }: { copyText?: string; items?: ReactNode[] }) {
437
+ const L = useLabels();
438
+ const shown = (items ?? []).filter((x) => x !== null && x !== undefined && x !== false && x !== "");
439
+ if (!text && !shown.length) return null;
440
+ return (
441
+ <div className="gb-chat-meta">
442
+ {text ? <CopyButton text={text} label={L.copy} done={L.copied} /> : null}
443
+ {shown.map((x, i) => <span key={i}>{x}</span>)}
444
+ </div>
445
+ );
446
+ }
447
+
448
+ /** A way out the user can take on their own machine: one sentence, and the command when there is one. */
449
+ export interface ChatFix { text: ReactNode; command?: string }
450
+
451
+ /** A turn that failed. With a `fix`: what to do, the command, Copy, and the original error folded underneath. */
452
+ export function ChatError({ children, fix }: { children: ReactNode; fix?: ChatFix | null }) {
453
+ const L = useLabels();
454
+ if (!fix) return <p className="ui-notice ui-notice--danger gb-chat-error" role="alert">{children}</p>;
455
+ return (
456
+ <div className="ui-notice ui-notice--danger gb-chat-error" role="alert">
457
+ <div>{fix.text}</div>
458
+ {fix.command ? <code className="gb-chat-cmd">{fix.command}</code> : null}
459
+ {fix.command ? <div><CopyButton text={fix.command} label={L.copyCommand} done={L.copied} /></div> : null}
460
+ <details className="gb-chat-original"><summary>{L.originalError}</summary><span>{children}</span></details>
461
+ </div>
462
+ );
463
+ }
464
+
465
+ /** A quiet centred line in the stream ("Switched to Codex: a new conversation", "Stopped"). */
466
+ export function ChatNote({ children }: { children: ReactNode }) {
467
+ return <p className="gb-chat-note">{children}</p>;
468
+ }
469
+
470
+ // ── The composer ───────────────────────────────────────────────────────────────────────────────────────────────────────
471
+
472
+ /**
473
+ * HOW MUCH THE TOOLBAR SAYS, stepped down only as far as the room demands (the admin's rule, measured not guessed):
474
+ * full "Claude Code · Opus" → compact "Claude Code" → icon (the engine's mark) → tight (a select's short label).
475
+ * The composer tries each step and keeps the first where no label is cut and nothing overflows.
476
+ */
477
+ export type ComposerDensity = "full" | "compact" | "icon" | "tight";
478
+ const DENSITIES: ComposerDensity[] = ["full", "compact", "icon", "tight"];
479
+ const DensityContext = createContext<ComposerDensity>("full");
480
+ /** For a control in `leading` that changes its TEXT, not only its CSS (a <select> with a short label). */
481
+ export function useComposerDensity(): ComposerDensity { return useContext(DensityContext); }
482
+
483
+ const LABEL_SEL = ".gemboss-ec-chiplabel, .gb-engine-chiplabel";
484
+ const CHIP_SEL = ".gemboss-ec-chipctl, .gb-engine-chip";
485
+ /** A label cut by its ellipsis, a chip narrower than its content, or a toolbar wider than its box: step down. */
486
+ function crowded(box: HTMLElement): boolean {
487
+ const lead = box.querySelector<HTMLElement>(".gemboss-ec-toolbar-lead");
488
+ const bar = box.querySelector<HTMLElement>(".gemboss-ec-toolbar");
489
+ if (lead && lead.scrollWidth > lead.clientWidth + 1) return true;
490
+ if (bar && bar.scrollWidth > bar.clientWidth + 1) return true;
491
+ for (const l of box.querySelectorAll<HTMLElement>(LABEL_SEL)) if (l.offsetParent !== null && l.scrollWidth > l.clientWidth + 1) return true;
492
+ for (const c of box.querySelectorAll<HTMLElement>(CHIP_SEL)) if (c.offsetParent !== null && c.tagName !== "SELECT" && c.scrollWidth > c.clientWidth + 1) return true;
493
+ return false;
494
+ }
495
+
496
+ /** The paperclip every composer draws: one glyph, not 📎 in one chat and an SVG in another. */
497
+ export function PaperclipIcon() {
498
+ return (
499
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
500
+ <path d="M21.4 11.1l-8.5 8.5a5.5 5.5 0 01-7.8-7.8l8.5-8.5a3.7 3.7 0 015.2 5.2l-8.5 8.5a1.8 1.8 0 01-2.6-2.6l7.8-7.8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
501
+ </svg>
502
+ );
503
+ }
504
+ function SendIcon() {
505
+ return <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M10.53 4.47a.75.75 0 0 0-1.06 0l-4.5 4.5a.75.75 0 0 0 1.06 1.06l3.22-3.22v8.69a.75.75 0 0 0 1.5 0v-8.69l3.22 3.22a.75.75 0 1 0 1.06-1.06l-4.5-4.5Z" /></svg>;
506
+ }
507
+
508
+ export interface ChatComposerProps {
509
+ value: string;
510
+ onChange: (value: string) => void;
511
+ /** Enter (not Shift+Enter, not mid-IME) or the send button, with text in the box. */
512
+ onSend: (text: string) => void;
513
+ /** A turn is running: the action slot is Stop when `onStop` is given (the same 44px circle, so nothing moves). */
514
+ busy?: boolean;
515
+ onStop?: () => void;
516
+ /** Send stays allowed while busy (a surface that queues or steers a running turn). */
517
+ sendWhileBusy?: boolean;
518
+ /** A stop is already on its way (0.30.2): Stop stays in the slot, disabled, so a second press is visibly a no-op. */
519
+ stopDisabled?: boolean;
520
+ /** Overrides whether the action may send (0.30.2): a picture attached with no words is sendable (the admin's reference
521
+ * image), an upload still running is not. The text box stays editable either way; `disabled` is for the whole box. */
522
+ canSend?: boolean;
523
+ placeholder?: string;
524
+ /** The text box's accessible name (default "Message"). */
525
+ label?: string;
526
+ /** The chip strip: the engine picker, a mode select. Chips only (density reads it as chips). */
527
+ leading?: ReactNode;
528
+ /** Tools that are not chips: the attach button. */
529
+ tools?: ReactNode;
530
+ /** Full-width rows above the text box (a pending attachment). */
531
+ top?: ReactNode;
532
+ /** Beside the action ("2 queued"). */
533
+ status?: ReactNode;
534
+ maxLength?: number;
535
+ rows?: number;
536
+ disabled?: boolean;
537
+ textareaRef?: RefObject<HTMLTextAreaElement | null>;
538
+ onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
539
+ onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
540
+ className?: string;
541
+ }
542
+
543
+ /**
544
+ * THE MESSAGE BOX. A DIV, not a form: the engine picker's popup lives inside this row (in the top layer, but in the DOM
545
+ * and the React tree), and its Setup tab has a pairing form whose Enter would otherwise submit the draft (content-radar
546
+ * review #916). The send button keeps `type="submit"` because chat.css draws it by that; outside a form it submits nothing.
547
+ */
548
+ export function ChatComposer(p: ChatComposerProps) {
549
+ const L = useLabels();
550
+ const own = useRef<HTMLTextAreaElement | null>(null);
551
+ const ref = p.textareaRef ?? own;
552
+ const boxRef = useRef<HTMLDivElement>(null);
553
+ const [density, setDensity] = useState<ComposerDensity>("full");
554
+ const [remeasure, setRemeasure] = useState(0);
555
+ const canSend = !p.disabled && (p.canSend ?? p.value.trim() !== "") && (!p.busy || p.sendWhileBusy || !p.onStop);
556
+
557
+ // One step per commit, before paint: the crowded frame is never seen. Terminates (four places, each pass stops or moves on).
558
+ useBeforePaint(() => {
559
+ const box = boxRef.current;
560
+ if (!box || box.offsetParent === null) return;
561
+ const i = DENSITIES.indexOf(density);
562
+ if (i < DENSITIES.length - 1 && crowded(box)) setDensity(DENSITIES[i + 1]);
563
+ }, [density, remeasure]);
564
+
565
+ useEffect(() => {
566
+ const box = boxRef.current;
567
+ const lead = box?.querySelector(".gemboss-ec-toolbar-lead");
568
+ if (!box || !lead) return undefined;
569
+ const restart = () => { setDensity("full"); setRemeasure((n) => n + 1); };
570
+ // WIDTH only (stepping does not change the box's width, so this cannot feed itself).
571
+ let width = box.clientWidth;
572
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
573
+ if (box.clientWidth === width) return;
574
+ width = box.clientWidth;
575
+ restart();
576
+ });
577
+ ro?.observe(box);
578
+ // A chip arriving, or its choice changing (`title` / `aria-label` / `data-label` carry the FULL label whatever the
579
+ // density). ONE observe call: a second one on the same node replaces the first one's options.
580
+ const mo = new MutationObserver(restart);
581
+ mo.observe(lead, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-label", "title", "aria-label"] });
582
+ restart();
583
+ let live = true;
584
+ void document.fonts?.ready.then(() => { if (live) restart(); });
585
+ return () => { live = false; ro?.disconnect(); mo.disconnect(); };
586
+ }, []);
587
+
588
+ // Grow from one line to the box's max-height (chat.css: 84px), then scroll inside. Hidden (a closed tab): skip, the
589
+ // observer fits it when it gets a size.
590
+ useEffect(() => {
591
+ const el = ref.current;
592
+ if (!el) return undefined;
593
+ const fit = () => {
594
+ if (el.offsetParent === null || el.scrollHeight === 0) return;
595
+ const max = parseFloat(getComputedStyle(el).maxHeight) || 84;
596
+ el.style.height = "auto";
597
+ const want = el.scrollHeight + (el.offsetHeight - el.clientHeight); // border-box: add the 1px borders back
598
+ el.style.height = `${Math.min(want, max)}px`;
599
+ el.style.overflowY = want > max ? "auto" : "hidden";
600
+ };
601
+ fit();
602
+ if (typeof ResizeObserver === "undefined") return undefined;
603
+ let width = el.clientWidth;
604
+ const ro = new ResizeObserver(() => { if (el.clientWidth === width) return; width = el.clientWidth; fit(); });
605
+ ro.observe(el);
606
+ return () => ro.disconnect();
607
+ }, [p.value, ref]);
608
+
609
+ const send = () => { if (canSend) p.onSend(p.value); };
610
+ const showStop = Boolean(p.busy && p.onStop);
611
+
612
+ return (
613
+ <DensityContext.Provider value={density}>
614
+ <div ref={boxRef} className={cx("gemboss-ec-input", p.className)} data-density={density}>
615
+ {p.top}
616
+ <textarea
617
+ ref={ref as Ref<HTMLTextAreaElement>}
618
+ value={p.value}
619
+ rows={p.rows ?? 1}
620
+ maxLength={p.maxLength}
621
+ disabled={p.disabled}
622
+ aria-label={p.label ?? L.message}
623
+ placeholder={p.placeholder}
624
+ onChange={(e) => p.onChange(e.target.value)}
625
+ onPaste={p.onPaste}
626
+ onKeyDown={(e) => {
627
+ p.onKeyDown?.(e);
628
+ if (e.defaultPrevented) return;
629
+ // Not mid-composition: Enter there picks the IME's candidate (Vietnamese Telex, Chinese pinyin), it does not send.
630
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
631
+ }}
632
+ />
633
+ <div className="gemboss-ec-toolbar">
634
+ <div className="gemboss-ec-toolbar-lead">{p.leading}</div>
635
+ {p.tools ? <div className="gemboss-ec-toolbar-tools">{p.tools}</div> : null}
636
+ {p.status}
637
+ {/* Two keys, two nodes: one reused <button> was born with stop's red and faded to grey (the admin, 22/09). */}
638
+ {showStop ? (
639
+ <button key="stop" type="button" className="gemboss-ec-stop" aria-label={L.stop} title={L.stop} disabled={p.stopDisabled || undefined}
640
+ onClick={() => { p.onStop?.(); ref.current?.focus(); }}>
641
+ <svg viewBox="0 0 12 12" aria-hidden="true" focusable="false"><rect x="1" y="1" width="10" height="10" rx="2" /></svg>
642
+ </button>
643
+ ) : (
644
+ <button key="send" type="submit" disabled={!canSend} aria-label={L.send} title={L.send} onClick={(e) => { e.preventDefault(); send(); }}>
645
+ <SendIcon />
646
+ </button>
647
+ )}
648
+ </div>
649
+ </div>
650
+ </DensityContext.Provider>
651
+ );
652
+ }
653
+
654
+ /** The attach button for `tools`: the paperclip, a hidden file input, the files handed over. */
655
+ export function ChatAttach({ onFiles, accept, multiple, label, title, disabled }: {
656
+ onFiles: (files: File[]) => void;
657
+ accept?: string;
658
+ multiple?: boolean;
659
+ label?: string;
660
+ title?: string;
661
+ disabled?: boolean;
662
+ }) {
663
+ const L = useLabels();
664
+ const input = useRef<HTMLInputElement>(null);
665
+ return (
666
+ <>
667
+ <button type="button" className="gemboss-ec-attach-btn" aria-label={label ?? L.attach} title={title ?? label ?? L.attach}
668
+ disabled={disabled} onClick={() => input.current?.click()}>
669
+ <PaperclipIcon />
670
+ </button>
671
+ <input ref={input} type="file" hidden accept={accept} multiple={multiple} tabIndex={-1}
672
+ onChange={(e) => { const files = [...(e.target.files ?? [])]; e.target.value = ""; if (files.length) onFiles(files); }} />
673
+ </>
674
+ );
675
+ }
676
+
677
+ // ── The whole chat ─────────────────────────────────────────────────────────────────────────────────────────────────────
678
+
679
+ export interface ChatMessageData {
680
+ id?: string | number;
681
+ role: "user" | "assistant" | "note";
682
+ /** user: plain text. assistant: markdown (or plain with `markdown: false`). note: the line. */
683
+ text?: string;
684
+ markdown?: boolean;
685
+ /** A turn in flight: the heartbeat until text arrives; with text, the cursor after it. */
686
+ pending?: boolean;
687
+ steps?: readonly ChatStep[];
688
+ /** The turn failed: the message. `fix` turns it into "what to do + the command". `bridge`: it failed because of the
689
+ * bridge, so it is one `BridgeNotice` line whose action opens the picker's Setup. */
690
+ error?: ReactNode;
691
+ fix?: ChatFix | null;
692
+ bridge?: boolean;
693
+ warn?: ReactNode;
694
+ stopped?: boolean;
695
+ /** Under a finished answer, after Copy: engine, seconds, model, cost. */
696
+ meta?: ReactNode[];
697
+ /** A note's look: a quiet centred line (default), or a BridgeNotice line (warn / down) with `action`. */
698
+ tone?: "muted" | "warn" | "down";
699
+ action?: { label?: string; onClick: () => void };
700
+ lang?: string;
701
+ }
702
+
703
+ export interface AssistantChatProps extends Omit<ChatMarkdownProps, "text"> {
704
+ messages: readonly ChatMessageData[];
705
+ busy?: boolean;
706
+ onSend: (text: string) => void;
707
+ onStop?: () => void;
708
+ sendWhileBusy?: boolean;
709
+ /** The draft, controlled. Omit both and the chat keeps it (and clears it after a send). */
710
+ draft?: string;
711
+ onDraftChange?: (v: string) => void;
712
+ placeholder?: string;
713
+ inputLabel?: string;
714
+ /** The empty state: an assistant bubble with what to ask, then the starters. */
715
+ intro?: ReactNode;
716
+ starters?: readonly ChatStarter[];
717
+ startersTitle?: ReactNode;
718
+ /** More starter groups under the first ("Or ask about partners in the radar:"). */
719
+ moreStarters?: readonly ChatStarter[];
720
+ moreStartersTitle?: ReactNode;
721
+ /** The engine picker element (and any other chip) for the composer's chip strip. */
722
+ engine?: ReactNode;
723
+ /** Tools beside the chips (`ChatAttach`). */
724
+ tools?: ReactNode;
725
+ composerTop?: ReactNode;
726
+ composerStatus?: ReactNode;
727
+ maxLength?: number;
728
+ /** The heartbeat's verbs ("Reading the board", "Answering"). */
729
+ pendingVerbs?: readonly string[];
730
+ /** A BridgeNotice's action ("Open setup" → the picker's Setup tab). */
731
+ onOpenSetup?: () => void;
732
+ /** Surface content under an answer (a proposal card, an action card). */
733
+ renderExtra?: (m: ChatMessageData, index: number) => ReactNode;
734
+ labels?: Partial<ChatLabels>;
735
+ /** The chat's own heading, shown where the frame has no title bar: full screen, or a page that is not in a frame. In
736
+ * the popup and the side panel the frame's header says it already (the admin's "Ask the Operator"). */
737
+ title?: ReactNode;
738
+ subtitle?: ReactNode;
739
+ threadRef?: Ref<HTMLDivElement>;
740
+ inputRef?: RefObject<HTMLTextAreaElement | null>;
741
+ onComposerKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
742
+ onComposerPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
743
+ className?: string;
744
+ }
745
+
746
+ /**
747
+ * The whole chat: the thread (empty state, messages) over the composer, filling the frame's body. Put it inside
748
+ * `AssistantHost` (and `AssistantWorkspaceLayout` for the 820px reading column at full screen).
749
+ */
750
+ export function AssistantChat(p: AssistantChatProps) {
751
+ const L = useMemo(() => ({ ...CHAT_EN, ...p.labels }), [p.labels]);
752
+ const [ownDraft, setOwnDraft] = useState("");
753
+ const controlled = p.draft !== undefined;
754
+ const draft = controlled ? (p.draft as string) : ownDraft;
755
+ const setDraft = (v: string) => { if (!controlled) setOwnDraft(v); p.onDraftChange?.(v); };
756
+ const ownInput = useRef<HTMLTextAreaElement | null>(null);
757
+ const inputRef = p.inputRef ?? ownInput;
758
+ const md: ChatMarkdownProps = { text: "", onNavigate: p.onNavigate, imageHosts: p.imageHosts, ownLink: p.ownLink, renderLink: p.renderLink };
759
+ // Back to the end on a send, and when another conversation is shown (its first message is another one, or it is empty).
760
+ const [sent, setSent] = useState(0);
761
+ const pinKey = `${sent}:${p.messages[0]?.id ?? ""}:${p.messages.length === 0}`;
762
+ const send = (text: string) => { p.onSend(text); setSent((n) => n + 1); if (!controlled) setOwnDraft(""); };
763
+ const rootRef = useRef<HTMLDivElement>(null);
764
+ useFullChromeRoom(rootRef);
765
+ const pick = (s: ChatStarter) => {
766
+ if (s.fill) { setDraft(s.prompt ?? s.label); inputRef.current?.focus(); return; }
767
+ p.onSend(s.prompt ?? s.label);
768
+ setSent((n) => n + 1);
769
+ };
770
+ return (
771
+ <LabelsContext.Provider value={L}>
772
+ <div ref={rootRef} className={cx("gb-chat", p.className)}>
773
+ {p.title ? (
774
+ <div className="gemboss-ec-head gb-chat-head">
775
+ <div className="gemboss-ec-title">{p.title}</div>
776
+ {p.subtitle ? <div className="gemboss-ec-sub">{p.subtitle}</div> : null}
777
+ </div>
778
+ ) : null}
779
+ <ChatThread busy={p.busy} scrollRef={p.threadRef} pinKey={pinKey}>
780
+ {p.messages.length === 0 && (p.intro || p.starters?.length) ? (
781
+ <ChatMessage role="assistant">
782
+ {p.intro ? <ChatBubble>{p.intro}</ChatBubble> : null}
783
+ {p.starters?.length ? <ChatStarters items={p.starters} onPick={pick} title={p.startersTitle} /> : null}
784
+ {p.moreStarters?.length ? <ChatStarters items={p.moreStarters} onPick={pick} title={p.moreStartersTitle} /> : null}
785
+ </ChatMessage>
786
+ ) : null}
787
+ {p.messages.map((m, i) => {
788
+ // An id when there is one, the position otherwise, never mixed in one space (id 2 and the third id-less message).
789
+ const key = m.id !== undefined && m.id !== null ? `id:${m.id}` : `at:${i}`;
790
+ if (m.role === "note") {
791
+ return m.tone === "warn" || m.tone === "down"
792
+ ? <BridgeNotice key={key} tone={m.tone} action={m.action?.label} onOpenSetup={m.action?.onClick}>{m.text}</BridgeNotice>
793
+ : <ChatNote key={key}>{m.text}</ChatNote>;
794
+ }
795
+ if (m.role === "user") {
796
+ return <ChatMessage key={key} role="user" lang={m.lang}><ChatBubble>{m.text}</ChatBubble></ChatMessage>;
797
+ }
798
+ const hasText = Boolean(m.text);
799
+ return (
800
+ <ChatMessage key={key} role="assistant" lang={m.lang}>
801
+ {m.steps?.length ? <ChatSteps steps={m.steps} /> : null}
802
+ {hasText ? (
803
+ <ChatBubble streaming={m.pending}>
804
+ {m.markdown === false ? m.text : <ChatMarkdown {...md} text={m.text as string} />}
805
+ </ChatBubble>
806
+ ) : m.pending ? <ChatPending verbs={p.pendingVerbs} /> : null}
807
+ {p.renderExtra?.(m, i)}
808
+ {m.warn ? <p className="ui-notice ui-notice--warning gb-chat-notice">{m.warn}</p> : null}
809
+ {m.error ? (m.bridge
810
+ ? <BridgeNotice onOpenSetup={p.onOpenSetup}>{m.error}</BridgeNotice>
811
+ : <ChatError fix={m.fix}>{m.error}</ChatError>) : null}
812
+ {m.stopped ? <ChatNote>{L.stopped}</ChatNote> : null}
813
+ {!m.pending && hasText ? <ChatMeta copyText={m.text} items={m.meta} /> : null}
814
+ </ChatMessage>
815
+ );
816
+ })}
817
+ </ChatThread>
818
+ <ChatComposer
819
+ value={draft} onChange={setDraft} onSend={send}
820
+ busy={p.busy} onStop={p.onStop} sendWhileBusy={p.sendWhileBusy}
821
+ placeholder={p.placeholder} label={p.inputLabel} maxLength={p.maxLength}
822
+ leading={p.engine} tools={p.tools} top={p.composerTop} status={p.composerStatus}
823
+ textareaRef={inputRef} onKeyDown={p.onComposerKeyDown} onPaste={p.onComposerPaste}
824
+ />
825
+ </div>
826
+ </LabelsContext.Provider>
827
+ );
828
+ }
829
+
830
+ /**
831
+ * Full screen: the frame floats its buttons (Back to the popup, the surface's `fullActions`) over the top right of its
832
+ * body. The admin's chat has a header whose right half is empty for them; a chat without one had its first message under
833
+ * them (content-radar /v2/assistant, 1280). How far they reach into THIS element is measured (the frame cannot know where
834
+ * a child's 820px column sits: at 1920 it does not reach it at all) and written as `--gb-chat-chrome-w` / `-h` with
835
+ * `data-chrome` on the element; nothing when they do not overlap. Re-measured when the element, the frame or the buttons
836
+ * themselves change size (an action added, fonts loaded). `AssistantChat` uses it; a surface arranging the pieces itself
837
+ * calls it on its own column and pads by the variables.
838
+ */
839
+ export function useFullChromeRoom(ref: RefObject<HTMLElement | null>) {
840
+ useEffect(() => {
841
+ const root = ref.current;
842
+ const dock = root?.closest(".gb-dock");
843
+ if (!root || !dock || typeof ResizeObserver === "undefined") return undefined;
844
+ let watched: Element | null = null;
845
+ const clear = () => { root.removeAttribute("data-chrome"); root.style.removeProperty("--gb-chat-chrome-w"); root.style.removeProperty("--gb-chat-chrome-h"); };
846
+ const ro = new ResizeObserver(() => measure());
847
+ function measure() {
848
+ const chrome = dock!.querySelector<HTMLElement>(":scope > [data-gb-dock-chrome]");
849
+ if (chrome !== watched) { if (watched) ro.unobserve(watched); if (chrome) ro.observe(chrome); watched = chrome; }
850
+ if (!chrome || getComputedStyle(chrome).position !== "absolute") return clear();
851
+ const c = chrome.getBoundingClientRect(), r = root!.getBoundingClientRect();
852
+ if (c.left >= r.right || c.bottom <= r.top || c.right <= r.left) return clear();
853
+ root!.style.setProperty("--gb-chat-chrome-w", `${Math.ceil(r.right - c.left + 8)}px`);
854
+ root!.style.setProperty("--gb-chat-chrome-h", `${Math.ceil(c.bottom - r.top + 8)}px`);
855
+ root!.setAttribute("data-chrome", "");
856
+ }
857
+ measure();
858
+ ro.observe(root);
859
+ ro.observe(dock);
860
+ // The buttons come and go with the mode (full ⇄ popup), and an action can be added inside them.
861
+ const mo = new MutationObserver(measure);
862
+ mo.observe(dock, { childList: true, attributes: true, attributeFilter: ["class"] });
863
+ return () => { ro.disconnect(); mo.disconnect(); };
864
+ }, [ref]);
865
+ }
866
+
867
+ /** The labels for pieces used outside `AssistantChat` (a surface arranging them itself). */
868
+ export function ChatLabelsProvider({ labels, children }: { labels?: Partial<ChatLabels>; children?: ReactNode }) {
869
+ const v = useMemo(() => ({ ...CHAT_EN, ...labels }), [labels]);
870
+ return <LabelsContext.Provider value={v}>{children}</LabelsContext.Provider>;
871
+ }