@gemboss/ui 0.2.1 → 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/README.md +70 -6
- package/catalog/index.html +91 -10
- package/gemboss-ui.css +688 -3
- package/package.json +5 -1
- package/src/assistant/dock.ts +83 -5
- package/src/assistant/host.tsx +113 -17
- package/src/assistant/index.ts +1 -1
- package/src/assistant/provider.tsx +75 -8
- package/src/assistant.css +90 -1
- package/src/chat-composer.css +110 -0
- package/src/chat.css +427 -0
- package/src/kit-plain.css +55 -0
- package/src/kit-plain.js +179 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemboss/ui",
|
|
3
|
-
"version": "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,8 +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",
|
|
21
23
|
"./kit-plain.css": "./src/kit-plain.css",
|
|
24
|
+
"./kit-plain.js": "./src/kit-plain.js",
|
|
22
25
|
"./assistant": "./src/assistant/index.ts",
|
|
23
26
|
"./assistant/dock": "./src/assistant/dock.ts",
|
|
24
27
|
"./assistant.css": "./src/assistant.css"
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
"src/**/*.ts",
|
|
28
31
|
"src/**/*.tsx",
|
|
29
32
|
"src/*.css",
|
|
33
|
+
"src/kit-plain.js",
|
|
30
34
|
"!src/**/*.test.*",
|
|
31
35
|
"gemboss-ui.css",
|
|
32
36
|
"catalog/index.html",
|
package/src/assistant/dock.ts
CHANGED
|
@@ -16,8 +16,17 @@
|
|
|
16
16
|
* being spread over three components' effects.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
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 ?
|
|
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" ?
|
|
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
|
}
|
package/src/assistant/host.tsx
CHANGED
|
@@ -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 =
|
|
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 }
|
|
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.
|
|
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
|
-
{
|
|
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={
|
|
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 ? (
|
package/src/assistant/index.ts
CHANGED
|
@@ -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 "
|
|
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 `
|
|
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
|
-
|
|
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:
|
|
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
|
}
|