@gemboss/ui 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "GemBoss design system for every surface in the fleet: the admin shell (topbar + rail + drawer) as React components, and the admin stylesheet exported as one CSS file.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -17,7 +17,11 @@
17
17
  "./gemboss-field.css": "./src/gemboss-field.css",
18
18
  "./app-shell.css": "./src/app-shell.css",
19
19
  "./shell.css": "./src/shell.css",
20
+ "./chat.css": "./src/chat.css",
21
+ "./chat-composer.css": "./src/chat-composer.css",
20
22
  "./auth.css": "./src/auth.css",
23
+ "./kit-plain.css": "./src/kit-plain.css",
24
+ "./kit-plain.js": "./src/kit-plain.js",
21
25
  "./assistant": "./src/assistant/index.ts",
22
26
  "./assistant/dock": "./src/assistant/dock.ts",
23
27
  "./assistant.css": "./src/assistant.css"
@@ -26,6 +30,7 @@
26
30
  "src/**/*.ts",
27
31
  "src/**/*.tsx",
28
32
  "src/*.css",
33
+ "src/kit-plain.js",
29
34
  "!src/**/*.test.*",
30
35
  "gemboss-ui.css",
31
36
  "catalog/index.html",
@@ -16,8 +16,17 @@
16
16
  * being spread over three components' effects.
17
17
  */
18
18
 
19
- /** Where the assistant is drawn. `hidden` still means MOUNTED — the run keeps going. */
20
- export type AssistantPresentation = "hidden" | "mini" | "dock" | "full";
19
+ /**
20
+ * Where the assistant is drawn. `hidden` still means MOUNTED — the run keeps going.
21
+ *
22
+ * `side` (01/10/2026): a full-height panel on the right, under the topbar, that the page makes room for instead of
23
+ * being covered by. Chris asked for one set of shapes across every surface's assistant — "full height right sidebar,
24
+ * popup, full width height" — and content-radar / partner-radar / GemCommunity each built their own right-side panel.
25
+ * A surface opts in (`AssistantHost allowSide`); without it nothing here ever produces `side`.
26
+ */
27
+ export type AssistantPresentation = "hidden" | "mini" | "dock" | "side" | "full";
28
+ /** The two shapes a seller WORKS in on top of a screen. The status bar reopens into whichever they last used. */
29
+ export type AssistantFloatShape = "dock" | "side";
21
30
 
22
31
  export interface AssistantDockState {
23
32
  presentation: AssistantPresentation;
@@ -35,6 +44,21 @@ export interface AssistantDockState {
35
44
  /** Bumped every time the assistant is brought back on screen. Lets a mount that already exists
36
45
  * pick up a question handed over from ⌘K, which a mount-time read would only ever see once. */
37
46
  openNonce: number;
47
+ /** Width of the `side` panel in px (seller-resizable, persisted). */
48
+ sideWidth: number;
49
+ /**
50
+ * The last shape the seller worked in over a screen. The status bar's "reopen", × on a busy bar and leaving full
51
+ * screen mid-run all go back to it — a seller who keeps the assistant pinned to the side must not have it pop back
52
+ * as a floating window every time it comes back.
53
+ */
54
+ floatShape: AssistantFloatShape;
55
+ /**
56
+ * Whether the mounted host offers the side panel (`AssistantHost allowSide`, registered on mount). Every way INTO
57
+ * `side` goes through it — the pin, the status bar, a busy ×, leaving full, a stored "last used" — so a surface
58
+ * that has not laid out for the panel never meets it, whatever is in storage from another surface or an older
59
+ * build. Review of #(this PR): the rail link dispatched a stored "side" straight past the host's opt-in.
60
+ */
61
+ sideAllowed: boolean;
38
62
  }
39
63
 
40
64
  export type AssistantDockEvent =
@@ -48,6 +72,16 @@ export type AssistantDockEvent =
48
72
  | { type: "minimize" }
49
73
  /** the popup's × */
50
74
  | { type: "dismiss" }
75
+ /** pin to the side panel (only surfaces that allow it send this) */
76
+ | { type: "side" }
77
+ /** bring it back in the last working shape (`floatShape`) — the status bar's click */
78
+ | { type: "reopen" }
79
+ /** `available` = the width of the content area the panel sits in (window minus the rail) */
80
+ | { type: "side-resize"; width: number; available: number }
81
+ /** the host says whether it offers the side panel */
82
+ | { type: "side-allowed"; allowed: boolean }
83
+ /** the last working shape, read back from storage on load */
84
+ | { type: "restore-float"; shape: AssistantFloatShape }
51
85
  | { type: "busy"; busy: boolean }
52
86
  | { type: "resize"; height: number; viewport: number }
53
87
  | { type: "slide"; offsetRight: number; min: number; max: number };
@@ -68,6 +102,24 @@ export const DOCK_VIEWPORT_RESERVE = DOCK_TOPBAR_HEIGHT + DOCK_TOPBAR_GAP;
68
102
  /** Matches the CSS `right: 24px` the popup starts at. */
69
103
  export const DOCK_DEFAULT_OFFSET_RIGHT = 24;
70
104
 
105
+ /** The side panel: narrow enough to leave the page usable, wide enough for a chat with a table in it. */
106
+ // 380, not 320 (02/10/2026, measured on admin.gemboss.ai): the header's fixed parts — padding, dot, gaps, five
107
+ // 44px controls — take 280px, so at 320 the title got 40px ("G…") and at 400 with the status word it got 74 of 90.
108
+ // 380 leaves 100px for a title, with the status word standing down in the panel (assistant.css).
109
+ export const SIDE_MIN_WIDTH = 380;
110
+ export const SIDE_DEFAULT_WIDTH = 400;
111
+ export const SIDE_MAX_WIDTH = 720;
112
+ /** The page beside the panel never gets narrower than this — a cap only exists when it is smaller than the room
113
+ * there really is (§9.1), so the ceiling is taken from the content area, not from SIDE_MAX_WIDTH alone. */
114
+ export const SIDE_PAGE_MIN = 480;
115
+
116
+ export function clampSideWidth(width: number, available: number): number {
117
+ const max = Number.isFinite(available) && available > 0
118
+ ? Math.max(SIDE_MIN_WIDTH, Math.min(SIDE_MAX_WIDTH, available - SIDE_PAGE_MIN))
119
+ : SIDE_MAX_WIDTH; // not measured yet (SSR, first paint): the plain cap, never a guessed-narrow one
120
+ return Math.min(Math.max(Math.round(width), SIDE_MIN_WIDTH), max);
121
+ }
122
+
71
123
  /** Keep the popup inside the usable area: never under the rail, never off the right edge. */
72
124
  export function clampDockOffset(offsetRight: number, min: number, max: number): number {
73
125
  if (max < min) return min;
@@ -87,8 +139,16 @@ export const initialAssistantDockState: AssistantDockState = {
87
139
  dockHeight: DOCK_DEFAULT_HEIGHT,
88
140
  dockOffsetRight: DOCK_DEFAULT_OFFSET_RIGHT,
89
141
  openNonce: 0,
142
+ sideWidth: SIDE_DEFAULT_WIDTH,
143
+ floatShape: "dock",
144
+ sideAllowed: false,
90
145
  };
91
146
 
147
+ /** The shape to come back to: the last one worked in, unless this host does not offer the side panel. */
148
+ function backTo(state: AssistantDockState): AssistantFloatShape {
149
+ return state.sideAllowed ? state.floatShape : "dock";
150
+ }
151
+
92
152
  export function assistantDockReducer(state: AssistantDockState, event: AssistantDockEvent): AssistantDockState {
93
153
  switch (event.type) {
94
154
  case "enter-full":
@@ -107,10 +167,25 @@ export function assistantDockReducer(state: AssistantDockState, event: Assistant
107
167
  // There used to be a `pinned` flag here, and a 📌 in the header to set it. Two later changes
108
168
  // ate its job: the bar already follows the seller, and the shell now reopens in whichever
109
169
  // shape they last used. A control that no longer decides anything is worse than no control.
110
- return { ...state, presentation: state.busy ? "dock" : "mini" };
170
+ return { ...state, presentation: state.busy ? backTo(state) : "mini" };
111
171
 
112
172
  case "dock":
113
- return { ...state, presentation: "dock", mounted: true, unread: false, openNonce: state.openNonce + 1 };
173
+ return { ...state, presentation: "dock", floatShape: "dock", mounted: true, unread: false, openNonce: state.openNonce + 1 };
174
+
175
+ case "side":
176
+ if (!state.sideAllowed) return { ...state, presentation: "dock", floatShape: "dock", mounted: true, unread: false, openNonce: state.openNonce + 1 };
177
+ return { ...state, presentation: "side", floatShape: "side", mounted: true, unread: false, openNonce: state.openNonce + 1 };
178
+
179
+ case "reopen":
180
+ return { ...state, presentation: backTo(state), mounted: true, unread: false, openNonce: state.openNonce + 1 };
181
+
182
+ case "side-allowed":
183
+ if (event.allowed === state.sideAllowed) return state;
184
+ // Withdrawn while the panel is up: it becomes the popup rather than a shape the host no longer draws for.
185
+ return { ...state, sideAllowed: event.allowed, presentation: !event.allowed && state.presentation === "side" ? "dock" : state.presentation };
186
+
187
+ case "restore-float":
188
+ return { ...state, floatShape: event.shape };
114
189
 
115
190
  case "minimize":
116
191
  return { ...state, presentation: "mini" };
@@ -122,7 +197,7 @@ export function assistantDockReducer(state: AssistantDockState, event: Assistant
122
197
  // Pressing it AGAIN on that bar opens the popup rather than doing nothing twice — a button that
123
198
  // visibly changes nothing is read as broken.
124
199
  if (!state.busy) return { ...state, presentation: "hidden" };
125
- return { ...state, presentation: state.presentation === "mini" ? "dock" : "mini" };
200
+ return { ...state, presentation: state.presentation === "mini" ? backTo(state) : "mini" };
126
201
 
127
202
  case "busy": {
128
203
  if (event.busy === state.busy) return state;
@@ -136,6 +211,9 @@ export function assistantDockReducer(state: AssistantDockState, event: Assistant
136
211
  case "slide":
137
212
  return { ...state, dockOffsetRight: clampDockOffset(event.offsetRight, event.min, event.max) };
138
213
 
214
+ case "side-resize":
215
+ return { ...state, sideWidth: clampSideWidth(event.width, event.available) };
216
+
139
217
  default:
140
218
  return state;
141
219
  }
@@ -14,12 +14,13 @@
14
14
  */
15
15
  import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
16
16
 
17
- import { DOCK_MIN_HEIGHT, clampDockHeight } from "./dock";
18
- import { useAssistant } from "./provider";
17
+ import { DOCK_MIN_HEIGHT, SIDE_MIN_WIDTH, clampDockHeight, clampSideWidth } from "./dock";
18
+ import { sideAvailable, useAssistant } from "./provider";
19
19
 
20
20
  /** One literal class per presentation, not `gb-dock-${mode}`: a runtime-built class name is a stem the
21
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;
22
+ const MODE_CLASS = { hidden: "gb-dock-hidden", mini: "gb-dock-mini", dock: "gb-dock-dock", side: "gb-dock-side", full: "gb-dock-full" } as const;
23
+
23
24
 
24
25
  /** mm:ss since a run started — the only honest thing the collapsed bar can say. */
25
26
  function elapsed(since: number): string {
@@ -36,6 +37,7 @@ export interface AssistantHostLabels {
36
37
  newChat: string; openFull: string; minimize: string; minimizeTitle: string;
37
38
  close: string; closeBusy: string; closeBusyTitle: string;
38
39
  shrinkToPopup: string; tablist: string; primaryTab: string;
40
+ pinSide: string; floatPopup: string; sideGrip: string; backToSide: string;
39
41
  }
40
42
  const EN: AssistantHostLabels = {
41
43
  ready: "Ready", working: "Working", done: "Done — tap to see it",
@@ -47,6 +49,8 @@ const EN: AssistantHostLabels = {
47
49
  newChat: "New chat", openFull: "Open full screen", minimize: "Minimise", minimizeTitle: "Shrink to a bar",
48
50
  close: "Close the assistant", closeBusy: "Shrink to a bar", closeBusyTitle: "Still working — shrink it to a bar",
49
51
  shrinkToPopup: "Shrink to popup", tablist: "Assistant or a person", primaryTab: "Assistant",
52
+ pinSide: "Pin to the side", floatPopup: "Float as a popup", sideGrip: "Drag to resize the assistant; left and right arrows work too",
53
+ backToSide: "Back to the side panel",
50
54
  };
51
55
 
52
56
  /** An optional second tab in the popup (the admin: a person on the Gem team, through gemcare). */
@@ -60,7 +64,7 @@ export interface AssistantSecondTab {
60
64
 
61
65
  export function AssistantHost({
62
66
  title, labels, onNewChat, onExpand, onShrink, fullActions, secondTab, view = "primary", onViewChange,
63
- miniHint, children,
67
+ miniHint, allowSide = false, children,
64
68
  }: {
65
69
  /** "Gem assistant" in the admin. Names the region, the popup header and the status bar. */
66
70
  title: string;
@@ -79,6 +83,12 @@ export function AssistantHost({
79
83
  onViewChange?: (view: "primary" | "secondary") => void;
80
84
  /** What the status bar says when idle and nothing is waiting, before the default "Ask about this screen". */
81
85
  miniHint?: string;
86
+ /**
87
+ * Offer the full-height SIDE panel (01/10/2026): a "Pin to the side" button in the popup, "Float as a popup" in the
88
+ * panel. The page makes room for it — `.gemboss-platform-main` pads by `--gb-assistant-side-w`; a surface with its
89
+ * own layout reads the same variable. Off by default, so a surface that has not laid out for it never meets it.
90
+ */
91
+ allowSide?: boolean;
82
92
  children?: ReactNode;
83
93
  }) {
84
94
  const dock = useAssistant();
@@ -181,6 +191,47 @@ export function AssistantHost({
181
191
  try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ }
182
192
  }, []);
183
193
 
194
+ // The provider gates every way into the side panel on this, so a host that never offers it never draws it.
195
+ const { setSideAllowed } = dock;
196
+ useEffect(() => { setSideAllowed(allowSide); }, [allowSide, setSideAllowed]);
197
+
198
+ // ── Width drag (side only) ───────────────────────────────────────────────
199
+ // The panel's LEFT edge is a `.ui-resizer` (the fleet's one seam, @gemboss/ui ui.css): dragging it left widens the
200
+ // panel. Pointer capture keeps the drag on the seam; `ui-resizing` on <body> gives the col-resize cursor and the
201
+ // shield over iframes that every other seam in the fleet shows.
202
+ const sideDragRef = useRef<{ startX: number; startW: number; id: number } | null>(null);
203
+ const endSideDrag = useCallback((e?: React.PointerEvent<HTMLDivElement>) => {
204
+ sideDragRef.current = null;
205
+ setDragging(false);
206
+ document.body.classList.remove("ui-resizing");
207
+ if (e) { try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* already released */ } }
208
+ }, []);
209
+ const onSideGripDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
210
+ if (e.button !== 0 || (e.pointerType === "mouse" && e.ctrlKey)) return;
211
+ e.preventDefault();
212
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
213
+ setDragging(true);
214
+ document.body.classList.add("ui-resizing");
215
+ sideDragRef.current = { startX: e.clientX, startW: dock.sideWidth, id: e.pointerId };
216
+ }, [dock.sideWidth]);
217
+ const onSideGripMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
218
+ const drag = sideDragRef.current;
219
+ if (!drag || e.pointerId !== drag.id) return;
220
+ // A lost release (a context menu took it) leaves a mouse with no button down: treat it as the release.
221
+ if (e.pointerType === "mouse" && e.buttons === 0) { endSideDrag(e); return; }
222
+ dock.setSideWidth(drag.startW + (drag.startX - e.clientX));
223
+ }, [dock, endSideDrag]);
224
+ const onSideGripKey = useCallback((e: React.KeyboardEvent) => {
225
+ if (e.altKey || e.metaKey || e.ctrlKey) return; // Back / Forward belong to the browser
226
+ const step = e.shiftKey ? 64 : 16;
227
+ if (e.key === "ArrowLeft") { e.preventDefault(); dock.setSideWidth(dock.sideWidth + step); }
228
+ else if (e.key === "ArrowRight") { e.preventDefault(); dock.setSideWidth(dock.sideWidth - step); }
229
+ else if (e.key === "Home") { e.preventDefault(); dock.setSideWidth(SIDE_MIN_WIDTH); }
230
+ else if (e.key === "End") { e.preventDefault(); dock.setSideWidth(Number.MAX_SAFE_INTEGER); }
231
+ }, [dock]);
232
+ // A drag that is still running when the panel goes away must not leave the shield over the page.
233
+ useEffect(() => { if (dock.presentation !== "side" && sideDragRef.current) endSideDrag(); }, [dock.presentation, endSideDrag]);
234
+
184
235
  // A drag handle nobody can reach with a keyboard is a control only half the sellers have.
185
236
  const onHandleKey = useCallback((e: React.KeyboardEvent) => {
186
237
  const step = e.shiftKey ? 96 : 32;
@@ -198,19 +249,24 @@ export function AssistantHost({
198
249
 
199
250
  const mode = dock.presentation;
200
251
  const isDock = mode === "dock";
252
+ const isSide = mode === "side";
253
+ /** The two working shapes over a screen share the header, the tabs and the body. */
254
+ const isPanel = isDock || isSide;
255
+ const maxSideWidth = clampSideWidth(Number.MAX_SAFE_INTEGER, sideAvailable());
201
256
  // Announced on the grip. Read at render (not in the key handler) so assistive tech is told the
202
257
  // real ceiling for this window, which changes when the window does.
203
258
  const maxDockHeight = clampDockHeight(Number.MAX_SAFE_INTEGER, typeof window === "undefined" ? 900 : window.innerHeight);
204
259
  const statusText = dock.busy ? L.working : dock.unread ? L.done : L.ready;
205
260
  // 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";
261
+ const secondVisible = isPanel && !!secondTab && view === "secondary";
207
262
  const secondBadge = secondTab?.badge || 0;
208
263
 
209
264
  return (
210
265
  <div
211
266
  ref={shellRef}
212
267
  className={`gb-dock ${MODE_CLASS[mode]}${dragging ? " gb-dock-dragging" : ""}`}
213
- style={isDock ? { height: dock.dockHeight, right: dock.dockOffsetRight } : undefined}
268
+ style={isDock ? { height: dock.dockHeight, right: dock.dockOffsetRight }
269
+ : isSide ? ({ "--gb-dock-side-w": `${dock.sideWidth}px` } as React.CSSProperties) : undefined}
214
270
  role={mode === "hidden" ? undefined : "region"}
215
271
  aria-label={mode === "hidden" ? undefined : title}
216
272
  >
@@ -223,14 +279,14 @@ export function AssistantHost({
223
279
  <svg viewBox="0 0 20 20" aria-hidden>
224
280
  <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
281
  </svg>
226
- <span>{L.shrinkToPopup}</span>
282
+ <span>{dock.sideAllowed && dock.floatShape === "side" ? L.backToSide : L.shrinkToPopup}</span>
227
283
  </button>
228
284
  </div>
229
285
  ) : null}
230
286
 
231
287
  {mode === "mini" ? (
232
288
  <div className="gb-dock-minirow">
233
- <button type="button" className="gb-dock-minibar" onClick={dock.openDock} aria-label={L.reopen.replace("{title}", title)}>
289
+ <button type="button" className="gb-dock-minibar" onClick={dock.reopen} aria-label={L.reopen.replace("{title}", title)}>
234
290
  <span className={`gb-dock-dot${dock.busy ? " gb-dock-dot-busy" : dock.unread ? " gb-dock-dot-unread" : ""}`} aria-hidden />
235
291
  <strong>{title}</strong>
236
292
  <span className="gb-dock-ministatus">
@@ -254,9 +310,28 @@ export function AssistantHost({
254
310
  </div>
255
311
  ) : null}
256
312
 
257
- {isDock ? (
313
+ {isSide ? (
314
+ <div
315
+ className="ui-resizer gb-dock-sidegrip"
316
+ role="separator"
317
+ aria-orientation="vertical"
318
+ aria-label={L.sideGrip}
319
+ aria-valuenow={dock.sideWidth}
320
+ aria-valuemin={SIDE_MIN_WIDTH}
321
+ aria-valuemax={maxSideWidth}
322
+ tabIndex={0}
323
+ onPointerDown={onSideGripDown}
324
+ onPointerMove={onSideGripMove}
325
+ onPointerUp={endSideDrag}
326
+ onPointerCancel={endSideDrag}
327
+ onLostPointerCapture={() => { if (sideDragRef.current) endSideDrag(); }}
328
+ onKeyDown={onSideGripKey}
329
+ />
330
+ ) : null}
331
+
332
+ {isPanel ? (
258
333
  <>
259
- <div
334
+ {isDock ? <div
260
335
  className="gb-dock-grip"
261
336
  role="separator"
262
337
  aria-orientation="horizontal"
@@ -272,14 +347,14 @@ export function AssistantHost({
272
347
  onKeyDown={onHandleKey}
273
348
  >
274
349
  <span aria-hidden />
275
- </div>
350
+ </div> : null}
276
351
  <header
277
352
  className="gb-dock-header"
278
- onPointerDown={onHeaderDown}
279
- onPointerMove={onHeaderMove}
280
- onPointerUp={onHeaderUp}
281
- onPointerCancel={onHeaderUp}
282
- title={L.headerTitle}
353
+ onPointerDown={isDock ? onHeaderDown : undefined}
354
+ onPointerMove={isDock ? onHeaderMove : undefined}
355
+ onPointerUp={isDock ? onHeaderUp : undefined}
356
+ onPointerCancel={isDock ? onHeaderUp : undefined}
357
+ title={isDock ? L.headerTitle : undefined}
283
358
  >
284
359
  <span className={`gb-dock-dot${dock.busy ? " gb-dock-dot-busy" : ""}`} aria-hidden />
285
360
  <span className="gb-dock-title">{title}</span>
@@ -296,6 +371,27 @@ export function AssistantHost({
296
371
  <span className="gb-dock-sr">{L.newChat}</span>
297
372
  </button>
298
373
  ) : null}
374
+ {isDock && allowSide ? (
375
+ <button type="button" className="gb-dock-icon gb-dock-action gb-dock-pinside" title={L.pinSide} onClick={dock.openSide}>
376
+ {/* A window with its right third filled: the panel it will become. */}
377
+ <svg viewBox="0 0 20 20" aria-hidden>
378
+ <rect x="3.5" y="4.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none"/>
379
+ <path d="M12 4.5v11" stroke="currentColor" strokeWidth="1.5"/>
380
+ <path d="M12.75 5.25h3v9.5h-3z" fill="currentColor"/>
381
+ </svg>
382
+ <span className="gb-dock-sr">{L.pinSide}</span>
383
+ </button>
384
+ ) : null}
385
+ {isSide ? (
386
+ <button type="button" className="gb-dock-icon gb-dock-action" title={L.floatPopup} onClick={dock.openDock}>
387
+ {/* The same window with a small box floating in its bottom-right corner: the popup. */}
388
+ <svg viewBox="0 0 20 20" aria-hidden>
389
+ <rect x="3.5" y="4.5" width="13" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none"/>
390
+ <rect x="10" y="10" width="5" height="4" rx="0.75" fill="currentColor"/>
391
+ </svg>
392
+ <span className="gb-dock-sr">{L.floatPopup}</span>
393
+ </button>
394
+ ) : null}
299
395
  <button
300
396
  type="button"
301
397
  className="gb-dock-icon"
@@ -357,7 +453,7 @@ export function AssistantHost({
357
453
  ) : null}
358
454
 
359
455
  <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}>
456
+ <div className="gb-dock-pane" id="gem-dock-pane-assistant" role={isPanel && secondTab ? "tabpanel" : undefined} aria-labelledby={isPanel && secondTab ? "gem-dock-tab-assistant" : undefined} hidden={secondVisible}>
361
457
  {children}
362
458
  </div>
363
459
  {secondTab?.content ? (
@@ -1,6 +1,6 @@
1
1
  // The assistant's frame: popup, status bar, full screen — and the rules for when each shows.
2
2
  // A separate entry from the shell/auth barrel so a surface that never opens an assistant pays nothing for it.
3
3
  export { AssistantHost, type AssistantHostLabels, type AssistantSecondTab } from "./host";
4
- export { AssistantProvider, useAssistant, useAssistantPageFacts, type AssistantDockApi, type AssistantOptions } from "./provider";
4
+ export { AssistantProvider, SIDE_WIDTH_VAR, useAssistant, useAssistantPageFacts, type AssistantDockApi, type AssistantOptions } from "./provider";
5
5
  export * from "./dock";
6
6
  export { AssistantWorkspaceLayout, OpenChatTabs, SessionRail, SESSION_RAIL_EN, groupSessions, normalizeForSearch, relativeTime, type AssistantSession, type SessionGroup, type SessionRailIcons, type SessionRailLabels } from "./sessions";
@@ -18,6 +18,7 @@ import {
18
18
  initialAssistantDockState,
19
19
  DOCK_DEFAULT_HEIGHT,
20
20
  DOCK_DEFAULT_OFFSET_RIGHT,
21
+ SIDE_DEFAULT_WIDTH,
21
22
  type AssistantDockState,
22
23
  } from "./dock";
23
24
 
@@ -29,9 +30,23 @@ export interface AssistantOptions {
29
30
  bodyClassPrefix?: string;
30
31
  }
31
32
 
32
- /** Storage keys. `lastMode` is the last shape the seller actually used: "full" or "dock" — nothing
33
+ /** Storage keys. `lastMode` is the last shape the seller actually used: "full", "dock" or "side" — nothing
33
34
  * 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
+ const storageKey = (prefix: string, k: "dockHeight" | "dockOffsetRight" | "lastMode" | "sideWidth" | "floatShape") => `${prefix}.${k}`;
36
+
37
+ /**
38
+ * The width of the content area the side panel sits in: the window minus the rail. Measured from `main`'s LEFT
39
+ * edge (where the rail ends), never from main's width — with the panel open, main is already narrower by the panel.
40
+ */
41
+ export function sideAvailable(): number {
42
+ if (typeof window === "undefined") return 0;
43
+ const left = document.querySelector(".gemboss-platform-main")?.getBoundingClientRect().left ?? 0;
44
+ return (document.documentElement.clientWidth || window.innerWidth) - left;
45
+ }
46
+
47
+ /** The custom property the page reads to make room for the side panel (`.gemboss-platform-main` does it itself;
48
+ * a surface with its own layout reads the same variable). */
49
+ export const SIDE_WIDTH_VAR = "--gb-assistant-side-w";
35
50
 
36
51
  const ADMIN_FULL_PATH = "/app/assistant";
37
52
  const DEFAULTS: Required<AssistantOptions> = {
@@ -52,6 +67,10 @@ export interface AssistantDockApi extends AssistantDockState {
52
67
  enterFull: () => void;
53
68
  leaveFull: () => void;
54
69
  openDock: () => void;
70
+ /** Pin to the full-height side panel. Only a host that allows it (`allowSide`) offers the button. */
71
+ openSide: () => void;
72
+ /** Back on screen in the last working shape — popup or side panel (`floatShape`). */
73
+ reopen: () => void;
55
74
  minimize: () => void;
56
75
  dismiss: () => void;
57
76
  setBusy: (busy: boolean) => void;
@@ -59,6 +78,10 @@ export interface AssistantDockApi extends AssistantDockState {
59
78
  /** Slide the popup along the bottom. `min`/`max` come from the caller, which is the only place
60
79
  * that knows how wide the popup is and where the rail ends. */
61
80
  setDockOffsetRight: (offsetRight: number, min: number, max: number) => void;
81
+ /** Resize the side panel; clamped so the page beside it keeps SIDE_PAGE_MIN. */
82
+ setSideWidth: (width: number) => void;
83
+ /** The host registers whether it offers the side panel (`allowSide`). */
84
+ setSideAllowed: (allowed: boolean) => void;
62
85
  }
63
86
 
64
87
  const Ctx = createContext<AssistantDockApi | null>(null);
@@ -106,6 +129,13 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
106
129
  // otherwise put the popup on the rail — or off the left edge — on a laptop.
107
130
  dispatch({ type: "slide", offsetRight: rawOffset, ...offsetBounds() });
108
131
  }
132
+ // The working shape outlives a reload: `lastMode` cannot stand in for it, since opening full screen overwrites it.
133
+ if (window.localStorage.getItem(storageKey(opts.current.storagePrefix, "floatShape")) === "side") dispatch({ type: "restore-float", shape: "side" });
134
+ const storedSide = Number(window.localStorage.getItem(storageKey(opts.current.storagePrefix, "sideWidth")));
135
+ if (Number.isFinite(storedSide) && storedSide > 0) {
136
+ requestedSide.current = storedSide;
137
+ dispatch({ type: "side-resize", width: storedSide, available: sideAvailable() });
138
+ }
109
139
  } catch { /* private mode — defaults are fine */ }
110
140
  }, []);
111
141
 
@@ -119,6 +149,8 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
119
149
  const requestedHeight = useRef(DOCK_DEFAULT_HEIGHT);
120
150
  /** Same reasoning as `requestedHeight`: persist what the seller ASKED for, clamp only to draw. */
121
151
  const requestedOffset = useRef(DOCK_DEFAULT_OFFSET_RIGHT);
152
+ /** …and the same for the side panel's width: a width chosen on a 27" screen comes back there. */
153
+ const requestedSide = useRef(SIDE_DEFAULT_WIDTH);
122
154
 
123
155
  /**
124
156
  * How far the popup may travel in THIS window.
@@ -144,6 +176,7 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
144
176
  // …and sideways too. `requestedOffset` was being written and never read, so a window that got
145
177
  // narrower left the popup where a wider one had allowed it: on the rail, or past the edge.
146
178
  dispatch({ type: "slide", offsetRight: requestedOffset.current, ...offsetBounds() });
179
+ dispatch({ type: "side-resize", width: requestedSide.current, available: sideAvailable() });
147
180
  };
148
181
  window.addEventListener("resize", onResize);
149
182
  return () => window.removeEventListener("resize", onResize);
@@ -158,6 +191,12 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
158
191
  try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "dockHeight"), String(Math.round(height))); } catch { /* best-effort */ }
159
192
  }, []);
160
193
 
194
+ const setSideWidth = useCallback((width: number) => {
195
+ requestedSide.current = Math.round(width);
196
+ dispatch({ type: "side-resize", width, available: sideAvailable() });
197
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "sideWidth"), String(Math.round(width))); } catch { /* best-effort */ }
198
+ }, []);
199
+
161
200
  const setDockOffsetRight = useCallback((offsetRight: number, min: number, max: number) => {
162
201
  requestedOffset.current = Math.round(offsetRight);
163
202
  dispatch({ type: "slide", offsetRight, min, max });
@@ -171,11 +210,15 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
171
210
  * someone who had been working in the popup all morning and closed it. Chris: "nếu đang ở popup mà
172
211
  * tắt đi, lần sau bấm vào menu assistant thì nó cũng nên là popup".
173
212
  *
174
- * Only `full` and `dock` are recorded. `mini` and `hidden` are ways of putting something away, not
213
+ * Only `full`, `dock` and `side` are recorded. `mini` and `hidden` are ways of putting something away, not
175
214
  * ways of working in it; reopening into a collapsed bar would answer a click with almost nothing.
176
215
  */
177
216
  useEffect(() => {
178
- if (state.presentation !== "full" && state.presentation !== "dock") return;
217
+ try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "floatShape"), state.floatShape); } catch { /* best-effort */ }
218
+ }, [state.floatShape]);
219
+
220
+ useEffect(() => {
221
+ if (state.presentation !== "full" && state.presentation !== "dock" && state.presentation !== "side") return;
179
222
  try { window.localStorage.setItem(storageKey(opts.current.storagePrefix, "lastMode"), state.presentation); } catch { /* best-effort */ }
180
223
  }, [state.presentation]);
181
224
 
@@ -198,10 +241,10 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
198
241
  if (opts.current.isOnFullPage()) return;
199
242
  let last = "";
200
243
  try { last = window.localStorage.getItem(storageKey(opts.current.storagePrefix, "lastMode")) ?? ""; } catch { /* private mode */ }
201
- if (last !== "dock") return;
244
+ if (last !== "dock" && last !== "side") return;
202
245
  e.preventDefault();
203
246
  e.stopPropagation();
204
- dispatch({ type: "dock" });
247
+ dispatch({ type: last });
205
248
  // The shell closes its mobile nav drawer when the ROUTE changes. This click deliberately does
206
249
  // not change the route, so on a phone the drawer stayed open on top of the popup it had just
207
250
  // opened — measured at 390px, `.gnav-scroll` sitting over the composer. Say it out loud
@@ -223,12 +266,36 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
223
266
  };
224
267
  }, [state.busy, state.unread]);
225
268
 
269
+ // The page makes room for the side panel: a body class plus the panel's width as a custom property, so the shell
270
+ // (and any surface with its own layout) can pad its content by exactly that much instead of being covered.
271
+ // Entering the side panel draws it at the width the seller ASKED for, clamped to THIS window — the stored width
272
+ // was clamped against whatever window it was last drawn in (or never, on a first open), and a resize is the only
273
+ // other moment that re-clamps.
274
+ useEffect(() => {
275
+ if (state.presentation === "side") dispatch({ type: "side-resize", width: requestedSide.current, available: sideAvailable() });
276
+ }, [state.presentation]);
277
+
278
+ useEffect(() => {
279
+ const p = opts.current.bodyClassPrefix;
280
+ const on = state.presentation === "side";
281
+ document.body.classList.toggle(`${p}-side`, on);
282
+ if (on) document.body.style.setProperty(SIDE_WIDTH_VAR, `${state.sideWidth}px`);
283
+ else document.body.style.removeProperty(SIDE_WIDTH_VAR);
284
+ return () => {
285
+ document.body.classList.remove(`${p}-side`);
286
+ document.body.style.removeProperty(SIDE_WIDTH_VAR);
287
+ };
288
+ }, [state.presentation, state.sideWidth]);
289
+
226
290
  // Every action is identity-STABLE (dispatch is). Children register these as callbacks and one of
227
291
  // them — the chat's busy reporter — is read in an unmount cleanup, where a changing identity would
228
292
  // fire a spurious "the run ended" on every state change.
229
293
  const enterFull = useCallback(() => dispatch({ type: "enter-full" }), []);
230
294
  const leaveFull = useCallback(() => dispatch({ type: "leave-full" }), []);
231
295
  const openDock = useCallback(() => dispatch({ type: "dock" }), []);
296
+ const openSide = useCallback(() => dispatch({ type: "side" }), []);
297
+ const reopen = useCallback(() => dispatch({ type: "reopen" }), []);
298
+ const setSideAllowed = useCallback((allowed: boolean) => dispatch({ type: "side-allowed", allowed }), []);
232
299
  const minimize = useCallback(() => dispatch({ type: "minimize" }), []);
233
300
  const dismiss = useCallback(() => dispatch({ type: "dismiss" }), []);
234
301
  const setBusy = useCallback((busy: boolean) => dispatch({ type: "busy", busy }), []);
@@ -236,8 +303,8 @@ export function AssistantProvider({ options, children }: { options?: AssistantOp
236
303
  const api = useMemo<AssistantDockApi>(() => ({
237
304
  ...state,
238
305
  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]);
306
+ enterFull, leaveFull, openDock, openSide, reopen, minimize, dismiss, setBusy, setDockHeight, setDockOffsetRight, setSideWidth, setSideAllowed,
307
+ }), [state, pageFacts, publishPageFacts, retractPageFacts, enterFull, leaveFull, openDock, openSide, reopen, minimize, dismiss, setBusy, setDockHeight, setDockOffsetRight, setSideWidth, setSideAllowed]);
241
308
 
242
309
  return <Ctx.Provider value={api}>{children}</Ctx.Provider>;
243
310
  }