@gemboss/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The assistant's popup / status bar / full-screen host — @gemboss/ui (28/09/2026).
5
+ *
6
+ * Moved from gemboss apps/admin/src/components/assistant/AssistantDockHost.tsx; the admin now renders
7
+ * its assistant through this, and gemwatcher's assistant wears the same one. What the admin had that
8
+ * nobody else has — the gemcare "Talk to a person" tab, the shop lookup, its router — comes in through
9
+ * props and slots. The chat itself is `children`.
10
+ *
11
+ * Mount it ONCE in the app shell, inside <AssistantProvider>, outside every route: a route-owned
12
+ * assistant is unmounted by the next menu click, and unmounting aborts the run. Geometry only changes
13
+ * class names; the subtree is never re-parented (that would remount the chat and abort the run too).
14
+ */
15
+ import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
16
+
17
+ import { DOCK_MIN_HEIGHT, clampDockHeight } from "./dock";
18
+ import { useAssistant } from "./provider";
19
+
20
+ /** One literal class per presentation, not `gb-dock-${mode}`: a runtime-built class name is a stem the
21
+ * admin's orphan gate (a-tap-target-rule-outlived-its-markup) has to exempt wholesale. */
22
+ const MODE_CLASS = { hidden: "gb-dock-hidden", mini: "gb-dock-mini", dock: "gb-dock-dock", full: "gb-dock-full" } as const;
23
+
24
+ /** mm:ss since a run started — the only honest thing the collapsed bar can say. */
25
+ function elapsed(since: number): string {
26
+ const s = Math.max(0, Math.floor((Date.now() - since) / 1000));
27
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
28
+ }
29
+
30
+ /** Every word the host says. English defaults are the admin's; a surface passes its own language. */
31
+ export interface AssistantHostLabels {
32
+ ready: string; working: string; done: string;
33
+ answerWaiting: string; askAboutScreen: string;
34
+ reopen: string; hide: string; hideTitle: string; openInstead: string; openInsteadTitle: string;
35
+ grip: string; headerTitle: string;
36
+ newChat: string; openFull: string; minimize: string; minimizeTitle: string;
37
+ close: string; closeBusy: string; closeBusyTitle: string;
38
+ shrinkToPopup: string; tablist: string; primaryTab: string;
39
+ }
40
+ const EN: AssistantHostLabels = {
41
+ ready: "Ready", working: "Working", done: "Done — tap to see it",
42
+ answerWaiting: "An answer is waiting", askAboutScreen: "Ask about this screen",
43
+ reopen: "Reopen the {title}", hide: "Hide the assistant", hideTitle: "Hide the assistant",
44
+ openInstead: "Open the assistant", openInsteadTitle: "Still working — open it",
45
+ grip: "Drag to resize the assistant; left and right arrows move it sideways",
46
+ headerTitle: "Drag this bar to move the popup left or right",
47
+ newChat: "New chat", openFull: "Open full screen", minimize: "Minimise", minimizeTitle: "Shrink to a bar",
48
+ close: "Close the assistant", closeBusy: "Shrink to a bar", closeBusyTitle: "Still working — shrink it to a bar",
49
+ shrinkToPopup: "Shrink to popup", tablist: "Assistant or a person", primaryTab: "Assistant",
50
+ };
51
+
52
+ /** An optional second tab in the popup (the admin: a person on the Gem team, through gemcare). */
53
+ export interface AssistantSecondTab {
54
+ label: ReactNode;
55
+ /** Unread count shown on the tab while it is not the one open. */
56
+ badge?: number;
57
+ /** Mounted once first opened, then kept mounted (hidden) so it keeps counting replies. */
58
+ content: ReactNode | null;
59
+ }
60
+
61
+ export function AssistantHost({
62
+ title, labels, onNewChat, onExpand, onShrink, fullActions, secondTab, view = "primary", onViewChange,
63
+ miniHint, children,
64
+ }: {
65
+ /** "Gem assistant" in the admin. Names the region, the popup header and the status bar. */
66
+ title: string;
67
+ labels?: Partial<AssistantHostLabels>;
68
+ /** The popup header's +. Omit it and the button is not drawn. */
69
+ onNewChat?: () => void;
70
+ /** Popup → full screen. The surface decides what that means (the admin navigates to its page). */
71
+ onExpand: () => void;
72
+ /** Full screen → popup, e.g. open the dock and go back to the screen they came from. */
73
+ onShrink: () => void;
74
+ /** Extra buttons in the full-screen chrome, before "Shrink to popup". Give them `gb-dock-shrink`. */
75
+ fullActions?: ReactNode;
76
+ secondTab?: AssistantSecondTab;
77
+ /** Which tab the popup shows. Owned by the surface, because its full-screen actions may switch it. */
78
+ view?: "primary" | "secondary";
79
+ onViewChange?: (view: "primary" | "secondary") => void;
80
+ /** What the status bar says when idle and nothing is waiting, before the default "Ask about this screen". */
81
+ miniHint?: string;
82
+ children?: ReactNode;
83
+ }) {
84
+ const dock = useAssistant();
85
+ const L = { ...EN, ...labels };
86
+ const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
87
+ const [, setTick] = useState(0);
88
+ const shellRef = useRef<HTMLDivElement>(null);
89
+
90
+ useEffect(() => { setRunStartedAt(dock.busy ? Date.now() : null); }, [dock.busy]);
91
+
92
+ // Keep the collapsed bar's clock moving while a run is live (1s, only while collapsed).
93
+ useEffect(() => {
94
+ if (!runStartedAt || dock.presentation !== "mini") return;
95
+ const id = window.setInterval(() => setTick((n) => n + 1), 1000);
96
+ return () => window.clearInterval(id);
97
+ }, [runStartedAt, dock.presentation]);
98
+
99
+ // Hidden means "still running, just not on screen": the subtree stays in the DOM (that is what
100
+ // keeps the stream alive) but must not be reachable by Tab or a screen reader.
101
+ useEffect(() => {
102
+ const el = shellRef.current;
103
+ if (!el) return;
104
+ if (dock.presentation === "hidden") el.setAttribute("inert", "");
105
+ else el.removeAttribute("inert");
106
+ }, [dock.presentation]);
107
+
108
+ // ── Height drag (dock only) ───────────────────────────────────────────────
109
+ // Dragging the top edge grows the popup UPWARD, so the composer under the cursor never moves.
110
+ const dragRef = useRef<{ startY: number; startH: number } | null>(null);
111
+ const onHandleDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
112
+ e.preventDefault();
113
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
114
+ setDragging(true);
115
+ dragRef.current = { startY: e.clientY, startH: dock.dockHeight };
116
+ }, [dock.dockHeight]);
117
+ const onHandleMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
118
+ const drag = dragRef.current;
119
+ if (!drag) return;
120
+ dock.setDockHeight(drag.startH + (drag.startY - e.clientY));
121
+ }, [dock]);
122
+ const onHandleUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
123
+ dragRef.current = null;
124
+ setDragging(false);
125
+ try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ }
126
+ }, []);
127
+ // ── Slide sideways (dock only) ───────────────────────────────────────────
128
+ /**
129
+ * Drag the header to move the popup along the bottom edge.
130
+ *
131
+ * Sideways only, and still glued to the bottom, on purpose: the popup GROWS UPWARD when resized,
132
+ * so a free-floating window would fight its own resize handle, and one that leaves the bottom
133
+ * covers more of the page rather than less. Chris asked for "move được từ trái sang phải"; this is
134
+ * exactly that and nothing more.
135
+ */
136
+ const slideRef = useRef<{ startX: number; startOffset: number; moved: boolean } | null>(null);
137
+ /**
138
+ * True while the seller is dragging the edge or the header.
139
+ *
140
+ * The popup animates when it APPEARS. It must not animate while a finger is on it: a transition on
141
+ * a value the pointer is setting sixty times a second is exactly what "mượt" turns into "lag" — the
142
+ * box trails the cursor and every frame fights the last one.
143
+ */
144
+ const [dragging, setDragging] = useState(false);
145
+ /** How far the popup may travel: never under the rail, never past the right edge. */
146
+ const slideBounds = useCallback(() => {
147
+ const host = shellRef.current;
148
+ // Round UP: a fractional width rounded down leaves the popup a pixel or two over the line, which
149
+ // is exactly how the first version of this ended up 3px on top of the rail (measured, 1440px).
150
+ const width = Math.ceil(host?.getBoundingClientRect().width ?? 600);
151
+ // Both limits from ONE rect — `main`, the content area the popup floats over.
152
+ //
153
+ // The first version mixed sources: `window.innerWidth` (and `clientWidth`, which reported the
154
+ // same) for the right edge and `main.left` for the left. Both said 1440 while the real layout
155
+ // viewport was 1429, so full-left landed ~11px on top of the rail. Measuring the travel INSIDE
156
+ // one box cannot disagree with itself: `main.right` is where an offset of 0 would sit, and
157
+ // `main.left` is where the rail ends.
158
+ const main = document.querySelector(".gemboss-platform-main")?.getBoundingClientRect();
159
+ const track = main ? main.width : (document.documentElement.clientWidth || window.innerWidth);
160
+ return { min: 8, max: Math.max(8, Math.floor(track - width - 8)) };
161
+ }, []);
162
+ const onHeaderDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
163
+ // Buttons in the header keep their clicks; only the bare strip drags.
164
+ if ((e.target as HTMLElement).closest("button")) return;
165
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
166
+ setDragging(true);
167
+ slideRef.current = { startX: e.clientX, startOffset: dock.dockOffsetRight, moved: false };
168
+ }, [dock.dockOffsetRight]);
169
+ const onHeaderMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
170
+ const drag = slideRef.current;
171
+ if (!drag) return;
172
+ // A few pixels of slop, so a click that wobbles is still a click.
173
+ if (!drag.moved && Math.abs(e.clientX - drag.startX) < 4) return;
174
+ drag.moved = true;
175
+ const { min, max } = slideBounds();
176
+ dock.setDockOffsetRight(drag.startOffset - (e.clientX - drag.startX), min, max);
177
+ }, [dock, slideBounds]);
178
+ const onHeaderUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
179
+ slideRef.current = null;
180
+ setDragging(false);
181
+ try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ }
182
+ }, []);
183
+
184
+ // A drag handle nobody can reach with a keyboard is a control only half the sellers have.
185
+ const onHandleKey = useCallback((e: React.KeyboardEvent) => {
186
+ const step = e.shiftKey ? 96 : 32;
187
+ const max = clampDockHeight(Number.MAX_SAFE_INTEGER, window.innerHeight);
188
+ if (e.key === "ArrowUp") { e.preventDefault(); dock.setDockHeight(dock.dockHeight + step); }
189
+ else if (e.key === "ArrowDown") { e.preventDefault(); dock.setDockHeight(dock.dockHeight - step); }
190
+ // Sideways from the same handle, so moving the popup is not mouse-only.
191
+ else if (e.key === "ArrowLeft") { const { min, max } = slideBounds(); e.preventDefault(); dock.setDockOffsetRight(dock.dockOffsetRight + step, min, max); }
192
+ else if (e.key === "ArrowRight") { const { min, max } = slideBounds(); e.preventDefault(); dock.setDockOffsetRight(dock.dockOffsetRight - step, min, max); }
193
+ else if (e.key === "Home") { e.preventDefault(); dock.setDockHeight(max); }
194
+ else if (e.key === "End") { e.preventDefault(); dock.setDockHeight(DOCK_MIN_HEIGHT); }
195
+ }, [dock, slideBounds]);
196
+
197
+ if (!dock.mounted) return null;
198
+
199
+ const mode = dock.presentation;
200
+ const isDock = mode === "dock";
201
+ // Announced on the grip. Read at render (not in the key handler) so assistive tech is told the
202
+ // real ceiling for this window, which changes when the window does.
203
+ const maxDockHeight = clampDockHeight(Number.MAX_SAFE_INTEGER, typeof window === "undefined" ? 900 : window.innerHeight);
204
+ const statusText = dock.busy ? L.working : dock.unread ? L.done : L.ready;
205
+ // Full screen is the assistant's own page and has no tab strip; the second tab lives in the popup.
206
+ const secondVisible = isDock && !!secondTab && view === "secondary";
207
+ const secondBadge = secondTab?.badge || 0;
208
+
209
+ return (
210
+ <div
211
+ ref={shellRef}
212
+ className={`gb-dock ${MODE_CLASS[mode]}${dragging ? " gb-dock-dragging" : ""}`}
213
+ style={isDock ? { height: dock.dockHeight, right: dock.dockOffsetRight } : undefined}
214
+ role={mode === "hidden" ? undefined : "region"}
215
+ aria-label={mode === "hidden" ? undefined : title}
216
+ >
217
+ {mode === "full" ? (
218
+ <div className="gb-dock-fullchrome">
219
+ {fullActions}
220
+ <button type="button" className="gb-dock-shrink" onClick={onShrink}>
221
+ {/* Two arrows pulled INTO the centre. The first attempt drew two corner brackets, which at 16px
222
+ reads as a plus sign — the opposite of "make this smaller". */}
223
+ <svg viewBox="0 0 20 20" aria-hidden>
224
+ <path d="M16 4l-5 5M11 9h3.5M11 9V5.5M4 16l5-5M9 11H5.5M9 11v3.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
225
+ </svg>
226
+ <span>{L.shrinkToPopup}</span>
227
+ </button>
228
+ </div>
229
+ ) : null}
230
+
231
+ {mode === "mini" ? (
232
+ <div className="gb-dock-minirow">
233
+ <button type="button" className="gb-dock-minibar" onClick={dock.openDock} aria-label={L.reopen.replace("{title}", title)}>
234
+ <span className={`gb-dock-dot${dock.busy ? " gb-dock-dot-busy" : dock.unread ? " gb-dock-dot-unread" : ""}`} aria-hidden />
235
+ <strong>{title}</strong>
236
+ <span className="gb-dock-ministatus">
237
+ {dock.busy && runStartedAt ? `${L.working} · ${elapsed(runStartedAt)}` : dock.unread ? L.answerWaiting : miniHint || L.askAboutScreen}
238
+ </span>
239
+ <span className="gb-dock-minichevron" aria-hidden>▲</span>
240
+ </button>
241
+ {/* A bar that follows you onto every screen has to be closable from the bar itself. While a
242
+ run is live `dismiss` opens it instead — see ./dock. */}
243
+ <button
244
+ type="button"
245
+ className="gb-dock-icon"
246
+ onClick={dock.dismiss}
247
+ title={dock.busy ? L.openInsteadTitle : L.hideTitle}
248
+ >
249
+ <svg viewBox="0 0 20 20" aria-hidden><path d="M6 6l8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>
250
+ {/* The NAME follows the behaviour: while a run is live this button opens the popup rather
251
+ than hiding it, and `title` is not the accessible name. */}
252
+ <span className="gb-dock-sr">{dock.busy ? L.openInstead : L.hide}</span>
253
+ </button>
254
+ </div>
255
+ ) : null}
256
+
257
+ {isDock ? (
258
+ <>
259
+ <div
260
+ className="gb-dock-grip"
261
+ role="separator"
262
+ aria-orientation="horizontal"
263
+ aria-label={L.grip}
264
+ aria-valuenow={dock.dockHeight}
265
+ aria-valuemin={DOCK_MIN_HEIGHT}
266
+ aria-valuemax={maxDockHeight}
267
+ tabIndex={0}
268
+ onPointerDown={onHandleDown}
269
+ onPointerMove={onHandleMove}
270
+ onPointerUp={onHandleUp}
271
+ onPointerCancel={onHandleUp}
272
+ onKeyDown={onHandleKey}
273
+ >
274
+ <span aria-hidden />
275
+ </div>
276
+ <header
277
+ className="gb-dock-header"
278
+ onPointerDown={onHeaderDown}
279
+ onPointerMove={onHeaderMove}
280
+ onPointerUp={onHeaderUp}
281
+ onPointerCancel={onHeaderUp}
282
+ title={L.headerTitle}
283
+ >
284
+ <span className={`gb-dock-dot${dock.busy ? " gb-dock-dot-busy" : ""}`} aria-hidden />
285
+ <span className="gb-dock-title">{title}</span>
286
+ <span className="gb-dock-status">{statusText}</span>
287
+ <div className="gb-dock-actions">
288
+ {onNewChat ? (
289
+ <button
290
+ type="button"
291
+ className="gb-dock-icon gb-dock-action"
292
+ title={L.newChat}
293
+ onClick={onNewChat}
294
+ >
295
+ <svg viewBox="0 0 20 20" aria-hidden><path d="M10 4.5v11M4.5 10h11" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>
296
+ <span className="gb-dock-sr">{L.newChat}</span>
297
+ </button>
298
+ ) : null}
299
+ <button
300
+ type="button"
301
+ className="gb-dock-icon"
302
+ title={L.openFull}
303
+ onClick={onExpand}
304
+ >
305
+ {/* The exact mirror of "Shrink to popup": same top-right ↔ bottom-left axis, arrows
306
+ pointing OUT instead of in. */}
307
+ <svg viewBox="0 0 20 20" aria-hidden>
308
+ <path d="M11 9l5-5M16 4h-3.5M16 4v3.5M9 11l-5 5M4 16h3.5M4 16v-3.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
309
+ </svg>
310
+ <span className="gb-dock-sr">{L.openFull}</span>
311
+ </button>
312
+ <button type="button" className="gb-dock-icon" title={L.minimizeTitle} onClick={dock.minimize}>
313
+ <svg viewBox="0 0 20 20" aria-hidden><path d="M5 13h10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>
314
+ <span className="gb-dock-sr">{L.minimize}</span>
315
+ </button>
316
+ <button
317
+ type="button"
318
+ className="gb-dock-icon"
319
+ title={dock.busy ? L.closeBusyTitle : L.close}
320
+ onClick={dock.dismiss}
321
+ >
322
+ <svg viewBox="0 0 20 20" aria-hidden><path d="M6 6l8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>
323
+ <span className="gb-dock-sr">{dock.busy ? L.closeBusy : L.close}</span>
324
+ </button>
325
+ </div>
326
+ </header>
327
+ {secondTab ? (
328
+ <div className="gb-dock-tabs" role="tablist" aria-label={L.tablist}>
329
+ <button
330
+ type="button"
331
+ role="tab"
332
+ id="gem-dock-tab-assistant"
333
+ aria-controls="gem-dock-pane-assistant"
334
+ aria-selected={view === "primary"}
335
+ className="gb-dock-tab"
336
+ onClick={() => onViewChange?.("primary")}
337
+ >
338
+ {L.primaryTab}
339
+ </button>
340
+ <button
341
+ type="button"
342
+ role="tab"
343
+ id="gem-dock-tab-support"
344
+ aria-controls="gem-dock-pane-support"
345
+ aria-selected={view === "secondary"}
346
+ className="gb-dock-tab"
347
+ onClick={() => onViewChange?.("secondary")}
348
+ >
349
+ {secondTab.label}
350
+ {secondBadge && view !== "secondary" ? (
351
+ <span className="gb-dock-badge" aria-label={`${secondBadge} unread`}>{secondBadge}</span>
352
+ ) : null}
353
+ </button>
354
+ </div>
355
+ ) : null}
356
+ </>
357
+ ) : null}
358
+
359
+ <div className="gb-dock-body">
360
+ <div className="gb-dock-pane" id="gem-dock-pane-assistant" role={isDock && secondTab ? "tabpanel" : undefined} aria-labelledby={isDock && secondTab ? "gem-dock-tab-assistant" : undefined} hidden={secondVisible}>
361
+ {children}
362
+ </div>
363
+ {secondTab?.content ? (
364
+ <div className="gb-dock-pane" id="gem-dock-pane-support" role="tabpanel" aria-labelledby="gem-dock-tab-support" hidden={!secondVisible}>
365
+ {secondTab.content}
366
+ </div>
367
+ ) : null}
368
+ </div>
369
+ </div>
370
+ );
371
+ }
@@ -0,0 +1,6 @@
1
+ // The assistant's frame: popup, status bar, full screen — and the rules for when each shows.
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";
4
+ export { AssistantProvider, useAssistant, useAssistantPageFacts, type AssistantDockApi, type AssistantOptions } from "./provider";
5
+ export * from "./dock";
6
+ export { AssistantWorkspaceLayout, OpenChatTabs, SessionRail, SESSION_RAIL_EN, groupSessions, normalizeForSearch, relativeTime, type AssistantSession, type SessionGroup, type SessionRailIcons, type SessionRailLabels } from "./sessions";
@@ -0,0 +1,269 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WHERE the assistant is drawn, held above the routes so switching screens cannot unmount it —
5
+ * @gemboss/ui (28/09/2026), moved from gemboss apps/admin/src/contexts/AssistantDockContext.tsx.
6
+ *
7
+ * The admin's names are the defaults. A surface with its own routing passes `options`:
8
+ * - `storagePrefix` — where the popup's height, offset and last shape are remembered
9
+ * - `isFullLink` / `isOnFullPage` — which link opens the assistant full screen, and whether that is
10
+ * the screen we are on (the admin: the path `/app/assistant`; a hash-routed SPA: `#/assistant`)
11
+ * - `dockedEvent` — announced when a rail click opened the popup instead of the full screen, so the
12
+ * shell can close its mobile drawer
13
+ * - `bodyClassPrefix` — `<prefix>-busy` / `<prefix>-unread` on <body>, which the rail item reads
14
+ */
15
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
16
+ import {
17
+ assistantDockReducer,
18
+ initialAssistantDockState,
19
+ DOCK_DEFAULT_HEIGHT,
20
+ DOCK_DEFAULT_OFFSET_RIGHT,
21
+ type AssistantDockState,
22
+ } from "./dock";
23
+
24
+ export interface AssistantOptions {
25
+ storagePrefix?: string;
26
+ isFullLink?: (url: URL) => boolean;
27
+ isOnFullPage?: () => boolean;
28
+ dockedEvent?: string;
29
+ bodyClassPrefix?: string;
30
+ }
31
+
32
+ /** Storage keys. `lastMode` is the last shape the seller actually used: "full" or "dock" — nothing
33
+ * else is worth reopening as. Module-level so the callbacks below stay identity-stable. */
34
+ const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode") => `${prefix}.${k}`;
35
+
36
+ const ADMIN_FULL_PATH = "/app/assistant";
37
+ const DEFAULTS: Required<AssistantOptions> = {
38
+ storagePrefix: "gemboss.assistant",
39
+ isFullLink: (url) => url.pathname === ADMIN_FULL_PATH,
40
+ isOnFullPage: () => window.location.pathname.startsWith(ADMIN_FULL_PATH),
41
+ dockedEvent: "gemboss:assistant-docked",
42
+ bodyClassPrefix: "gemboss-assistant",
43
+ };
44
+
45
+ export interface AssistantDockApi extends AssistantDockState {
46
+ /** Specifics the CURRENT screen registered about itself, for the assistant to read. */
47
+ pageFacts: string[];
48
+ /** Screens call this through `useAssistantPageFacts`; the last screen to mount wins. */
49
+ publishPageFacts: (owner: string, facts: string[]) => void;
50
+ retractPageFacts: (owner: string) => void;
51
+ /** The /app/assistant route claims full mode on mount and releases it on unmount. */
52
+ enterFull: () => void;
53
+ leaveFull: () => void;
54
+ openDock: () => void;
55
+ minimize: () => void;
56
+ dismiss: () => void;
57
+ setBusy: (busy: boolean) => void;
58
+ setDockHeight: (height: number) => void;
59
+ /** Slide the popup along the bottom. `min`/`max` come from the caller, which is the only place
60
+ * that knows how wide the popup is and where the rail ends. */
61
+ setDockOffsetRight: (offsetRight: number, min: number, max: number) => void;
62
+ }
63
+
64
+ const Ctx = createContext<AssistantDockApi | null>(null);
65
+
66
+ /**
67
+ * Holds WHERE the assistant is drawn, above the routes, so switching menus cannot unmount it.
68
+ * See `./dock.ts` for the transition rules and why they exist.
69
+ */
70
+ export function AssistantProvider({ options, children }: { options?: AssistantOptions; children: ReactNode }) {
71
+ const [state, dispatch] = useReducer(assistantDockReducer, initialAssistantDockState);
72
+ // Read through a ref: the options object is usually built inline, and the effects below must not
73
+ // re-subscribe (or re-read storage) every render because of it.
74
+ const opts = useRef<Required<AssistantOptions>>({ ...DEFAULTS, ...options });
75
+ opts.current = { ...DEFAULTS, ...options };
76
+
77
+ // Facts about the screen the seller is on, published by that screen. Keyed by owner so a screen
78
+ // unmounting cannot wipe facts the NEXT screen already published — during a route change both are
79
+ // briefly alive, and a blind "clear on unmount" would leave the assistant reading an empty screen.
80
+ const factsOwner = useRef<string>("");
81
+ const [pageFacts, setPageFacts] = useState<string[]>([]);
82
+ const publishPageFacts = useCallback((owner: string, facts: string[]) => {
83
+ factsOwner.current = owner;
84
+ setPageFacts(facts);
85
+ }, []);
86
+ const retractPageFacts = useCallback((owner: string) => {
87
+ if (factsOwner.current !== owner) return;
88
+ factsOwner.current = "";
89
+ setPageFacts([]);
90
+ }, []);
91
+
92
+ // Restore the seller's popup size + pin after mount (localStorage is unavailable during SSR).
93
+ useEffect(() => {
94
+ try {
95
+ const raw = Number(window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockHeight")));
96
+ const height = Number.isFinite(raw) && raw > 0 ? raw : DOCK_DEFAULT_HEIGHT;
97
+ requestedHeight.current = height;
98
+ dispatch({ type: "resize", height, viewport: window.innerHeight });
99
+ // `Number(null)` is 0, not NaN — so reading a key that was never written used to slide the
100
+ // popup flush against the window edge on every fresh browser. Measured: inline `right: 0px`.
101
+ const storedOffset = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "dockOffsetRight"));
102
+ const rawOffset = Number(storedOffset);
103
+ if (storedOffset !== null && storedOffset !== "" && Number.isFinite(rawOffset) && rawOffset >= 0) {
104
+ requestedOffset.current = rawOffset;
105
+ // Clamped against THIS window, not the one it was saved in. A value from a 27" screen would
106
+ // otherwise put the popup on the rail — or off the left edge — on a laptop.
107
+ dispatch({ type: "slide", offsetRight: rawOffset, ...offsetBounds() });
108
+ }
109
+ } catch { /* private mode — defaults are fine */ }
110
+ }, []);
111
+
112
+ /**
113
+ * What the seller ASKED for, which is not what the popup currently measures.
114
+ *
115
+ * The resize listener used to re-dispatch `state.dockHeight` — already clamped — so shrinking the
116
+ * window baked the smaller number in and growing it back never recovered. Measured 2026-09-12:
117
+ * 800 → window 600 → 512 → window 1200 → still 512. Same trap the persisted value already avoids.
118
+ */
119
+ const requestedHeight = useRef(DOCK_DEFAULT_HEIGHT);
120
+ /** Same reasoning as `requestedHeight`: persist what the seller ASKED for, clamp only to draw. */
121
+ const requestedOffset = useRef(DOCK_DEFAULT_OFFSET_RIGHT);
122
+
123
+ /**
124
+ * How far the popup may travel in THIS window.
125
+ *
126
+ * Kept here as well as in the host because the restore and the resize listener both need it before
127
+ * the host exists — and a bound taken from the window that SAVED the value is not a bound at all.
128
+ * Measured from `main`, the content area the popup floats over, so one rect decides both edges.
129
+ */
130
+ const offsetBounds = useCallback(() => {
131
+ if (typeof window === "undefined") return { min: 8, max: 8 };
132
+ const main = document.querySelector(".gemboss-platform-main")?.getBoundingClientRect();
133
+ const track = main ? main.width : (document.documentElement.clientWidth || window.innerWidth);
134
+ // The popup's own width is not knowable here (it may not be mounted); its CSS cap is.
135
+ const popup = Math.min(460, Math.max(0, track - 48));
136
+ return { min: 8, max: Math.max(8, Math.floor(track - popup - 8)) };
137
+ }, []);
138
+
139
+ // A window that got shorter must not leave the popup taller than the screen — and a window that
140
+ // got taller again must give the seller their height back.
141
+ useEffect(() => {
142
+ const onResize = () => {
143
+ dispatch({ type: "resize", height: requestedHeight.current, viewport: window.innerHeight });
144
+ // …and sideways too. `requestedOffset` was being written and never read, so a window that got
145
+ // narrower left the popup where a wider one had allowed it: on the rail, or past the edge.
146
+ dispatch({ type: "slide", offsetRight: requestedOffset.current, ...offsetBounds() });
147
+ };
148
+ window.addEventListener("resize", onResize);
149
+ return () => window.removeEventListener("resize", onResize);
150
+ }, []);
151
+
152
+ const setDockHeight = useCallback((height: number) => {
153
+ const viewport = typeof window === "undefined" ? 900 : window.innerHeight;
154
+ requestedHeight.current = Math.round(height);
155
+ dispatch({ type: "resize", height, viewport });
156
+ // The RAW request, not the clamped result: dragging to the ceiling in a short window used to
157
+ // save that ceiling, so the popup came back permanently smaller on a tall screen.
158
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "dockHeight"), String(Math.round(height))); } catch { /* best-effort */ }
159
+ }, []);
160
+
161
+ const setDockOffsetRight = useCallback((offsetRight: number, min: number, max: number) => {
162
+ requestedOffset.current = Math.round(offsetRight);
163
+ dispatch({ type: "slide", offsetRight, min, max });
164
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "dockOffsetRight"), String(Math.round(offsetRight))); } catch { /* best-effort */ }
165
+ }, []);
166
+
167
+ /**
168
+ * Remember which shape the seller last used, and reopen in it.
169
+ *
170
+ * The rail's Assistant item is a link to /app/assistant, so it always opened FULL — even for
171
+ * someone who had been working in the popup all morning and closed it. Chris: "nếu đang ở popup mà
172
+ * tắt đi, lần sau bấm vào menu assistant thì nó cũng nên là popup".
173
+ *
174
+ * Only `full` and `dock` are recorded. `mini` and `hidden` are ways of putting something away, not
175
+ * ways of working in it; reopening into a collapsed bar would answer a click with almost nothing.
176
+ */
177
+ useEffect(() => {
178
+ if (state.presentation !== "full" && state.presentation !== "dock") return;
179
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "lastMode"), state.presentation); } catch { /* best-effort */ }
180
+ }, [state.presentation]);
181
+
182
+ /**
183
+ * Intercept the rail's Assistant link when the popup is what they last used.
184
+ *
185
+ * Capture phase and on `document`, because the link lives in `IconNav` — a component this provider
186
+ * does not own and should not have to. A plain left-click only: ⌘-click, middle-click and a
187
+ * keyboard-modified click still open the page, because those mean "somewhere else", not "here".
188
+ */
189
+ useEffect(() => {
190
+ const onClick = (e: MouseEvent) => {
191
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
192
+ const anchor = (e.target as HTMLElement | null)?.closest?.("a") as HTMLAnchorElement | null;
193
+ if (!anchor || anchor.target === "_blank") return;
194
+ let url: URL;
195
+ try { url = new URL(anchor.href, window.location.origin); } catch { return; }
196
+ if (!opts.current.isFullLink(url)) return;
197
+ // Already on the page it points at — let the click be the no-op it already is.
198
+ if (opts.current.isOnFullPage()) return;
199
+ let last = "";
200
+ try { last = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "lastMode")) ?? ""; } catch { /* private mode */ }
201
+ if (last !== "dock") return;
202
+ e.preventDefault();
203
+ e.stopPropagation();
204
+ dispatch({ type: "dock" });
205
+ // The shell closes its mobile nav drawer when the ROUTE changes. This click deliberately does
206
+ // not change the route, so on a phone the drawer stayed open on top of the popup it had just
207
+ // opened — measured at 390px, `.gnav-scroll` sitting over the composer. Say it out loud
208
+ // instead of reaching into the shell's state.
209
+ window.dispatchEvent(new CustomEvent(opts.current.dockedEvent));
210
+ };
211
+ document.addEventListener("click", onClick, true);
212
+ return () => document.removeEventListener("click", onClick, true);
213
+ }, []);
214
+
215
+ // The rail's assistant icon shows a working dot without re-rendering IconNav (it is memoized and
216
+ // re-rendering the whole nav on every busy flip would cost more than the dot is worth).
217
+ useEffect(() => {
218
+ const p = opts.current.bodyClassPrefix;
219
+ document.body.classList.toggle(`${p}-busy`, state.busy);
220
+ document.body.classList.toggle(`${p}-unread`, state.unread && !state.busy);
221
+ return () => {
222
+ document.body.classList.remove(`${p}-busy`, `${p}-unread`);
223
+ };
224
+ }, [state.busy, state.unread]);
225
+
226
+ // Every action is identity-STABLE (dispatch is). Children register these as callbacks and one of
227
+ // them — the chat's busy reporter — is read in an unmount cleanup, where a changing identity would
228
+ // fire a spurious "the run ended" on every state change.
229
+ const enterFull = useCallback(() => dispatch({ type: "enter-full" }), []);
230
+ const leaveFull = useCallback(() => dispatch({ type: "leave-full" }), []);
231
+ const openDock = useCallback(() => dispatch({ type: "dock" }), []);
232
+ const minimize = useCallback(() => dispatch({ type: "minimize" }), []);
233
+ const dismiss = useCallback(() => dispatch({ type: "dismiss" }), []);
234
+ const setBusy = useCallback((busy: boolean) => dispatch({ type: "busy", busy }), []);
235
+
236
+ const api = useMemo<AssistantDockApi>(() => ({
237
+ ...state,
238
+ pageFacts, publishPageFacts, retractPageFacts,
239
+ enterFull, leaveFull, openDock, minimize, dismiss, setBusy, setDockHeight, setDockOffsetRight,
240
+ }), [state, pageFacts, publishPageFacts, retractPageFacts, enterFull, leaveFull, openDock, minimize, dismiss, setBusy, setDockHeight, setDockOffsetRight]);
241
+
242
+ return <Ctx.Provider value={api}>{children}</Ctx.Provider>;
243
+ }
244
+
245
+ export function useAssistant(): AssistantDockApi {
246
+ const ctx = useContext(Ctx);
247
+ if (!ctx) throw new Error("useAssistant must be used inside <AssistantProvider>");
248
+ return ctx;
249
+ }
250
+
251
+ /**
252
+ * A screen tells the assistant what is on it.
253
+ *
254
+ * Route-level context ("they are on Plugins") is free and automatic; this is for the part only the
255
+ * screen knows — which plugins are on, which page is open, what step the build is at. Pass a stable
256
+ * `owner` (the screen's name) and plain sentences a person would say.
257
+ *
258
+ * The facts are STRINGS on purpose: they end up in a prompt, and a shape nobody can read at the call
259
+ * site is a shape that drifts into nonsense without anyone noticing.
260
+ */
261
+ export function useAssistantPageFacts(owner: string, facts: string[]): void {
262
+ const { publishPageFacts, retractPageFacts } = useAssistant();
263
+ // Compare by value: callers build this array inline, so a reference check would republish every render.
264
+ const key = facts.join("\u0000");
265
+ useEffect(() => {
266
+ publishPageFacts(owner, key ? key.split("\u0000") : []);
267
+ return () => retractPageFacts(owner);
268
+ }, [owner, key, publishPageFacts, retractPageFacts]);
269
+ }