cue-console 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,493 @@
1
+ "use client";
2
+
3
+ import {
4
+ useMemo,
5
+ type ChangeEvent,
6
+ type ClipboardEvent,
7
+ type Dispatch,
8
+ type MutableRefObject,
9
+ type RefObject,
10
+ type SetStateAction,
11
+ } from "react";
12
+ import { Button } from "@/components/ui/button";
13
+ import { cn, getAgentEmoji } from "@/lib/utils";
14
+ import { setAgentDisplayName } from "@/lib/actions";
15
+ import { Plus, Send, X } from "lucide-react";
16
+
17
+ type MentionDraft = {
18
+ userId: string;
19
+ start: number;
20
+ length: number;
21
+ display: string;
22
+ };
23
+
24
+ const shiftMentions = (from: number, delta: number, list: MentionDraft[]) => {
25
+ return list.map((m) => {
26
+ if (m.start >= from) return { ...m, start: m.start + delta };
27
+ return m;
28
+ });
29
+ };
30
+
31
+ const reconcileMentionsByDisplay = (text: string, list: MentionDraft[]) => {
32
+ const used = new Set<number>();
33
+ const next: MentionDraft[] = [];
34
+ for (const m of list) {
35
+ const windowStart = Math.max(0, m.start - 8);
36
+ const windowEnd = Math.min(text.length, m.start + 32);
37
+ const windowText = text.slice(windowStart, windowEnd);
38
+ const localIdx = windowText.indexOf(m.display);
39
+ let idx = -1;
40
+ if (localIdx >= 0) idx = windowStart + localIdx;
41
+ if (idx < 0) idx = text.indexOf(m.display);
42
+ if (idx >= 0 && !used.has(idx)) {
43
+ used.add(idx);
44
+ next.push({ ...m, start: idx, length: m.display.length });
45
+ }
46
+ }
47
+ next.sort((a, b) => a.start - b.start);
48
+ return next;
49
+ };
50
+
51
+ export function ChatComposer({
52
+ type,
53
+ onBack,
54
+ busy,
55
+ canSend,
56
+ hasPendingRequests,
57
+ input,
58
+ setInput,
59
+ images,
60
+ setImages,
61
+ setNotice,
62
+ setPreviewImage,
63
+ handleSend,
64
+ handlePaste,
65
+ handleImageUpload,
66
+ textareaRef,
67
+ fileInputRef,
68
+ inputWrapRef,
69
+ mentionOpen,
70
+ mentionPos,
71
+ mentionCandidates,
72
+ mentionActive,
73
+ setMentionActive,
74
+ mentionScrollable,
75
+ mentionPopoverRef,
76
+ mentionListRef,
77
+ pointerInMentionRef,
78
+ mentionScrollTopRef,
79
+ closeMention,
80
+ insertMention,
81
+ updateMentionFromCursor,
82
+ draftMentions,
83
+ setDraftMentions,
84
+ agentNameMap,
85
+ setAgentNameMap,
86
+ }: {
87
+ type: "agent" | "group";
88
+ onBack?: (() => void) | undefined;
89
+ busy: boolean;
90
+ canSend: boolean;
91
+ hasPendingRequests: boolean;
92
+ input: string;
93
+ setInput: Dispatch<SetStateAction<string>>;
94
+ images: { mime_type: string; base64_data: string }[];
95
+ setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string }[]>>;
96
+ setNotice: Dispatch<SetStateAction<string | null>>;
97
+ setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
98
+ handleSend: () => void | Promise<void>;
99
+ handlePaste: (e: ClipboardEvent<HTMLTextAreaElement>) => void;
100
+ handleImageUpload: (e: ChangeEvent<HTMLInputElement>) => void;
101
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
102
+ fileInputRef: RefObject<HTMLInputElement | null>;
103
+ inputWrapRef: RefObject<HTMLDivElement | null>;
104
+
105
+ mentionOpen: boolean;
106
+ mentionPos: { left: number; top: number } | null;
107
+ mentionCandidates: string[];
108
+ mentionActive: number;
109
+ setMentionActive: (v: number) => void;
110
+ mentionScrollable: boolean;
111
+ mentionPopoverRef: RefObject<HTMLDivElement | null>;
112
+ mentionListRef: RefObject<HTMLDivElement | null>;
113
+ pointerInMentionRef: MutableRefObject<boolean>;
114
+ mentionScrollTopRef: MutableRefObject<number>;
115
+ closeMention: () => void;
116
+ insertMention: (display: string, userId: string) => void;
117
+ updateMentionFromCursor: (nextText: string) => void;
118
+
119
+ draftMentions: MentionDraft[];
120
+ setDraftMentions: Dispatch<SetStateAction<MentionDraft[]>>;
121
+
122
+ agentNameMap: Record<string, string>;
123
+ setAgentNameMap: Dispatch<SetStateAction<Record<string, string>>>;
124
+ }) {
125
+ const composerStyle = useMemo(() => {
126
+ return onBack
127
+ ? ({ left: 0, right: 0 } as const)
128
+ : ({ left: "var(--cuehub-sidebar-w, 0px)", right: 0 } as const);
129
+ }, [onBack]);
130
+
131
+ return (
132
+ <>
133
+ {/* Input */}
134
+ <div className="fixed bottom-5 z-40 px-4" style={composerStyle}>
135
+ <div
136
+ ref={inputWrapRef}
137
+ className={cn(
138
+ "relative mx-auto flex w-full max-w-230 flex-col gap-1 rounded-4xl px-2 py-1",
139
+ "glass-surface glass-noise"
140
+ )}
141
+ >
142
+ {/* Image Preview */}
143
+ {images.length > 0 && (
144
+ <div className="flex max-w-full gap-2 overflow-x-auto px-0.5 pt-0.5">
145
+ {images.map((img, i) => (
146
+ <div key={i} className="relative shrink-0">
147
+ <img
148
+ src={`data:${img.mime_type};base64,${img.base64_data}`}
149
+ alt=""
150
+ className="h-16 w-16 rounded-xl object-cover shadow-sm ring-1 ring-border/60 cursor-pointer"
151
+ onClick={() => setPreviewImage(img)}
152
+ />
153
+ <button
154
+ className="absolute -right-1 -top-1 rounded-full bg-destructive p-0.5 text-white"
155
+ onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))}
156
+ >
157
+ <X className="h-3 w-3" />
158
+ </button>
159
+ </div>
160
+ ))}
161
+ </div>
162
+ )}
163
+
164
+ {mentionOpen && type === "group" && (
165
+ <div
166
+ ref={mentionPopoverRef}
167
+ className={cn(
168
+ "absolute mb-2",
169
+ "w-auto max-w-130",
170
+ "rounded-2xl glass-surface glass-noise"
171
+ )}
172
+ style={
173
+ mentionPos
174
+ ? {
175
+ left: mentionPos.left,
176
+ top: mentionPos.top,
177
+ transform: "translateY(-100%)",
178
+ }
179
+ : undefined
180
+ }
181
+ onPointerDownCapture={() => {
182
+ pointerInMentionRef.current = true;
183
+ }}
184
+ onPointerUpCapture={() => {
185
+ pointerInMentionRef.current = false;
186
+ }}
187
+ onPointerCancelCapture={() => {
188
+ pointerInMentionRef.current = false;
189
+ }}
190
+ onMouseEnter={() => {
191
+ pointerInMentionRef.current = true;
192
+ }}
193
+ onMouseLeave={() => {
194
+ pointerInMentionRef.current = false;
195
+ }}
196
+ onWheel={(e) => {
197
+ e.stopPropagation();
198
+ }}
199
+ >
200
+ <div className="flex items-center justify-between px-3 pt-2">
201
+ <p className="text-[11px] text-muted-foreground">Mention members</p>
202
+ <p className="text-[11px] text-muted-foreground">↑↓ / Enter</p>
203
+ </div>
204
+ <div
205
+ ref={mentionListRef}
206
+ className={cn(
207
+ "px-1 pb-2 pt-1",
208
+ mentionScrollable ? "max-h-28 overflow-y-auto" : "overflow-hidden"
209
+ )}
210
+ onWheel={(e) => {
211
+ e.stopPropagation();
212
+ }}
213
+ onScroll={(e) => {
214
+ mentionScrollTopRef.current = (e.currentTarget as HTMLDivElement).scrollTop;
215
+ }}
216
+ >
217
+ {mentionCandidates.length === 0 ? (
218
+ <div className="px-3 py-2 text-sm text-muted-foreground">No matches</div>
219
+ ) : (
220
+ mentionCandidates.map((m, idx) => {
221
+ const isAll = m === "all";
222
+ const label = isAll ? "All" : agentNameMap[m] || m;
223
+ const active = idx === mentionActive;
224
+ return (
225
+ <button
226
+ key={m}
227
+ type="button"
228
+ data-mention-active={active ? "true" : "false"}
229
+ className={cn(
230
+ "w-full flex items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm",
231
+ active ? "bg-accent" : "hover:bg-accent/50"
232
+ )}
233
+ onMouseEnter={() => setMentionActive(idx)}
234
+ onClick={() => {
235
+ insertMention(label, isAll ? "all" : m);
236
+ }}
237
+ >
238
+ <span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[12px]">
239
+ {isAll ? "@" : getAgentEmoji(m)}
240
+ </span>
241
+ <span
242
+ className="flex-1 truncate"
243
+ onDoubleClick={(e) => {
244
+ if (isAll) return;
245
+ e.preventDefault();
246
+ e.stopPropagation();
247
+ const current = agentNameMap[m] || m;
248
+ const next = window.prompt(`Rename: ${m}`, current);
249
+ if (!next) return;
250
+ void (async () => {
251
+ await setAgentDisplayName(m, next);
252
+ setAgentNameMap((prev) => ({ ...prev, [m]: next.trim() }));
253
+ })();
254
+ }}
255
+ title={isAll ? undefined : "Double-click to rename"}
256
+ >
257
+ @{label}
258
+ </span>
259
+ </button>
260
+ );
261
+ })
262
+ )}
263
+ </div>
264
+ </div>
265
+ )}
266
+
267
+ {/* Row 1: textarea */}
268
+ <div
269
+ className="px-0.5 cursor-text"
270
+ onPointerDown={(e) => {
271
+ if (busy) return;
272
+ const ta = textareaRef.current;
273
+ if (!ta) return;
274
+ const target = e.target as Node | null;
275
+ if (target && ta.contains(target)) return;
276
+ ta.focus();
277
+ }}
278
+ >
279
+ <textarea
280
+ ref={textareaRef}
281
+ placeholder={
282
+ hasPendingRequests
283
+ ? type === "group"
284
+ ? "Type... (Enter to send, Shift+Enter for newline, supports @)"
285
+ : "Type... (Enter to send, Shift+Enter for newline)"
286
+ : "Waiting for new pending requests..."
287
+ }
288
+ title={
289
+ !hasPendingRequests
290
+ ? "No pending requests (PENDING/PROCESSING). Send button is disabled."
291
+ : type === "group"
292
+ ? "Type @ to mention members; ↑↓ to navigate, Enter to insert; Enter to send, Shift+Enter for newline"
293
+ : "Enter to send, Shift+Enter for newline"
294
+ }
295
+ value={input}
296
+ onPaste={handlePaste}
297
+ onChange={(e) => {
298
+ const next = e.target.value;
299
+ setInput(next);
300
+ setDraftMentions((prev) => reconcileMentionsByDisplay(next, prev));
301
+ updateMentionFromCursor(next);
302
+ }}
303
+ onKeyDown={(e) => {
304
+ if (type === "group" && e.key === "@") {
305
+ requestAnimationFrame(() => updateMentionFromCursor(input));
306
+ }
307
+
308
+ if (mentionOpen) {
309
+ if (e.key === "ArrowDown") {
310
+ e.preventDefault();
311
+ const next = Math.min(mentionActive + 1, mentionCandidates.length - 1);
312
+ setMentionActive(next);
313
+ requestAnimationFrame(() => {
314
+ const list = mentionListRef.current;
315
+ if (!list) return;
316
+ const btn = list.querySelector<HTMLButtonElement>(
317
+ `button[data-mention-active='true']`
318
+ );
319
+ const fallback = list.querySelectorAll<HTMLButtonElement>(
320
+ 'button[type="button"]'
321
+ )[next];
322
+ (btn || fallback)?.scrollIntoView({ block: "nearest" });
323
+ });
324
+ return;
325
+ }
326
+ if (e.key === "ArrowUp") {
327
+ e.preventDefault();
328
+ const next = Math.max(mentionActive - 1, 0);
329
+ setMentionActive(next);
330
+ requestAnimationFrame(() => {
331
+ const list = mentionListRef.current;
332
+ if (!list) return;
333
+ const btn = list.querySelector<HTMLButtonElement>(
334
+ `button[data-mention-active='true']`
335
+ );
336
+ const fallback = list.querySelectorAll<HTMLButtonElement>(
337
+ 'button[type="button"]'
338
+ )[next];
339
+ (btn || fallback)?.scrollIntoView({ block: "nearest" });
340
+ });
341
+ return;
342
+ }
343
+ if (e.key === "Enter") {
344
+ e.preventDefault();
345
+ const picked = mentionCandidates[mentionActive];
346
+ if (picked) {
347
+ if (picked === "all") insertMention("all", "all");
348
+ else insertMention(picked, picked);
349
+ }
350
+ return;
351
+ }
352
+ if (e.key === "Escape") {
353
+ e.preventDefault();
354
+ closeMention();
355
+ return;
356
+ }
357
+ }
358
+
359
+ if (e.key === "Backspace" || e.key === "Delete") {
360
+ const el = textareaRef.current;
361
+ if (!el) return;
362
+ const start = el.selectionStart ?? 0;
363
+ const end = el.selectionEnd ?? start;
364
+ const hit = draftMentions.find(
365
+ (m) =>
366
+ (start > m.start && start <= m.start + m.length) ||
367
+ (end > m.start && end <= m.start + m.length) ||
368
+ (start <= m.start && end >= m.start + m.length)
369
+ );
370
+ if (hit) {
371
+ e.preventDefault();
372
+ const before = input.slice(0, hit.start);
373
+ const after = input.slice(hit.start + hit.length);
374
+ const next = before + after;
375
+ setInput(next);
376
+ setDraftMentions((prev) =>
377
+ shiftMentions(
378
+ hit.start + hit.length,
379
+ -hit.length,
380
+ prev.filter((m) => m !== hit)
381
+ )
382
+ );
383
+ requestAnimationFrame(() => {
384
+ const cur = textareaRef.current;
385
+ if (!cur) return;
386
+ cur.setSelectionRange(hit.start, hit.start);
387
+ });
388
+ closeMention();
389
+ return;
390
+ }
391
+ }
392
+
393
+ if (e.key === "Enter" && !e.shiftKey) {
394
+ e.preventDefault();
395
+ if (canSend) void handleSend();
396
+ }
397
+ }}
398
+ onKeyUp={() => {
399
+ if (document.activeElement !== textareaRef.current) return;
400
+ updateMentionFromCursor(input);
401
+ }}
402
+ onSelect={() => {
403
+ if (document.activeElement !== textareaRef.current) return;
404
+ updateMentionFromCursor(input);
405
+ }}
406
+ onBlur={() => {
407
+ setTimeout(() => {
408
+ const cur = document.activeElement;
409
+ const ta = textareaRef.current;
410
+ const pop = mentionPopoverRef.current;
411
+ if (cur && ta && cur === ta) return;
412
+ if (cur && pop && pop.contains(cur)) return;
413
+ if (pointerInMentionRef.current) return;
414
+ closeMention();
415
+ }, 120);
416
+ }}
417
+ disabled={busy}
418
+ className={cn(
419
+ "w-full resize-none rounded-2xl bg-transparent px-1 pt-1.5 pb-0.5 text-sm border-0 outline-none ring-0",
420
+ "leading-6",
421
+ "min-h-9 max-h-36 overflow-y-auto",
422
+ "focus-visible:ring-0 focus-visible:ring-offset-0",
423
+ "disabled:opacity-60 disabled:cursor-not-allowed"
424
+ )}
425
+ rows={1}
426
+ />
427
+ </div>
428
+
429
+ {/* Row 2: toolbar */}
430
+ <div className="flex items-center justify-between gap-2 px-0.5 pb-0">
431
+ <div className="flex min-w-0 items-center gap-1 overflow-x-auto">
432
+ <Button
433
+ type="button"
434
+ variant="ghost"
435
+ size="icon"
436
+ className={cn(
437
+ "h-8 w-8 rounded-xl",
438
+ "text-muted-foreground hover:text-foreground",
439
+ "hover:bg-white/40"
440
+ )}
441
+ onClick={() => fileInputRef.current?.click()}
442
+ disabled={busy}
443
+ title="Add image"
444
+ >
445
+ <Plus className="h-4.5 w-4.5" />
446
+ </Button>
447
+ </div>
448
+
449
+ <Button
450
+ type="button"
451
+ onClick={() => {
452
+ if (canSend) {
453
+ void handleSend();
454
+ return;
455
+ }
456
+ if (!hasPendingRequests) {
457
+ setNotice("No pending questions to answer.");
458
+ return;
459
+ }
460
+ if (!input.trim() && images.length === 0) {
461
+ setNotice("Enter a message to send, or select an image.");
462
+ return;
463
+ }
464
+ setNotice("Unable to send right now. Please try again later.");
465
+ }}
466
+ disabled={busy || (!canSend && (!input.trim() && images.length === 0))}
467
+ className={cn(
468
+ "h-8 w-8 rounded-xl p-0",
469
+ canSend
470
+ ? "bg-primary text-primary-foreground hover:bg-primary/90"
471
+ : "bg-transparent text-muted-foreground hover:bg-white/40",
472
+ (busy || (!canSend && (!input.trim() && images.length === 0))) &&
473
+ "opacity-40 hover:bg-transparent"
474
+ )}
475
+ title={canSend ? "Send" : "Enter a message"}
476
+ >
477
+ <Send className="h-4 w-4" />
478
+ </Button>
479
+ </div>
480
+
481
+ <input
482
+ ref={fileInputRef}
483
+ type="file"
484
+ accept="image/*"
485
+ multiple
486
+ className="hidden"
487
+ onChange={handleImageUpload}
488
+ />
489
+ </div>
490
+ </div>
491
+ </>
492
+ );
493
+ }