@gemboss/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The assistant's thread rail and open-chat tabs — @gemboss/ui (28/09/2026).
5
+ *
6
+ * Moved out of gemboss apps/admin/src/components/operator/AssistantWorkspace.tsx, which renders it
7
+ * now; gemwatcher's assistant renders the same one. The markup and class names are the admin's own
8
+ * (`gemboss-assistant-*`): their CSS still lives in the admin's gemboss-shell.css and reaches other
9
+ * surfaces through gemboss-ui.css, extracted by apps/admin/scripts/export-ui-css.mjs.
10
+ *
11
+ * What this owns: how the list LOOKS and the UI-only state of looking at it — the search box, the
12
+ * recency groups, inline rename, the row menu and its two-step delete. What it does not own: the
13
+ * sessions themselves, and what opening, renaming, pinning, deleting or exporting one does. Those
14
+ * are the surface's, through callbacks. Collapse and keyboard shortcuts are the surface's too (the
15
+ * admin only lets its full-screen page own the keyboard).
16
+ */
17
+ import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
18
+
19
+ /** One conversation in the rail. `updatedAt` is epoch ms. */
20
+ export interface AssistantSession {
21
+ id: string;
22
+ title: string;
23
+ pinned?: boolean;
24
+ updatedAt: number;
25
+ /** Searched along with the title. */
26
+ preview?: string;
27
+ }
28
+
29
+ type IconC = (props: { className?: string }) => ReactNode;
30
+ export interface SessionRailIcons {
31
+ compose: IconC; chevronLeft: IconC; chevronRight: IconC; search: IconC; close: IconC; check: IconC;
32
+ more: IconC; pin: IconC; pinFilled: IconC; edit: IconC; exportIcon: IconC; remove: IconC;
33
+ }
34
+
35
+ const svg = (name: string, d: ReactNode): IconC => {
36
+ const Icon = ({ className }: { className?: string }) => (
37
+ <svg viewBox="0 0 20 20" className={className} aria-hidden="true" focusable="false">
38
+ <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">{d}</g>
39
+ </svg>
40
+ );
41
+ Icon.displayName = `RailIcon.${name}`;
42
+ return Icon;
43
+ };
44
+ /** Line icons for a surface that brings none. The admin passes its Polaris icons instead. */
45
+ const DEFAULT_ICONS: SessionRailIcons = {
46
+ compose: svg("compose", <><path d="M11 4H5.5A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16h9a1.5 1.5 0 0 0 1.5-1.5V9" /><path d="M14.5 3.5l2 2L10 12H8v-2z" /></>),
47
+ chevronLeft: svg("chevronLeft", <path d="M12 5l-5 5 5 5" />),
48
+ chevronRight: svg("chevronRight", <path d="M8 5l5 5-5 5" />),
49
+ search: svg("search", <><circle cx="9" cy="9" r="5" /><path d="M13 13l3.5 3.5" /></>),
50
+ close: svg("close", <path d="M6 6l8 8M14 6l-8 8" />),
51
+ check: svg("check", <path d="M4.5 10.5l3.5 3.5 7.5-8" />),
52
+ more: svg("more", <><circle cx="5" cy="10" r=".8" /><circle cx="10" cy="10" r=".8" /><circle cx="15" cy="10" r=".8" /></>),
53
+ pin: svg("pin", <><path d="M8 3.5h4l-.5 5 2.5 2.5H6l2.5-2.5z" /><path d="M10 11v5.5" /></>),
54
+ pinFilled: function PinFilled({ className }) {
55
+ return (
56
+ <svg viewBox="0 0 20 20" className={className} aria-hidden="true" focusable="false">
57
+ <path d="M8 3.5h4l-.5 5 2.5 2.5H6l2.5-2.5z" fill="currentColor" />
58
+ <path d="M10 11v5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
59
+ </svg>
60
+ );
61
+ },
62
+ edit: svg("edit", <path d="M4 16h3l8.5-8.5-3-3L4 13z" />),
63
+ exportIcon: svg("exportIcon", <><path d="M10 3.5v9M6.5 7L10 3.5 13.5 7" /><path d="M4 12.5v3h12v-3" /></>),
64
+ remove: svg("remove", <><path d="M5 6h10M8 6V4.5h4V6M6.5 6l.7 9.5h5.6l.7-9.5" /></>),
65
+ };
66
+
67
+ /** Every word the rail says. English defaults are the admin's. */
68
+ export interface SessionRailLabels {
69
+ newChat: string; expand: string; collapse: string; expandTitle: string; collapseTitle: string;
70
+ searchPlaceholder: string; searchLabel: string; clearSearch: string;
71
+ noneFound: string; noneYet: string; openNow: string;
72
+ pinned: string; today: string; yesterday: string; previous7: string; previous30: string; older: string;
73
+ working: string; options: string; save: string; cancel: string;
74
+ pin: string; unpin: string; rename: string; exportMarkdown: string; remove: string; confirmRemove: string;
75
+ /** Relative time, and the locale for dates older than a week. */
76
+ justNow: string; minutesAgo: (m: number) => string; hoursAgo: (h: number) => string; daysAgo: (d: number) => string;
77
+ dateLocale: string;
78
+ /** The popup's open-chat tabs. */
79
+ openChats: string; newChatN: (n: number) => string; tabWorking: (title: string) => string;
80
+ closeChat: string; closeChatRunning: string; closeChatSr: string; closeChatRunningSr: string;
81
+ }
82
+ export const SESSION_RAIL_EN: SessionRailLabels = {
83
+ newChat: "New chat", expand: "Expand sidebar", collapse: "Collapse sidebar", expandTitle: "Expand", collapseTitle: "Collapse",
84
+ searchPlaceholder: "Search conversations…", searchLabel: "Search history", clearSearch: "Clear search",
85
+ noneFound: "No conversations found", noneYet: "No conversations yet", openNow: "Open now",
86
+ pinned: "Pinned", today: "Today", yesterday: "Yesterday", previous7: "Previous 7 days", previous30: "Previous 30 days", older: "Older",
87
+ working: "working", options: "Options", save: "Save", cancel: "Cancel",
88
+ pin: "Pin", unpin: "Unpin", rename: "Rename", exportMarkdown: "Export Markdown", remove: "Delete", confirmRemove: "Delete this chat?",
89
+ justNow: "just now", minutesAgo: (m) => `${m} min ago`, hoursAgo: (h) => `${h} hr ago`,
90
+ daysAgo: (d) => `${d} day${d > 1 ? "s" : ""} ago`, dateLocale: "en-US",
91
+ openChats: "Open chats", newChatN: (n) => `New chat ${n}`, tabWorking: (t) => `${t} — working`,
92
+ closeChat: "Close this chat", closeChatRunning: "Working — closing this stops it",
93
+ closeChatSr: "Close this chat", closeChatRunningSr: "Close and stop the run",
94
+ };
95
+
96
+ /** Short relative time for the thread list. */
97
+ export function relativeTime(ts: number, L: SessionRailLabels = SESSION_RAIL_EN, now = Date.now()): string {
98
+ const m = Math.floor((now - ts) / 60000);
99
+ if (m < 1) return L.justNow;
100
+ if (m < 60) return L.minutesAgo(m);
101
+ const h = Math.floor(m / 60);
102
+ if (h < 24) return L.hoursAgo(h);
103
+ const d = Math.floor(h / 24);
104
+ if (d < 7) return L.daysAgo(d);
105
+ return new Date(ts).toLocaleDateString(L.dateLocale);
106
+ }
107
+
108
+ /** Lowercase + strip Vietnamese diacritics so search matches "muc tieu" ↔ "mục tiêu". */
109
+ export function normalizeForSearch(s: string): string {
110
+ return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/g, "d");
111
+ }
112
+
113
+ export interface SessionGroup { label: string; items: AssistantSession[] }
114
+
115
+ /** Bucket threads ChatGPT-style: a pinned group on top, then by recency. Input is already
116
+ * pinned-first + newest-first, so each bucket keeps that order and empty buckets are dropped. */
117
+ export function groupSessions(sessions: AssistantSession[], L: SessionRailLabels = SESSION_RAIL_EN, nowMs = Date.now()): SessionGroup[] {
118
+ const pinned = sessions.filter((s) => s.pinned);
119
+ const rest = sessions.filter((s) => !s.pinned);
120
+ const now = new Date(nowMs);
121
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
122
+ const DAY = 86_400_000;
123
+ const buckets: Array<{ label: string; min: number }> = [
124
+ { label: L.today, min: startOfToday },
125
+ { label: L.yesterday, min: startOfToday - DAY },
126
+ { label: L.previous7, min: startOfToday - 7 * DAY },
127
+ { label: L.previous30, min: startOfToday - 30 * DAY },
128
+ { label: L.older, min: -Infinity },
129
+ ];
130
+ const dateGroups: SessionGroup[] = buckets.map((b) => ({ label: b.label, items: [] }));
131
+ for (const s of rest) {
132
+ const idx = buckets.findIndex((b) => s.updatedAt >= b.min);
133
+ dateGroups[idx === -1 ? dateGroups.length - 1 : idx].items.push(s);
134
+ }
135
+ const out: SessionGroup[] = [];
136
+ if (pinned.length) out.push({ label: L.pinned, items: pinned });
137
+ return out.concat(dateGroups.filter((g) => g.items.length > 0));
138
+ }
139
+
140
+ /**
141
+ * The thread rail. Full screen only — the popup has no room for it and shows `OpenChatTabs` instead.
142
+ */
143
+ export function SessionRail({
144
+ sessions, unlistedIds = [], activeId, pendingId, busyIds = [], collapsed, onToggleCollapsed,
145
+ onNewChat, newChatTitle, onOpen, onRename, onTogglePin, onDelete, onExport, footer, searchRef, labels, icons,
146
+ }: {
147
+ /** Pinned-first, newest-first — the order the rail shows them in. */
148
+ sessions: AssistantSession[];
149
+ /** Chats that are open but not in `sessions` yet (a new chat mid-first-turn) — listed under "Open now". */
150
+ unlistedIds?: string[];
151
+ activeId?: string;
152
+ /** Highlighted while its turns load, so the click reads as heard. */
153
+ pendingId?: string | null;
154
+ /** Threads with a turn running right now. */
155
+ busyIds?: string[];
156
+ collapsed: boolean;
157
+ onToggleCollapsed: () => void;
158
+ onNewChat: () => void;
159
+ /** Hover text of New chat — the admin puts its shortcut here. */
160
+ newChatTitle?: string;
161
+ onOpen: (id: string) => void;
162
+ onRename: (id: string, title: string) => void;
163
+ onTogglePin?: (session: AssistantSession) => void;
164
+ onDelete: (id: string) => void;
165
+ onExport?: (id: string) => void;
166
+ /** Buttons at the foot of the rail (the admin: "Give Gem a goal", "Store memory"). Give them
167
+ * `gemboss-assistant-railfoot is-button`. */
168
+ footer?: ReactNode;
169
+ /** The search input, so the surface can focus it from a shortcut. */
170
+ searchRef?: React.Ref<HTMLInputElement>;
171
+ labels?: Partial<SessionRailLabels>;
172
+ icons?: Partial<SessionRailIcons>;
173
+ }) {
174
+ const L = { ...SESSION_RAIL_EN, ...labels };
175
+ const I = { ...DEFAULT_ICONS, ...icons };
176
+ const [query, setQuery] = useState("");
177
+ const [editingId, setEditingId] = useState<string | null>(null);
178
+ const [editValue, setEditValue] = useState("");
179
+ const [menuId, setMenuId] = useState<string | null>(null);
180
+ // Fixed-position coords for the open row menu (so an overflow:auto list can't clip it), and the
181
+ // thread awaiting a two-step delete confirm inside that menu.
182
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
183
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
184
+ const menuRef = useRef<HTMLDivElement>(null);
185
+
186
+ // Search filters by title + preview (diacritic-insensitive); while searching the rail is a flat
187
+ // result list, otherwise threads are bucketed by recency.
188
+ const groups = useMemo<SessionGroup[]>(() => {
189
+ const q = normalizeForSearch(query.trim());
190
+ if (q) {
191
+ const hits = sessions.filter((s) => normalizeForSearch(`${s.title} ${s.preview ?? ""}`).includes(q));
192
+ return hits.length ? [{ label: "", items: hits }] : [];
193
+ }
194
+ const listed = groupSessions(sessions, L);
195
+ /**
196
+ * Chats that are OPEN but not yet in the server's list. A session appears there only once its
197
+ * first turn has been logged and titled; until then the rail is the only way back to it in full
198
+ * mode, so it has to be listed here.
199
+ */
200
+ const unlisted = unlistedIds
201
+ .filter((id) => !sessions.some((x) => x.id === id))
202
+ .map((id): AssistantSession => ({ id, title: L.newChat, updatedAt: Date.now(), pinned: false }));
203
+ return unlisted.length ? [{ label: L.openNow, items: unlisted }, ...listed] : listed;
204
+ // `L` is rebuilt every render from `labels`; the words it carries are what matter here.
205
+ // eslint-disable-next-line react-hooks/exhaustive-deps
206
+ }, [sessions, query, unlistedIds, labels]);
207
+
208
+ // Open the row menu at a FIXED position anchored to the kebab (so the overflow:auto thread list
209
+ // can't clip it), flipping above the button when it would overflow the viewport bottom.
210
+ const openMenu = useCallback((e: React.MouseEvent, sessionId: string) => {
211
+ if (menuId === sessionId) { setMenuId(null); return; }
212
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
213
+ const MENU_W = 184, MENU_H = 200;
214
+ const openUp = r.bottom + MENU_H + 8 > window.innerHeight;
215
+ setMenuPos({
216
+ left: Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8)),
217
+ top: openUp ? Math.max(8, r.top - 6 - MENU_H) : r.bottom + 6,
218
+ });
219
+ setConfirmDeleteId(null);
220
+ setMenuId(sessionId);
221
+ }, [menuId]);
222
+
223
+ // Close the row actions menu on an outside click / scroll / resize (the kebab stops propagation
224
+ // so it can toggle; a fixed menu must close on scroll since it no longer tracks the row).
225
+ useEffect(() => {
226
+ if (!menuId) return;
227
+ const close = () => setMenuId(null);
228
+ const onDown = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) close(); };
229
+ document.addEventListener("mousedown", onDown);
230
+ window.addEventListener("scroll", close, true);
231
+ window.addEventListener("resize", close);
232
+ return () => {
233
+ document.removeEventListener("mousedown", onDown);
234
+ window.removeEventListener("scroll", close, true);
235
+ window.removeEventListener("resize", close);
236
+ };
237
+ }, [menuId]);
238
+
239
+ const beginEdit = (s: AssistantSession) => { setEditingId(s.id); setEditValue(s.title); };
240
+ const cancelEdit = () => { setEditingId(null); setEditValue(""); };
241
+ const commitEdit = (s: AssistantSession) => {
242
+ const title = editValue.trim();
243
+ setEditingId(null);
244
+ if (!title || title === s.title) return;
245
+ onRename(s.id, title);
246
+ };
247
+
248
+ const renderThread = (s: AssistantSession) => (
249
+ <div
250
+ key={s.id}
251
+ className={`gemboss-assistant-thread${s.id === activeId || s.id === pendingId ? " active" : ""}${editingId === s.id ? " editing" : ""}${menuId === s.id ? " menu-open" : ""}`}
252
+ >
253
+ {editingId === s.id ? (
254
+ <div className="gemboss-assistant-thread-edit">
255
+ <input className="gemboss-field"
256
+ autoFocus
257
+ value={editValue}
258
+ onChange={(e) => setEditValue(e.target.value)}
259
+ onKeyDown={(e) => {
260
+ if (e.key === "Enter") { e.preventDefault(); commitEdit(s); }
261
+ else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
262
+ }}
263
+ />
264
+ <button type="button" className="gemboss-assistant-thread-act" aria-label={L.save} onClick={() => commitEdit(s)}><I.check /></button>
265
+ <button type="button" className="gemboss-assistant-thread-act" aria-label={L.cancel} onClick={cancelEdit}><I.close /></button>
266
+ </div>
267
+ ) : (
268
+ <>
269
+ <button type="button" className="gemboss-assistant-thread-main" onClick={() => onOpen(s.id)} title={s.title}>
270
+ <span className="gemboss-assistant-thread-titlerow">
271
+ {/* Several threads can be working at once; the rail has to say which. */}
272
+ {busyIds.includes(s.id) && <span className="gemboss-assistant-thread-running" aria-label={L.working} />}
273
+ {s.pinned && <I.pinFilled className="gemboss-assistant-pin-dot" />}
274
+ <span className="gemboss-assistant-thread-title">{s.title}</span>
275
+ </span>
276
+ <span className="gemboss-assistant-thread-meta">{relativeTime(s.updatedAt, L)}</span>
277
+ </button>
278
+ <div className="gemboss-assistant-thread-actions">
279
+ <button
280
+ type="button"
281
+ className="gemboss-assistant-thread-act"
282
+ aria-label={L.options}
283
+ onMouseDown={(e) => e.stopPropagation()}
284
+ onClick={(e) => openMenu(e, s.id)}
285
+ ><I.more /></button>
286
+ </div>
287
+ {menuId === s.id && (
288
+ <div className="gemboss-popover-surface gemboss-assistant-menu" ref={menuRef} role="menu" style={{ position: "fixed", top: menuPos.top, left: menuPos.left }}>
289
+ {confirmDeleteId === s.id ? (
290
+ <>
291
+ <div className="gemboss-assistant-menu-confirm">{L.confirmRemove}</div>
292
+ <button type="button" role="menuitem" className="danger" onClick={() => { setMenuId(null); onDelete(s.id); }}>
293
+ <I.remove /><span>{L.remove}</span>
294
+ </button>
295
+ <button type="button" role="menuitem" onClick={() => setConfirmDeleteId(null)}>
296
+ <I.close /><span>{L.cancel}</span>
297
+ </button>
298
+ </>
299
+ ) : (
300
+ <>
301
+ {onTogglePin ? (
302
+ <button type="button" role="menuitem" onClick={() => { setMenuId(null); onTogglePin(s); }}>
303
+ {s.pinned ? <I.pin /> : <I.pinFilled />}<span>{s.pinned ? L.unpin : L.pin}</span>
304
+ </button>
305
+ ) : null}
306
+ <button type="button" role="menuitem" onClick={() => { setMenuId(null); beginEdit(s); }}>
307
+ <I.edit /><span>{L.rename}</span>
308
+ </button>
309
+ {onExport ? (
310
+ <button type="button" role="menuitem" onClick={() => { setMenuId(null); onExport(s.id); }}>
311
+ <I.exportIcon /><span>{L.exportMarkdown}</span>
312
+ </button>
313
+ ) : null}
314
+ {/* Two-step: first click asks for confirmation instead of deleting immediately. */}
315
+ <button type="button" role="menuitem" className="danger" onClick={() => setConfirmDeleteId(s.id)}>
316
+ <I.remove /><span>{L.remove}</span>
317
+ </button>
318
+ </>
319
+ )}
320
+ </div>
321
+ )}
322
+ </>
323
+ )}
324
+ </div>
325
+ );
326
+
327
+ return (
328
+ <aside className={`gemboss-assistant-rail${collapsed ? " is-collapsed" : ""}`}>
329
+ <div className="gemboss-assistant-rail-top">
330
+ <button type="button" className="gemboss-assistant-newchat" onClick={onNewChat} title={newChatTitle ?? L.newChat}>
331
+ <I.compose />
332
+ <span>{L.newChat}</span>
333
+ </button>
334
+ <button
335
+ type="button"
336
+ className="gemboss-assistant-collapse"
337
+ onClick={onToggleCollapsed}
338
+ aria-label={collapsed ? L.expand : L.collapse}
339
+ title={collapsed ? L.expandTitle : L.collapseTitle}
340
+ >
341
+ {collapsed ? <I.chevronRight /> : <I.chevronLeft />}
342
+ </button>
343
+ </div>
344
+
345
+ {sessions.length > 0 && (
346
+ <div className="gemboss-assistant-search gemboss-field gemboss-field--group">
347
+ <I.search />
348
+ <input className="gemboss-field__bare"
349
+ ref={searchRef}
350
+ value={query}
351
+ onChange={(e) => setQuery(e.target.value)}
352
+ onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setQuery(""); e.currentTarget.blur(); } }}
353
+ placeholder={L.searchPlaceholder}
354
+ aria-label={L.searchLabel}
355
+ />
356
+ {query
357
+ ? <button type="button" className="gemboss-assistant-search-clear" aria-label={L.clearSearch} onClick={() => setQuery("")}><I.close /></button>
358
+ : <kbd className="gemboss-assistant-kbd">/</kbd>}
359
+ </div>
360
+ )}
361
+
362
+ <div className="gemboss-assistant-threads">
363
+ {/* Ask the LIST what is empty, not the server's copy of it: an open chat mid-first-turn is in
364
+ `groups` and in nothing else. */}
365
+ {groups.length === 0 ? (
366
+ <div className="gemboss-assistant-threads-empty">
367
+ {query ? L.noneFound : L.noneYet}
368
+ </div>
369
+ ) : (
370
+ groups.map((g) => (
371
+ <div key={g.label || "search"} className="gemboss-assistant-group">
372
+ {g.label && <div className="gemboss-assistant-group-label">{g.label}</div>}
373
+ {g.items.map(renderThread)}
374
+ </div>
375
+ ))
376
+ )}
377
+ </div>
378
+
379
+ {footer}
380
+ </aside>
381
+ );
382
+ }
383
+
384
+ /** The popup's tabs — which of the chats I have OPEN is working right now. Shown only with 2+ open. */
385
+ export function OpenChatTabs({ chats, activeId, onSelect, onClose, labels, icons }: {
386
+ chats: Array<{ id: string; title?: string; running?: boolean }>;
387
+ activeId?: string;
388
+ onSelect: (id: string) => void;
389
+ onClose: (id: string) => void;
390
+ labels?: Partial<SessionRailLabels>;
391
+ icons?: Partial<SessionRailIcons>;
392
+ }) {
393
+ const L = { ...SESSION_RAIL_EN, ...labels };
394
+ const I = { ...DEFAULT_ICONS, ...icons };
395
+ // Untitled tabs are numbered by their POSITION, so two fresh chats are not both called "New chat".
396
+ const untitledCount = chats.filter((c) => !c.title).length;
397
+ return (
398
+ <div className="gemboss-assistant-tabs" role="tablist" aria-label={L.openChats}>
399
+ {chats.map((chat, index) => {
400
+ const running = !!chat.running;
401
+ const title = chat.title || (untitledCount > 1 ? L.newChatN(index + 1) : L.newChat);
402
+ return (
403
+ <div key={chat.id} className={`gemboss-assistant-tab${chat.id === activeId ? " active" : ""}`}>
404
+ <button
405
+ type="button"
406
+ role="tab"
407
+ aria-selected={chat.id === activeId}
408
+ className="gemboss-assistant-tab-main"
409
+ onClick={() => onSelect(chat.id)}
410
+ title={running ? L.tabWorking(title) : title}
411
+ >
412
+ <span className={`gemboss-assistant-tab-dot${running ? " is-running" : ""}`} aria-hidden />
413
+ <span className="gemboss-assistant-tab-title">{title}</span>
414
+ </button>
415
+ <button
416
+ type="button"
417
+ className="gemboss-assistant-tab-close"
418
+ onClick={() => onClose(chat.id)}
419
+ title={running ? L.closeChatRunning : L.closeChat}
420
+ >
421
+ <I.close />
422
+ <span className="gemboss-assistant-sronly">{running ? L.closeChatRunningSr : L.closeChatSr}</span>
423
+ </button>
424
+ </div>
425
+ );
426
+ })}
427
+ </div>
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Rail + page. `rail` is left out in the popup (`dock`), where there is no room for it.
433
+ * `after` renders inside the layout after the page (the admin's goal and memory drawers).
434
+ */
435
+ export function AssistantWorkspaceLayout({ dock, rail, switching, after, children }: {
436
+ dock?: boolean;
437
+ rail?: ReactNode;
438
+ /** A thread switch is loading: the current chat stays visible, dimmed, until the new one arrives. */
439
+ switching?: boolean;
440
+ after?: ReactNode;
441
+ children?: ReactNode;
442
+ }) {
443
+ return (
444
+ <div className={`gemboss-assistant-layout${dock ? " is-dock" : ""}`}>
445
+ {dock ? null : rail}
446
+ <div className={`gemboss-assistant-page${switching ? " is-switching" : ""}`}>{children}</div>
447
+ {after}
448
+ </div>
449
+ );
450
+ }