@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.
- package/README.md +208 -0
- package/catalog/index.html +423 -0
- package/gemboss-ui.css +2889 -0
- package/package.json +51 -0
- package/src/GemMark.tsx +22 -0
- package/src/account.tsx +88 -0
- package/src/app-shell.css +65 -0
- package/src/assistant/dock.ts +142 -0
- package/src/assistant/host.tsx +371 -0
- package/src/assistant/index.ts +6 -0
- package/src/assistant/provider.tsx +269 -0
- package/src/assistant/sessions.tsx +450 -0
- package/src/assistant.css +407 -0
- package/src/auth.css +606 -0
- package/src/auth.tsx +202 -0
- package/src/brand.tsx +31 -0
- package/src/gemboss-field.css +108 -0
- package/src/gemboss-tokens.css +164 -0
- package/src/icons.tsx +75 -0
- package/src/index.ts +9 -0
- package/src/shell.css +1069 -0
- package/src/shell.tsx +212 -0
- package/src/ui.css +403 -0
|
@@ -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
|
+
}
|