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,1463 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ClipboardEvent,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { ScrollArea } from "@/components/ui/scroll-area";
13
+ import { Button } from "@/components/ui/button";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from "@/components/ui/dialog";
21
+ import {
22
+ cn,
23
+ getAgentEmoji,
24
+ formatFullTime,
25
+ getWaitingDuration,
26
+ } from "@/lib/utils";
27
+ import {
28
+ fetchAgentTimeline,
29
+ fetchGroupTimeline,
30
+ fetchGroupMembers,
31
+ fetchAgentDisplayNames,
32
+ setAgentDisplayName,
33
+ setGroupName,
34
+ submitResponse,
35
+ cancelRequest,
36
+ batchRespond,
37
+ type CueRequest,
38
+ type CueResponse,
39
+ type AgentTimelineItem,
40
+ } from "@/lib/actions";
41
+ import { ChevronLeft } from "lucide-react";
42
+ import { MarkdownRenderer } from "@/components/markdown-renderer";
43
+ import { PayloadCard } from "@/components/payload-card";
44
+ import { ChatComposer } from "@/components/chat-composer";
45
+
46
+ type MentionDraft = {
47
+ userId: string;
48
+ start: number;
49
+ length: number;
50
+ display: string;
51
+ };
52
+
53
+ interface ChatViewProps {
54
+ type: "agent" | "group";
55
+ id: string;
56
+ name: string;
57
+ onBack?: () => void;
58
+ }
59
+
60
+ export function ChatView({ type, id, name, onBack }: ChatViewProps) {
61
+ const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
62
+ const [nextCursor, setNextCursor] = useState<string | null>(null);
63
+ const [loadingMore, setLoadingMore] = useState(false);
64
+ const [isAtBottom, setIsAtBottom] = useState(true);
65
+ const [members, setMembers] = useState<string[]>([]);
66
+ const [agentNameMap, setAgentNameMap] = useState<Record<string, string>>({});
67
+ const [editingTitle, setEditingTitle] = useState(false);
68
+ const [titleDraft, setTitleDraft] = useState("");
69
+ const [groupTitle, setGroupTitle] = useState(name);
70
+ const [input, setInput] = useState("");
71
+ const [busy, setBusy] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const [notice, setNotice] = useState<string | null>(null);
74
+ const [images, setImages] = useState<
75
+ { mime_type: string; base64_data: string }[]
76
+ >([]);
77
+ const imagesRef = useRef<{ mime_type: string; base64_data: string }[]>([]);
78
+ const [previewImage, setPreviewImage] = useState<
79
+ { mime_type: string; base64_data: string } | null
80
+ >(null);
81
+
82
+ const [draftMentions, setDraftMentions] = useState<MentionDraft[]>([]);
83
+ const [mentionOpen, setMentionOpen] = useState(false);
84
+ const [mentionQuery, setMentionQuery] = useState("");
85
+ const [mentionActive, setMentionActive] = useState(0);
86
+ const [mentionAtIndex, setMentionAtIndex] = useState<number | null>(null);
87
+
88
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
89
+ const inputWrapRef = useRef<HTMLDivElement>(null);
90
+ const mentionListRef = useRef<HTMLDivElement>(null);
91
+ const mentionPopoverRef = useRef<HTMLDivElement>(null);
92
+ const fileInputRef = useRef<HTMLInputElement>(null);
93
+ const scrollRef = useRef<HTMLDivElement>(null);
94
+
95
+ const [mentionPos, setMentionPos] = useState<{ left: number; top: number } | null>(
96
+ null
97
+ );
98
+ const prevMentionQueryRef = useRef<string>("");
99
+ const prevMentionOpenRef = useRef<boolean>(false);
100
+ const shouldAutoScrollMentionRef = useRef<boolean>(false);
101
+ const mentionScrollTopRef = useRef<number>(0);
102
+ const pointerInMentionRef = useRef<boolean>(false);
103
+
104
+ const nextCursorRef = useRef<string | null>(null);
105
+ const loadingMoreRef = useRef(false);
106
+
107
+ const PAGE_SIZE = 30;
108
+
109
+ const IMAGE_MAX_DIM = 1600;
110
+ const IMAGE_COMPRESS_QUALITY = 0.82;
111
+ const IMAGE_COMPRESS_THRESHOLD_BYTES = 1_200_000;
112
+
113
+ const readAsDataUrl = (file: Blob) =>
114
+ new Promise<string>((resolve, reject) => {
115
+ const reader = new FileReader();
116
+ reader.onload = () => resolve(String(reader.result || ""));
117
+ reader.onerror = () => reject(new Error("read failed"));
118
+ reader.readAsDataURL(file);
119
+ });
120
+
121
+ const fileToImage = (file: File) =>
122
+ new Promise<HTMLImageElement>((resolve, reject) => {
123
+ const url = URL.createObjectURL(file);
124
+ const img = new Image();
125
+ img.onload = () => {
126
+ URL.revokeObjectURL(url);
127
+ resolve(img);
128
+ };
129
+ img.onerror = () => {
130
+ URL.revokeObjectURL(url);
131
+ reject(new Error("image load failed"));
132
+ };
133
+ img.src = url;
134
+ });
135
+
136
+ const maybeCompressImageFile = async (file: File) => {
137
+ const inputType = (file.type || "").trim();
138
+ const shouldTryCompress =
139
+ file.size >= IMAGE_COMPRESS_THRESHOLD_BYTES ||
140
+ !inputType.startsWith("image/") ||
141
+ inputType === "image/png";
142
+
143
+ if (!shouldTryCompress) return { blob: file as Blob, mime: inputType };
144
+
145
+ const img = await fileToImage(file);
146
+ const w = img.naturalWidth || img.width;
147
+ const h = img.naturalHeight || img.height;
148
+ if (!w || !h) return { blob: file as Blob, mime: inputType };
149
+
150
+ const scale = Math.min(1, IMAGE_MAX_DIM / Math.max(w, h));
151
+ const outW = Math.max(1, Math.round(w * scale));
152
+ const outH = Math.max(1, Math.round(h * scale));
153
+
154
+ const canvas = document.createElement("canvas");
155
+ canvas.width = outW;
156
+ canvas.height = outH;
157
+ const ctx = canvas.getContext("2d");
158
+ if (!ctx) return { blob: file as Blob, mime: inputType };
159
+ ctx.drawImage(img, 0, 0, outW, outH);
160
+
161
+ const outMime = inputType === "image/webp" ? "image/webp" : "image/jpeg";
162
+ const blob = await new Promise<Blob>((resolve, reject) => {
163
+ canvas.toBlob(
164
+ (b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
165
+ outMime,
166
+ IMAGE_COMPRESS_QUALITY
167
+ );
168
+ });
169
+
170
+ if (blob.size >= file.size) return { blob: file as Blob, mime: inputType };
171
+ return { blob, mime: outMime };
172
+ };
173
+
174
+ const fileToInlineImage = async (file: File) => {
175
+ const { blob, mime } = await maybeCompressImageFile(file);
176
+ const dataUrl = await readAsDataUrl(blob);
177
+ const comma = dataUrl.indexOf(",");
178
+ if (comma < 0) throw new Error("invalid data url");
179
+ const header = dataUrl.slice(0, comma);
180
+ const base64 = dataUrl.slice(comma + 1);
181
+ const m = /data:([^;]+);base64/i.exec(header);
182
+ const rawMime = (m?.[1] || mime || file.type || "").trim();
183
+ const finalMime = rawMime.startsWith("image/") ? rawMime : "image/png";
184
+ if (!base64 || base64.length < 16) throw new Error("empty base64");
185
+ return { mime_type: finalMime, base64_data: base64 };
186
+ };
187
+
188
+ useEffect(() => {
189
+ imagesRef.current = images;
190
+ }, [images]);
191
+
192
+ const titleDisplay = useMemo(() => {
193
+ if (type === "agent") return agentNameMap[id] || id;
194
+ return groupTitle;
195
+ }, [agentNameMap, groupTitle, id, type]);
196
+
197
+ useEffect(() => {
198
+ if (type !== "group") return;
199
+ setGroupTitle(name);
200
+ }, [name, type]);
201
+
202
+ const keyForItem = (item: AgentTimelineItem) => {
203
+ return item.item_type === "request"
204
+ ? `req:${item.request.request_id}`
205
+ : `resp:${item.response.id}`;
206
+ };
207
+
208
+ const handlePaste = useCallback(
209
+ async (e: ClipboardEvent<HTMLTextAreaElement>) => {
210
+ const cd = e.clipboardData;
211
+ if (!cd) return;
212
+
213
+ const imageFilesFromItems: File[] = [];
214
+ for (const it of Array.from(cd.items || [])) {
215
+ if (it.kind !== "file") continue;
216
+ const f = it.getAsFile();
217
+ if (!f) continue;
218
+ const itemType = (it.type || "").trim();
219
+ const fileType = (f.type || "").trim();
220
+ const isImage =
221
+ (itemType && itemType.startsWith("image/")) ||
222
+ (fileType && fileType.startsWith("image/"));
223
+ if (isImage) imageFilesFromItems.push(f);
224
+ }
225
+
226
+ // Prefer items over files. Some environments expose the same image in both,
227
+ // and the metadata differs enough that naive dedupe fails.
228
+ const imageFiles: File[] =
229
+ imageFilesFromItems.length > 0
230
+ ? imageFilesFromItems
231
+ : Array.from(cd.files || []).filter((f) => (f?.type || "").startsWith("image/"));
232
+
233
+ if (imageFiles.length === 0) {
234
+ setNotice("No pasteable images detected (make sure your clipboard contains an image)");
235
+ return;
236
+ }
237
+
238
+ // If images are present, treat this paste as an image attach.
239
+ // Prevent unexpected text insertion (some browsers may paste placeholder text).
240
+ e.preventDefault();
241
+
242
+ try {
243
+ const nextImages: { mime_type: string; base64_data: string }[] = [];
244
+ const failures: string[] = [];
245
+ const seen = new Set<string>();
246
+
247
+ const fingerprint = (img: { mime_type: string; base64_data: string }) => {
248
+ const head = img.base64_data.slice(0, 64);
249
+ return `${img.mime_type}|${img.base64_data.length}|${head}`;
250
+ };
251
+
252
+ for (const f of imageFiles) {
253
+ try {
254
+ const img = await fileToInlineImage(f);
255
+ const key = fingerprint(img);
256
+ if (seen.has(key)) continue;
257
+ seen.add(key);
258
+ nextImages.push(img);
259
+ } catch (err) {
260
+ const msg = err instanceof Error ? err.message : String(err);
261
+ failures.push(msg || "unknown error");
262
+ }
263
+ }
264
+
265
+ if (nextImages.length > 0) {
266
+ setImages((prev) => [...prev, ...nextImages]);
267
+ if (failures.length > 0) {
268
+ setNotice(
269
+ `Added ${nextImages.length} image(s) from paste; failed ${failures.length}: ${failures[0]}`
270
+ );
271
+ } else {
272
+ setNotice(`Added ${nextImages.length} image(s) from paste`);
273
+ }
274
+ } else {
275
+ setNotice(`Detected images but failed to parse: ${failures[0] || "unknown error"}`);
276
+ }
277
+ } catch (err) {
278
+ const msg = err instanceof Error ? err.message : String(err);
279
+ setNotice(`Failed to read clipboard image: ${msg || "unknown error"}`);
280
+ }
281
+ },
282
+ [fileToInlineImage]
283
+ );
284
+
285
+ const beginEditTitle = () => {
286
+ setEditingTitle(true);
287
+ setTitleDraft(type === "agent" ? agentNameMap[id] || id : groupTitle);
288
+ setTimeout(() => {
289
+ const el = document.getElementById("chat-title-input");
290
+ if (el instanceof HTMLInputElement) el.focus();
291
+ }, 0);
292
+ };
293
+
294
+ const commitEditTitle = async () => {
295
+ const next = titleDraft.trim();
296
+ setEditingTitle(false);
297
+ if (!next) return;
298
+ if (type === "agent") {
299
+ if (next === (agentNameMap[id] || id)) return;
300
+ await setAgentDisplayName(id, next);
301
+ setAgentNameMap((prev) => ({ ...prev, [id]: next }));
302
+ window.dispatchEvent(
303
+ new CustomEvent("cuehub:agentDisplayNameUpdated", {
304
+ detail: { agentId: id, displayName: next },
305
+ })
306
+ );
307
+ return;
308
+ }
309
+ if (next === groupTitle) return;
310
+ await setGroupName(id, next);
311
+ setGroupTitle(next);
312
+ };
313
+
314
+ useEffect(() => {
315
+ nextCursorRef.current = nextCursor;
316
+ }, [nextCursor]);
317
+
318
+ useEffect(() => {
319
+ loadingMoreRef.current = loadingMore;
320
+ }, [loadingMore]);
321
+
322
+ const requestsById = useMemo(() => {
323
+ const map = new Map<string, CueRequest>();
324
+ for (const item of timeline) {
325
+ if (item.item_type === "request") {
326
+ map.set(item.request.request_id, item.request);
327
+ }
328
+ }
329
+ return map;
330
+ }, [timeline]);
331
+
332
+ const pendingRequests = useMemo(() => {
333
+ const list: CueRequest[] = [];
334
+ for (const req of requestsById.values()) {
335
+ if (req.status === "PENDING") {
336
+ list.push(req);
337
+ }
338
+ }
339
+ return list;
340
+ }, [requestsById]);
341
+
342
+ const mentionCandidates = useMemo(() => {
343
+ if (type !== "group") return [];
344
+ const q = mentionQuery.trim().toLowerCase();
345
+
346
+ const base = members
347
+ .filter((agentId) => {
348
+ if (!q) return true;
349
+ const label = (agentNameMap[agentId] || agentId).toLowerCase();
350
+ return label.includes(q) || agentId.toLowerCase().includes(q);
351
+ })
352
+ .sort((a, b) => {
353
+ const la = agentNameMap[a] || a;
354
+ const lb = agentNameMap[b] || b;
355
+ return la.localeCompare(lb);
356
+ });
357
+
358
+ const all = q.length === 0 ? ["all", ...base] : base;
359
+ return all;
360
+ }, [agentNameMap, members, mentionQuery, type]);
361
+
362
+ const mentionScrollable = mentionCandidates.length > 5;
363
+
364
+ useEffect(() => {
365
+ const loadNames = async () => {
366
+ try {
367
+ const ids = type === "group" ? Array.from(new Set([id, ...members])) : [id];
368
+ const map = await fetchAgentDisplayNames(ids);
369
+ setAgentNameMap(map);
370
+ } catch {
371
+ // ignore
372
+ }
373
+ };
374
+
375
+ void loadNames();
376
+ }, [id, members, type]);
377
+
378
+ const closeMention = () => {
379
+ setMentionQuery("");
380
+ setMentionOpen(false);
381
+ setMentionActive(0);
382
+ setMentionPos(null);
383
+ };
384
+
385
+ const insertMentionAtCursor = (userId: string, name: string) => {
386
+ const el = textareaRef.current;
387
+ if (!el) return;
388
+ const text = input;
389
+
390
+ const cursorStart = el.selectionStart ?? text.length;
391
+ const cursorEnd = el.selectionEnd ?? cursorStart;
392
+
393
+ const display = name === "all" ? "@all" : `@${name}`;
394
+ const insertion = `${display} `;
395
+
396
+ const before = text.slice(0, cursorStart);
397
+ const after = text.slice(cursorEnd);
398
+ const nextText = before + insertion + after;
399
+ const delta = insertion.length - (cursorEnd - cursorStart);
400
+
401
+ const start = cursorStart;
402
+ const mention: MentionDraft = {
403
+ userId,
404
+ start,
405
+ length: display.length,
406
+ display,
407
+ };
408
+
409
+ setInput(nextText);
410
+ setDraftMentions((prev) => {
411
+ const shifted = shiftMentions(cursorEnd, delta, prev);
412
+ return [...shifted, mention].sort((a, b) => a.start - b.start);
413
+ });
414
+
415
+ requestAnimationFrame(() => {
416
+ const cur = textareaRef.current;
417
+ if (!cur) return;
418
+ const pos = start + insertion.length;
419
+ cur.focus();
420
+ cur.setSelectionRange(pos, pos);
421
+ });
422
+
423
+ closeMention();
424
+ };
425
+
426
+ const pasteToInput = (text: string, mode: "replace" | "append" = "replace") => {
427
+ const cleaned = (text || "").trim();
428
+ if (!cleaned) return;
429
+
430
+ const next = (() => {
431
+ if (mode !== "append") return cleaned;
432
+
433
+ const lines = input
434
+ .split(/\r?\n/)
435
+ .map((s) => s.trim())
436
+ .filter(Boolean);
437
+ const exists = new Set(lines);
438
+ if (exists.has(cleaned)) return input;
439
+
440
+ const base = input.trim() ? input.replace(/\s+$/, "") : "";
441
+ return base ? base + "\n" + cleaned : cleaned;
442
+ })();
443
+
444
+ setInput(next);
445
+ setDraftMentions((prev) => reconcileMentionsByDisplay(next, prev));
446
+ closeMention();
447
+
448
+ requestAnimationFrame(() => {
449
+ const el = textareaRef.current;
450
+ if (!el) return;
451
+ el.focus();
452
+ const pos = el.value.length;
453
+ el.setSelectionRange(pos, pos);
454
+ });
455
+ };
456
+
457
+ const getCaretCoords = (el: HTMLTextAreaElement, pos: number) => {
458
+ const style = window.getComputedStyle(el);
459
+ const div = document.createElement("div");
460
+ div.style.position = "absolute";
461
+ div.style.visibility = "hidden";
462
+ div.style.whiteSpace = "pre-wrap";
463
+ div.style.wordWrap = "break-word";
464
+
465
+ // mirror styles that affect layout
466
+ div.style.font = style.font;
467
+ div.style.letterSpacing = style.letterSpacing;
468
+ div.style.textTransform = style.textTransform;
469
+ div.style.padding = style.padding;
470
+ div.style.border = style.border;
471
+ div.style.boxSizing = style.boxSizing;
472
+ div.style.lineHeight = style.lineHeight;
473
+ div.style.width = style.width;
474
+
475
+ const value = el.value;
476
+ div.textContent = value.substring(0, pos);
477
+
478
+ const span = document.createElement("span");
479
+ span.textContent = value.substring(pos) || ".";
480
+ div.appendChild(span);
481
+
482
+ document.body.appendChild(div);
483
+ const rect = span.getBoundingClientRect();
484
+ const divRect = div.getBoundingClientRect();
485
+ document.body.removeChild(div);
486
+
487
+ return { left: rect.left - divRect.left, top: rect.top - divRect.top };
488
+ };
489
+
490
+ const updateMentionPosition = () => {
491
+ const ta = textareaRef.current;
492
+ const wrap = inputWrapRef.current;
493
+ if (!ta || !wrap) return;
494
+ const cursor = ta.selectionStart ?? ta.value.length;
495
+ const caret = getCaretCoords(ta, cursor);
496
+ const taRect = ta.getBoundingClientRect();
497
+ const wrapRect = wrap.getBoundingClientRect();
498
+ const left = taRect.left + caret.left - wrapRect.left;
499
+ const top = taRect.top + caret.top - wrapRect.top;
500
+ setMentionPos({ left, top });
501
+ };
502
+
503
+ useEffect(() => {
504
+ if (!mentionOpen) return;
505
+ if (!mentionPos) return;
506
+
507
+ requestAnimationFrame(() => {
508
+ // clamp within container
509
+ const wrap = inputWrapRef.current;
510
+ const pop = mentionPopoverRef.current;
511
+ if (!wrap || !pop) return;
512
+ const wrapW = wrap.clientWidth;
513
+ const wrapH = wrap.clientHeight;
514
+ const popW = pop.offsetWidth;
515
+ const popH = pop.offsetHeight;
516
+ const padding = 12;
517
+ const clampedLeft = Math.min(
518
+ Math.max(mentionPos.left, padding),
519
+ Math.max(padding, wrapW - popW - padding)
520
+ );
521
+ const clampedTop = Math.min(
522
+ Math.max(mentionPos.top, padding),
523
+ Math.max(padding, wrapH - popH - padding)
524
+ );
525
+ if (clampedLeft !== mentionPos.left || clampedTop !== mentionPos.top) {
526
+ setMentionPos((p) =>
527
+ p ? { ...p, left: clampedLeft, top: clampedTop } : p
528
+ );
529
+ }
530
+ });
531
+ }, [mentionOpen, mentionPos]);
532
+
533
+ // Note: we intentionally do NOT restore scrollTop on every render.
534
+ // This avoids "scroll jumping". We only set scrollTop when query changes.
535
+
536
+ useEffect(() => {
537
+ if (mentionOpen) return;
538
+ prevMentionOpenRef.current = false;
539
+ prevMentionQueryRef.current = "";
540
+ }, [mentionOpen]);
541
+
542
+ const updateMentionFromCursor = (text: string) => {
543
+ if (type !== "group") return;
544
+ // While the user is interacting with the mention popover (scrollbar/scroll),
545
+ // do not recompute mention state; it can reset query/active and jump scroll.
546
+ if (pointerInMentionRef.current) return;
547
+ const el = textareaRef.current;
548
+ if (!el) return;
549
+ const cursor = el.selectionStart ?? text.length;
550
+ const at = text.lastIndexOf("@", cursor - 1);
551
+ if (at < 0) {
552
+ closeMention();
553
+ return;
554
+ }
555
+
556
+ const before = at === 0 ? "" : text[at - 1];
557
+ const allowedBefore =
558
+ at === 0 ||
559
+ /\s/.test(before) ||
560
+ /[\(\[\{\<\>\-—_,.,。!?!?:;;“”"'、]/.test(before);
561
+
562
+ // avoid email/identifier like a@b
563
+ if (!allowedBefore) {
564
+ closeMention();
565
+ return;
566
+ }
567
+
568
+ const after = text.slice(at + 1, cursor);
569
+ if (after.includes(" ") || after.includes("\n") || after.includes("\t")) {
570
+ closeMention();
571
+ return;
572
+ }
573
+
574
+ // If nothing changed, don't touch state (prevents scroll reset / active reset)
575
+ if (mentionOpen && mentionAtIndex === at && mentionQuery === after) {
576
+ return;
577
+ }
578
+
579
+ // email heuristic: something@something.com while cursor after @ part
580
+ if (/[\w.+-]+@[\w-]+\.[\w.-]+/.test(text.slice(Math.max(0, at - 32), cursor + 32))) {
581
+ closeMention();
582
+ return;
583
+ }
584
+
585
+ setMentionAtIndex(at);
586
+ setMentionQuery(after);
587
+ setMentionOpen(true);
588
+ setMentionActive(0);
589
+ requestAnimationFrame(() => {
590
+ updateMentionPosition();
591
+ });
592
+ };
593
+
594
+ useEffect(() => {
595
+ if (!mentionOpen) return;
596
+ const el = mentionListRef.current;
597
+ if (!el) return;
598
+
599
+ // Reset only when opening, or when query actually changes (avoid stealing scroll)
600
+ const queryChanged = prevMentionQueryRef.current !== mentionQuery;
601
+ if (queryChanged) {
602
+ // filtering should jump to the first match like WeChat
603
+ shouldAutoScrollMentionRef.current = true;
604
+ setMentionActive(0);
605
+ el.scrollTop = 0;
606
+ mentionScrollTopRef.current = 0;
607
+ }
608
+ prevMentionOpenRef.current = true;
609
+ prevMentionQueryRef.current = mentionQuery;
610
+ }, [mentionOpen, mentionQuery]);
611
+
612
+ const shiftMentions = (from: number, delta: number, list: MentionDraft[]) => {
613
+ return list.map((m) => {
614
+ if (m.start >= from) return { ...m, start: m.start + delta };
615
+ return m;
616
+ });
617
+ };
618
+
619
+ const reconcileMentionsByDisplay = (text: string, list: MentionDraft[]) => {
620
+ const used = new Set<number>();
621
+ const next: MentionDraft[] = [];
622
+ for (const m of list) {
623
+ const windowStart = Math.max(0, m.start - 8);
624
+ const windowEnd = Math.min(text.length, m.start + 32);
625
+ const windowText = text.slice(windowStart, windowEnd);
626
+ const localIdx = windowText.indexOf(m.display);
627
+ let idx = -1;
628
+ if (localIdx >= 0) idx = windowStart + localIdx;
629
+ if (idx < 0) idx = text.indexOf(m.display);
630
+ if (idx >= 0 && !used.has(idx)) {
631
+ used.add(idx);
632
+ next.push({ ...m, start: idx, length: m.display.length });
633
+ }
634
+ }
635
+ next.sort((a, b) => a.start - b.start);
636
+ return next;
637
+ };
638
+
639
+ const insertMention = (userId: string, name: string) => {
640
+ const el = textareaRef.current;
641
+ if (!el) return;
642
+ const text = input;
643
+ const cursor = el.selectionStart ?? text.length;
644
+ const at = mentionAtIndex;
645
+ if (at === null) return;
646
+
647
+ const display = name === "all" ? "@all" : `@${name}`;
648
+ const insertion = `${display} `;
649
+ const before = text.slice(0, at);
650
+ const after = text.slice(cursor);
651
+ const nextText = before + insertion + after;
652
+ const delta = insertion.length - (cursor - at);
653
+
654
+ const start = at;
655
+ const mention: MentionDraft = {
656
+ userId,
657
+ start,
658
+ length: display.length,
659
+ display,
660
+ };
661
+
662
+ setInput(nextText);
663
+ setDraftMentions((prev) => {
664
+ const shifted = shiftMentions(cursor, delta, prev);
665
+ return [...shifted, mention].sort((a, b) => a.start - b.start);
666
+ });
667
+
668
+ requestAnimationFrame(() => {
669
+ const cur = textareaRef.current;
670
+ if (!cur) return;
671
+ const pos = start + insertion.length;
672
+ cur.focus();
673
+ cur.setSelectionRange(pos, pos);
674
+ });
675
+
676
+ closeMention();
677
+ };
678
+
679
+ const fetchPage = async (before: string | null, limit: number) => {
680
+ if (type === "agent") {
681
+ return fetchAgentTimeline(id, before, limit);
682
+ }
683
+ return fetchGroupTimeline(id, before, limit);
684
+ };
685
+
686
+ const loadInitial = async () => {
687
+ try {
688
+ if (type === "group") {
689
+ const mems = await fetchGroupMembers(id);
690
+ setMembers(mems);
691
+ }
692
+ const { items, nextCursor: cursor } = await fetchPage(null, PAGE_SIZE);
693
+ const asc = [...items].reverse();
694
+ setTimeline(asc);
695
+ setNextCursor(cursor);
696
+ } catch (e) {
697
+ setError(e instanceof Error ? e.message : String(e));
698
+ }
699
+ };
700
+
701
+ const refreshLatest = async () => {
702
+ try {
703
+ const { items } = await fetchPage(null, PAGE_SIZE);
704
+ const asc = [...items].reverse();
705
+ setTimeline((prev) => {
706
+ const map = new Map<string, AgentTimelineItem>();
707
+ for (const it of prev) map.set(keyForItem(it), it);
708
+ for (const it of asc) map.set(keyForItem(it), it);
709
+ return Array.from(map.values()).sort((a, b) => a.time.localeCompare(b.time));
710
+ });
711
+ } catch (e) {
712
+ setError(e instanceof Error ? e.message : String(e));
713
+ }
714
+ };
715
+
716
+ useEffect(() => {
717
+ loadInitial();
718
+
719
+ const tick = () => {
720
+ if (document.visibilityState !== "visible") return;
721
+ void refreshLatest();
722
+ };
723
+
724
+ const interval = setInterval(tick, 3000);
725
+
726
+ const onVisibilityChange = () => {
727
+ if (document.visibilityState === "visible") tick();
728
+ };
729
+
730
+ document.addEventListener("visibilitychange", onVisibilityChange);
731
+
732
+ return () => {
733
+ document.removeEventListener("visibilitychange", onVisibilityChange);
734
+ clearInterval(interval);
735
+ };
736
+ }, [type, id]);
737
+
738
+ useEffect(() => {
739
+ const el = scrollRef.current;
740
+ if (!el) return;
741
+
742
+ const onScroll = () => {
743
+ const threshold = 60;
744
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
745
+ setIsAtBottom(atBottom);
746
+
747
+ // Lazy load: auto-load more when near the top
748
+ if (
749
+ el.scrollTop <= threshold &&
750
+ nextCursorRef.current &&
751
+ !loadingMoreRef.current
752
+ ) {
753
+ void loadMore();
754
+ }
755
+ };
756
+
757
+ el.addEventListener("scroll", onScroll, { passive: true });
758
+ onScroll();
759
+ return () => el.removeEventListener("scroll", onScroll);
760
+ }, []);
761
+
762
+ useEffect(() => {
763
+ const el = scrollRef.current;
764
+ if (!el) return;
765
+ if (!isAtBottom) return;
766
+ el.scrollTop = el.scrollHeight;
767
+ }, [timeline, isAtBottom]);
768
+
769
+ const loadMore = async () => {
770
+ if (!nextCursor) return;
771
+ if (loadingMore) return;
772
+
773
+ const el = scrollRef.current;
774
+ const prevScrollHeight = el?.scrollHeight ?? 0;
775
+ const prevScrollTop = el?.scrollTop ?? 0;
776
+
777
+ setLoadingMore(true);
778
+ try {
779
+ const { items, nextCursor: cursor } = await fetchPage(nextCursor, PAGE_SIZE);
780
+ const asc = [...items].reverse();
781
+ setTimeline((prev) => {
782
+ const merged = [...asc, ...prev];
783
+ const map = new Map<string, AgentTimelineItem>();
784
+ for (const it of merged) map.set(keyForItem(it), it);
785
+ return Array.from(map.values()).sort((a, b) => a.time.localeCompare(b.time));
786
+ });
787
+ setNextCursor(cursor);
788
+ requestAnimationFrame(() => {
789
+ const cur = scrollRef.current;
790
+ if (!cur) return;
791
+ const newScrollHeight = cur.scrollHeight;
792
+ cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
793
+ });
794
+ } catch (e) {
795
+ setError(e instanceof Error ? e.message : String(e));
796
+ } finally {
797
+ setLoadingMore(false);
798
+ }
799
+ };
800
+
801
+ const handleSend = async () => {
802
+ const currentImages = imagesRef.current;
803
+ if (!input.trim() && currentImages.length === 0) return;
804
+
805
+ if (busy) return;
806
+ setBusy(true);
807
+ setError(null);
808
+
809
+ let sent = false;
810
+
811
+ const isPending = (r: CueRequest) => r.status === "PENDING";
812
+
813
+ if (type === "agent") {
814
+ // Direct chat: respond to all pending requests for this agent
815
+ const pendingIds = pendingRequests.filter(isPending).map((r) => r.request_id);
816
+ if (pendingIds.length > 0) {
817
+ const result = await batchRespond(pendingIds, input, currentImages, draftMentions);
818
+ if (!result.success) {
819
+ setError(result.error || "Send failed");
820
+ setBusy(false);
821
+ return;
822
+ }
823
+ sent = true;
824
+ }
825
+ } else {
826
+ // Group chat
827
+ const mentionTargets = new Set(
828
+ draftMentions
829
+ .map((m) => m.userId)
830
+ .filter((id) => id && id !== "all")
831
+ );
832
+
833
+ const hasMentions = mentionTargets.size > 0;
834
+
835
+ if (hasMentions) {
836
+ // If there are mentions, only respond to mentioned members
837
+ const targetRequests = pendingRequests.filter(
838
+ (r) => isPending(r) && r.agent_id && mentionTargets.has(r.agent_id)
839
+ );
840
+ if (targetRequests.length > 0) {
841
+ const result = await batchRespond(
842
+ targetRequests.map((r) => r.request_id),
843
+ input,
844
+ images,
845
+ draftMentions
846
+ );
847
+ if (!result.success) {
848
+ setError(result.error || "Send failed");
849
+ setBusy(false);
850
+ return;
851
+ }
852
+ sent = true;
853
+ }
854
+ } else {
855
+ // Without mentions, respond to all pending
856
+ const pendingIds = pendingRequests.filter(isPending).map((r) => r.request_id);
857
+ if (pendingIds.length > 0) {
858
+ const result = await batchRespond(pendingIds, input, images, draftMentions);
859
+ if (!result.success) {
860
+ setError(result.error || "Send failed");
861
+ setBusy(false);
862
+ return;
863
+ }
864
+ sent = true;
865
+ }
866
+ }
867
+ }
868
+
869
+ if (!sent) {
870
+ setError("No pending requests to answer");
871
+ setBusy(false);
872
+ return;
873
+ }
874
+
875
+ setInput("");
876
+ setImages([]);
877
+ setDraftMentions([]);
878
+ await refreshLatest();
879
+ setBusy(false);
880
+ };
881
+
882
+ const handleCancel = async (requestId: string) => {
883
+ if (busy) return;
884
+ setBusy(true);
885
+ setError(null);
886
+ const result = await cancelRequest(requestId);
887
+ if (!result.success) {
888
+ setError(result.error || "End failed");
889
+ setBusy(false);
890
+ return;
891
+ }
892
+ await refreshLatest();
893
+ setBusy(false);
894
+ };
895
+
896
+ const handleReply = async (requestId: string) => {
897
+ const currentImages = imagesRef.current;
898
+ if (!input.trim() && currentImages.length === 0) return;
899
+ if (busy) return;
900
+ setBusy(true);
901
+ setError(null);
902
+ const result = await submitResponse(requestId, input, currentImages, draftMentions);
903
+ if (!result.success) {
904
+ setError(result.error || "Reply failed");
905
+ setBusy(false);
906
+ return;
907
+ }
908
+ setInput("");
909
+ setImages([]);
910
+ setDraftMentions([]);
911
+ await refreshLatest();
912
+ setBusy(false);
913
+ };
914
+
915
+ const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
916
+ const files = e.target.files;
917
+ if (!files) return;
918
+
919
+ try {
920
+ const list = Array.from(files);
921
+ const failures: string[] = [];
922
+ const converted = await Promise.all(
923
+ list.map(async (file) => {
924
+ try {
925
+ return await fileToInlineImage(file);
926
+ } catch (err) {
927
+ const msg = err instanceof Error ? err.message : String(err);
928
+ failures.push(msg || "unknown error");
929
+ return null;
930
+ }
931
+ })
932
+ );
933
+ const next = converted.filter(Boolean) as { mime_type: string; base64_data: string }[];
934
+ if (next.length > 0) {
935
+ setImages((prev) => [...prev, ...next]);
936
+ if (failures.length > 0) {
937
+ setNotice(`Added ${next.length} image(s); failed ${failures.length}: ${failures[0]}`);
938
+ } else {
939
+ setNotice(`Added ${next.length} image(s)`);
940
+ }
941
+ } else {
942
+ setNotice(`Selected images but failed to parse: ${failures[0] || "unknown error"}`);
943
+ }
944
+ } finally {
945
+ // allow selecting the same file again
946
+ e.target.value = "";
947
+ }
948
+ };
949
+
950
+ const hasPendingRequests = pendingRequests.length > 0;
951
+ const canSend =
952
+ !busy &&
953
+ hasPendingRequests &&
954
+ (input.trim().length > 0 || images.length > 0);
955
+
956
+ useEffect(() => {
957
+ if (!notice) return;
958
+ const t = setTimeout(() => setNotice(null), 2200);
959
+ return () => clearTimeout(t);
960
+ }, [notice]);
961
+
962
+ useEffect(() => {
963
+ const el = textareaRef.current;
964
+ if (!el) return;
965
+ // Auto-grow up to ~8 lines; beyond that, keep it scrollable
966
+ el.style.height = "0px";
967
+ const maxPx = 8 * 22; // ~8 lines
968
+ el.style.height = Math.min(el.scrollHeight, maxPx) + "px";
969
+ el.style.overflowY = el.scrollHeight > maxPx ? "auto" : "hidden";
970
+ }, [input]);
971
+
972
+ const formatDivider = (dateStr: string) => {
973
+ const d = new Date(dateStr + "Z");
974
+ return d.toLocaleString("zh-CN", {
975
+ month: "2-digit",
976
+ day: "2-digit",
977
+ hour: "2-digit",
978
+ minute: "2-digit",
979
+ timeZone: "Asia/Shanghai",
980
+ });
981
+ };
982
+
983
+ return (
984
+ <div className="flex h-full flex-1 flex-col overflow-hidden">
985
+ {notice && (
986
+ <div className="pointer-events-none fixed right-5 top-5 z-50">
987
+ <div className="rounded-2xl border bg-background/95 px-3 py-2 text-sm shadow-lg backdrop-blur">
988
+ {notice}
989
+ </div>
990
+ </div>
991
+ )}
992
+ {/* Header */}
993
+ <div className="flex items-center gap-3 border-b border-border/60 px-4 py-3 glass-surface-soft glass-noise">
994
+ {onBack && (
995
+ <Button variant="ghost" size="icon" onClick={onBack}>
996
+ <ChevronLeft className="h-5 w-5" />
997
+ </Button>
998
+ )}
999
+ <span className="text-2xl">
1000
+ {type === "group" ? "👥" : getAgentEmoji(id)}
1001
+ </span>
1002
+ <div className="flex-1">
1003
+ {editingTitle ? (
1004
+ <input
1005
+ id="chat-title-input"
1006
+ value={titleDraft}
1007
+ onChange={(e) => setTitleDraft(e.target.value)}
1008
+ onKeyDown={(e) => {
1009
+ if (e.key === "Enter") {
1010
+ e.preventDefault();
1011
+ void commitEditTitle();
1012
+ }
1013
+ if (e.key === "Escape") {
1014
+ e.preventDefault();
1015
+ setEditingTitle(false);
1016
+ }
1017
+ }}
1018
+ onBlur={() => {
1019
+ void commitEditTitle();
1020
+ }}
1021
+ className="w-60 max-w-full rounded-xl border border-white/45 bg-white/55 px-2.5 py-1.5 text-sm font-semibold outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]"
1022
+ />
1023
+ ) : (
1024
+ <h2
1025
+ className={cn("font-semibold", "cursor-text")}
1026
+ onDoubleClick={beginEditTitle}
1027
+ title="Double-click to rename"
1028
+ >
1029
+ {titleDisplay}
1030
+ </h2>
1031
+ )}
1032
+ {type === "group" && members.length > 0 && (
1033
+ <p className="text-xs text-muted-foreground">
1034
+ {members.length} member{members.length === 1 ? "" : "s"}
1035
+ </p>
1036
+ )}
1037
+ </div>
1038
+ {type === "group" && (
1039
+ <span className="hidden sm:inline text-[11px] text-muted-foreground select-none mr-1" title="Type @ to mention members">
1040
+ @ mention
1041
+ </span>
1042
+ )}
1043
+ </div>
1044
+
1045
+ {/* Messages */}
1046
+ <ScrollArea
1047
+ className={cn(
1048
+ "flex-1 min-h-0 p-2 sm:p-4",
1049
+ "bg-transparent"
1050
+ )}
1051
+ ref={scrollRef}
1052
+ >
1053
+ <div className="mx-auto flex w-full max-w-230 flex-col gap-6 pb-36 overflow-x-hidden">
1054
+ {loadingMore && (
1055
+ <div className="flex justify-center py-1">
1056
+ <span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground shadow-sm">
1057
+ Loading...
1058
+ </span>
1059
+ </div>
1060
+ )}
1061
+ {nextCursor && (
1062
+ <div className="flex justify-center">
1063
+ <Button
1064
+ variant="outline"
1065
+ size="sm"
1066
+ onClick={loadMore}
1067
+ disabled={loadingMore}
1068
+ >
1069
+ {loadingMore ? "Loading..." : "Load more"}
1070
+ </Button>
1071
+ </div>
1072
+ )}
1073
+
1074
+ {/* Timeline: all messages sorted by time (paged) */}
1075
+ {timeline.map((item, idx) => {
1076
+ const prev = idx > 0 ? timeline[idx - 1] : null;
1077
+
1078
+ const curTime = item.time;
1079
+ const prevTime = prev?.time;
1080
+ const showDivider = (() => {
1081
+ if (!prevTime) return true;
1082
+ const a = new Date(prevTime + "Z").getTime();
1083
+ const b = new Date(curTime + "Z").getTime();
1084
+ return b - a > 5 * 60 * 1000;
1085
+ })();
1086
+
1087
+ const divider = showDivider ? (
1088
+ <div key={`div-${curTime}-${idx}`} className="flex justify-center py-1">
1089
+ <span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground shadow-sm">
1090
+ {formatDivider(curTime)}
1091
+ </span>
1092
+ </div>
1093
+ ) : null;
1094
+
1095
+ if (item.item_type === "request") {
1096
+ const prevSameSender =
1097
+ prev?.item_type === "request" &&
1098
+ prev.request.agent_id === item.request.agent_id;
1099
+
1100
+ const prevWasRequest = prev?.item_type === "request";
1101
+ const compact = prevWasRequest && prevSameSender;
1102
+
1103
+ return (
1104
+ <div key={`wrap-req-${item.request.request_id}`} className={cn(compact ? "-mt-1" : "")}>
1105
+ {divider}
1106
+ <MessageBubble
1107
+ request={item.request}
1108
+ showAgent={type === "group"}
1109
+ agentNameMap={agentNameMap}
1110
+ showName={!prevSameSender}
1111
+ showAvatar={!prevSameSender}
1112
+ compact={compact}
1113
+ disabled={busy}
1114
+ currentInput={input}
1115
+ isGroup={type === "group"}
1116
+ onPasteChoice={pasteToInput}
1117
+ onMentionAgent={(agentId) => insertMentionAtCursor(agentId, agentId)}
1118
+ onReply={() => handleReply(item.request.request_id)}
1119
+ onCancel={() => handleCancel(item.request.request_id)}
1120
+ />
1121
+ </div>
1122
+ );
1123
+ }
1124
+
1125
+ const prevIsResp = prev?.item_type === "response";
1126
+ const compactResp = prevIsResp;
1127
+
1128
+ return (
1129
+ <div key={`wrap-resp-${item.response.id}`} className={cn(compactResp ? "-mt-1" : "")}>
1130
+ {divider}
1131
+ <UserResponseBubble
1132
+ response={item.response}
1133
+ showAvatar={!compactResp}
1134
+ compact={compactResp}
1135
+ onPreview={setPreviewImage}
1136
+ />
1137
+ </div>
1138
+ );
1139
+ })}
1140
+
1141
+ {timeline.length === 0 && (
1142
+ <div className="flex h-40 items-center justify-center text-muted-foreground">
1143
+ No messages yet
1144
+ </div>
1145
+ )}
1146
+ </div>
1147
+ </ScrollArea>
1148
+
1149
+ {error && (
1150
+ <div className="border-t bg-background px-3 py-2 text-sm text-destructive">
1151
+ {error}
1152
+ </div>
1153
+ )}
1154
+
1155
+ <ChatComposer
1156
+ type={type}
1157
+ onBack={onBack}
1158
+ busy={busy}
1159
+ canSend={canSend}
1160
+ hasPendingRequests={hasPendingRequests}
1161
+ input={input}
1162
+ setInput={setInput}
1163
+ images={images}
1164
+ setImages={setImages}
1165
+ setNotice={setNotice}
1166
+ setPreviewImage={setPreviewImage}
1167
+ handleSend={handleSend}
1168
+ handlePaste={handlePaste}
1169
+ handleImageUpload={handleImageUpload}
1170
+ textareaRef={textareaRef}
1171
+ fileInputRef={fileInputRef}
1172
+ inputWrapRef={inputWrapRef}
1173
+ mentionOpen={mentionOpen}
1174
+ mentionPos={mentionPos}
1175
+ mentionCandidates={mentionCandidates}
1176
+ mentionActive={mentionActive}
1177
+ setMentionActive={setMentionActive}
1178
+ mentionScrollable={mentionScrollable}
1179
+ mentionPopoverRef={mentionPopoverRef}
1180
+ mentionListRef={mentionListRef}
1181
+ pointerInMentionRef={pointerInMentionRef}
1182
+ mentionScrollTopRef={mentionScrollTopRef}
1183
+ closeMention={closeMention}
1184
+ insertMention={insertMention}
1185
+ updateMentionFromCursor={updateMentionFromCursor}
1186
+ draftMentions={draftMentions}
1187
+ setDraftMentions={setDraftMentions}
1188
+ agentNameMap={agentNameMap}
1189
+ setAgentNameMap={setAgentNameMap}
1190
+ />
1191
+
1192
+ <Dialog open={!!previewImage} onOpenChange={(open) => !open && setPreviewImage(null)}>
1193
+ <DialogContent className="max-w-3xl glass-surface glass-noise">
1194
+ <DialogHeader>
1195
+ <DialogTitle>Preview</DialogTitle>
1196
+ </DialogHeader>
1197
+ {previewImage && (
1198
+ <div className="flex items-center justify-center">
1199
+ <img
1200
+ src={`data:${previewImage.mime_type};base64,${previewImage.base64_data}`}
1201
+ alt=""
1202
+ className="max-h-[70vh] rounded-lg"
1203
+ />
1204
+ </div>
1205
+ )}
1206
+ </DialogContent>
1207
+ </Dialog>
1208
+ </div>
1209
+ );
1210
+ }
1211
+
1212
+ function MessageBubble({
1213
+ request,
1214
+ showAgent,
1215
+ agentNameMap,
1216
+ isHistory,
1217
+ showName,
1218
+ showAvatar,
1219
+ compact,
1220
+ disabled,
1221
+ currentInput,
1222
+ isGroup,
1223
+ onPasteChoice,
1224
+ onMentionAgent,
1225
+ onReply,
1226
+ onCancel,
1227
+ }: {
1228
+ request: CueRequest;
1229
+ showAgent?: boolean;
1230
+ agentNameMap?: Record<string, string>;
1231
+ isHistory?: boolean;
1232
+ showName?: boolean;
1233
+ showAvatar?: boolean;
1234
+ compact?: boolean;
1235
+ disabled?: boolean;
1236
+ currentInput?: string;
1237
+ isGroup?: boolean;
1238
+ onPasteChoice?: (text: string, mode?: "replace" | "append") => void;
1239
+ onMentionAgent?: (agentId: string) => void;
1240
+ onReply?: () => void;
1241
+ onCancel?: () => void;
1242
+ }) {
1243
+ const isPending = request.status === "PENDING";
1244
+
1245
+ const selectedLines = useMemo(() => {
1246
+ const text = (currentInput || "").trim();
1247
+ if (!text) return new Set<string>();
1248
+ return new Set(
1249
+ text
1250
+ .split(/\r?\n/)
1251
+ .map((s) => s.trim())
1252
+ .filter(Boolean)
1253
+ );
1254
+ }, [currentInput]);
1255
+
1256
+ const rawId = request.agent_id || "";
1257
+ const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
1258
+ const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
1259
+
1260
+ return (
1261
+ <div
1262
+ className={cn(
1263
+ "flex max-w-full min-w-0 items-start gap-3",
1264
+ compact && "gap-2",
1265
+ isHistory && "opacity-60"
1266
+ )}
1267
+ >
1268
+ {(showAvatar ?? true) ? (
1269
+ <span
1270
+ className={cn(
1271
+ "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
1272
+ isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
1273
+ )}
1274
+ title={
1275
+ isGroup && request.agent_id && onMentionAgent
1276
+ ? "Double-click avatar to @mention"
1277
+ : undefined
1278
+ }
1279
+ onDoubleClick={() => {
1280
+ if (!isGroup) return;
1281
+ const agentId = request.agent_id;
1282
+ if (!agentId) return;
1283
+ onMentionAgent?.(agentId);
1284
+ }}
1285
+ >
1286
+ {getAgentEmoji(request.agent_id || "")}
1287
+ </span>
1288
+ ) : (
1289
+ <span className="h-9 w-9 shrink-0" />
1290
+ )}
1291
+ <div className="flex-1 min-w-0 overflow-hidden">
1292
+ {(showName ?? true) && (showAgent || displayName) && (
1293
+ <p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
1294
+ )}
1295
+ <div
1296
+ className={cn(
1297
+ "rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
1298
+ "glass-surface-soft glass-noise",
1299
+ isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
1300
+ )}
1301
+ style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
1302
+ >
1303
+ <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
1304
+ <MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
1305
+ </div>
1306
+ <PayloadCard
1307
+ raw={request.payload}
1308
+ disabled={disabled}
1309
+ onPasteChoice={onPasteChoice}
1310
+ selectedLines={selectedLines}
1311
+ />
1312
+ </div>
1313
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1314
+ <span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
1315
+ {isPending && (
1316
+ <>
1317
+ <Badge variant="default" className="text-xs shrink-0">
1318
+ Pending
1319
+ </Badge>
1320
+ <Badge variant="outline" className="text-xs shrink-0">
1321
+ Waiting {getWaitingDuration(request.created_at || "")}
1322
+ </Badge>
1323
+ {onReply && (
1324
+ <Button
1325
+ variant="link"
1326
+ size="sm"
1327
+ className="h-auto p-0 text-xs"
1328
+ onClick={onReply}
1329
+ disabled={disabled}
1330
+ >
1331
+ Reply
1332
+ </Button>
1333
+ )}
1334
+ {onCancel && (
1335
+ <Button
1336
+ variant="link"
1337
+ size="sm"
1338
+ className="h-auto p-0 text-xs text-destructive"
1339
+ onClick={onCancel}
1340
+ disabled={disabled}
1341
+ >
1342
+ End
1343
+ </Button>
1344
+ )}
1345
+ </>
1346
+ )}
1347
+ {request.status === "COMPLETED" && (
1348
+ <Badge variant="secondary" className="text-xs shrink-0">
1349
+ Replied
1350
+ </Badge>
1351
+ )}
1352
+ {request.status === "CANCELLED" && (
1353
+ <Badge variant="destructive" className="text-xs shrink-0">
1354
+ Ended
1355
+ </Badge>
1356
+ )}
1357
+ </div>
1358
+ </div>
1359
+ </div>
1360
+ );
1361
+ }
1362
+
1363
+ function UserResponseBubble({
1364
+ response,
1365
+ showAvatar = true,
1366
+ compact = false,
1367
+ onPreview,
1368
+ }: {
1369
+ response: CueResponse;
1370
+ showAvatar?: boolean;
1371
+ compact?: boolean;
1372
+ onPreview?: (img: { mime_type: string; base64_data: string }) => void;
1373
+ }) {
1374
+ const parsed = JSON.parse(response.response_json || "{}") as {
1375
+ text?: string;
1376
+ images?: { mime_type: string; base64_data: string }[];
1377
+ mentions?: { userId: string; start: number; length: number; display: string }[];
1378
+ };
1379
+
1380
+ const renderTextWithMentions = (
1381
+ text: string,
1382
+ mentions?: { start: number; length: number }[]
1383
+ ) => {
1384
+ if (!mentions || mentions.length === 0) return text;
1385
+ const safe = [...mentions]
1386
+ .filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
1387
+ .sort((a, b) => a.start - b.start);
1388
+
1389
+ const nodes: ReactNode[] = [];
1390
+ let cursor = 0;
1391
+ for (const m of safe) {
1392
+ if (m.start < cursor) continue;
1393
+ if (m.start > cursor) {
1394
+ nodes.push(text.slice(cursor, m.start));
1395
+ }
1396
+ const seg = text.slice(m.start, m.start + m.length);
1397
+ nodes.push(
1398
+ <span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
1399
+ {seg}
1400
+ </span>
1401
+ );
1402
+ cursor = m.start + m.length;
1403
+ }
1404
+ if (cursor < text.length) nodes.push(text.slice(cursor));
1405
+ return nodes;
1406
+ };
1407
+
1408
+ if (response.cancelled) {
1409
+ return (
1410
+ <div className="flex justify-end gap-3 max-w-full min-w-0">
1411
+ <div
1412
+ className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
1413
+ style={{
1414
+ clipPath: "inset(0 round 1rem)",
1415
+ maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
1416
+ }}
1417
+ >
1418
+ <p className="text-sm text-muted-foreground italic">Conversation ended</p>
1419
+ <p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
1420
+ </div>
1421
+ </div>
1422
+ );
1423
+ }
1424
+
1425
+ return (
1426
+ <div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
1427
+ <div
1428
+ className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
1429
+ style={{
1430
+ clipPath: "inset(0 round 1rem)",
1431
+ maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
1432
+ }}
1433
+ >
1434
+ {parsed.text && (
1435
+ <p className="whitespace-pre-wrap text-sm wrap-anywhere">
1436
+ {renderTextWithMentions(parsed.text, parsed.mentions)}
1437
+ </p>
1438
+ )}
1439
+ {parsed.images && parsed.images.length > 0 && (
1440
+ <div className="flex flex-wrap gap-2 mt-2 max-w-full">
1441
+ {parsed.images.map((img, i) => (
1442
+ <img
1443
+ key={i}
1444
+ src={`data:${img.mime_type};base64,${img.base64_data}`}
1445
+ alt=""
1446
+ className="max-h-32 max-w-full h-auto rounded cursor-pointer"
1447
+ onClick={() => onPreview?.(img)}
1448
+ />
1449
+ ))}
1450
+ </div>
1451
+ )}
1452
+ <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
1453
+ </div>
1454
+ {showAvatar ? (
1455
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
1456
+ 👤
1457
+ </span>
1458
+ ) : (
1459
+ <span className="h-9 w-9 shrink-0" />
1460
+ )}
1461
+ </div>
1462
+ );
1463
+ }