@gemboss/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -0
- package/catalog/index.html +423 -0
- package/gemboss-ui.css +2889 -0
- package/package.json +51 -0
- package/src/GemMark.tsx +22 -0
- package/src/account.tsx +88 -0
- package/src/app-shell.css +65 -0
- package/src/assistant/dock.ts +142 -0
- package/src/assistant/host.tsx +371 -0
- package/src/assistant/index.ts +6 -0
- package/src/assistant/provider.tsx +269 -0
- package/src/assistant/sessions.tsx +450 -0
- package/src/assistant.css +407 -0
- package/src/auth.css +606 -0
- package/src/auth.tsx +202 -0
- package/src/brand.tsx +31 -0
- package/src/gemboss-field.css +108 -0
- package/src/gemboss-tokens.css +164 -0
- package/src/icons.tsx +75 -0
- package/src/index.ts +9 -0
- package/src/shell.css +1069 -0
- package/src/shell.tsx +212 -0
- package/src/ui.css +403 -0
|
@@ -0,0 +1,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
|
+
}
|