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,525 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { ScrollArea } from "@/components/ui/scroll-area";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Collapsible,
9
+ CollapsibleContent,
10
+ CollapsibleTrigger,
11
+ } from "@/components/ui/collapsible";
12
+ import { cn, getAgentEmoji, formatTime, truncateText } from "@/lib/utils";
13
+ import { fetchConversationList, type ConversationItem } from "@/lib/actions";
14
+ import {
15
+ Plus,
16
+ MessageCircle,
17
+ Search,
18
+ ChevronDown,
19
+ Users,
20
+ Bot,
21
+ ChevronLeft,
22
+ ChevronRight,
23
+ } from "lucide-react";
24
+ import { Input } from "@/components/ui/input";
25
+
26
+ const HIDDEN_CONVERSATIONS_STORAGE_KEY = "cuehub:hiddenConversations";
27
+
28
+ function conversationKey(item: Pick<ConversationItem, "type" | "id">) {
29
+ return `${item.type}:${item.id}`;
30
+ }
31
+
32
+ interface ConversationListProps {
33
+ selectedId: string | null;
34
+ selectedType: "agent" | "group" | null;
35
+ onSelect: (id: string, type: "agent" | "group", name: string) => void;
36
+ onCreateGroup: () => void;
37
+ collapsed?: boolean;
38
+ onToggleCollapsed?: () => void;
39
+ }
40
+
41
+ export function ConversationList({
42
+ selectedId,
43
+ selectedType,
44
+ onSelect,
45
+ onCreateGroup,
46
+ collapsed,
47
+ onToggleCollapsed,
48
+ }: ConversationListProps) {
49
+ const [items, setItems] = useState<ConversationItem[]>([]);
50
+ const [search, setSearch] = useState("");
51
+ const [groupsOpen, setGroupsOpen] = useState(true);
52
+ const [agentsOpen, setAgentsOpen] = useState(true);
53
+ const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(new Set());
54
+ const [menu, setMenu] = useState<
55
+ | { open: false }
56
+ | {
57
+ open: true;
58
+ x: number;
59
+ y: number;
60
+ item: ConversationItem | null;
61
+ }
62
+ >({ open: false });
63
+
64
+ useEffect(() => {
65
+ try {
66
+ const raw = localStorage.getItem(HIDDEN_CONVERSATIONS_STORAGE_KEY);
67
+ const parsed = raw ? (JSON.parse(raw) as unknown) : [];
68
+ if (Array.isArray(parsed)) {
69
+ const next = new Set(parsed.filter((v) => typeof v === "string"));
70
+ setHiddenKeys(next);
71
+ }
72
+ } catch {
73
+ setHiddenKeys(new Set());
74
+ }
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ if (!menu.open) return;
79
+ const onPointerDown = () => setMenu({ open: false });
80
+ const onKeyDown = (e: KeyboardEvent) => {
81
+ if (e.key === "Escape") setMenu({ open: false });
82
+ };
83
+ window.addEventListener("pointerdown", onPointerDown);
84
+ window.addEventListener("keydown", onKeyDown);
85
+ return () => {
86
+ window.removeEventListener("pointerdown", onPointerDown);
87
+ window.removeEventListener("keydown", onKeyDown);
88
+ };
89
+ }, [menu.open]);
90
+
91
+ const persistHidden = useCallback((next: Set<string>) => {
92
+ setHiddenKeys(next);
93
+ try {
94
+ localStorage.setItem(
95
+ HIDDEN_CONVERSATIONS_STORAGE_KEY,
96
+ JSON.stringify(Array.from(next))
97
+ );
98
+ } catch {
99
+ // ignore
100
+ }
101
+ }, []);
102
+
103
+ const hideConversation = useCallback(
104
+ (item: ConversationItem) => {
105
+ const key = conversationKey(item);
106
+ persistHidden(new Set([...hiddenKeys, key]));
107
+ },
108
+ [hiddenKeys, persistHidden]
109
+ );
110
+
111
+ const restoreAllHidden = useCallback(() => {
112
+ persistHidden(new Set());
113
+ }, [persistHidden]);
114
+
115
+ useEffect(() => {
116
+ const onAgentNameUpdated = (evt: Event) => {
117
+ const e = evt as CustomEvent<{ agentId: string; displayName: string }>;
118
+ const agentId = e.detail?.agentId;
119
+ const displayName = (e.detail?.displayName || "").trim();
120
+ if (!agentId || !displayName) return;
121
+ setItems((prev) =>
122
+ prev.map((it) =>
123
+ it.type === "agent" && it.id === agentId ? { ...it, displayName } : it
124
+ )
125
+ );
126
+ };
127
+
128
+ window.addEventListener("cuehub:agentDisplayNameUpdated", onAgentNameUpdated);
129
+ return () => {
130
+ window.removeEventListener("cuehub:agentDisplayNameUpdated", onAgentNameUpdated);
131
+ };
132
+ }, []);
133
+
134
+ const loadData = useCallback(async () => {
135
+ const data = await fetchConversationList();
136
+ setItems(data);
137
+ }, []);
138
+
139
+ useEffect(() => {
140
+ const t0 = setTimeout(() => {
141
+ void loadData();
142
+ }, 0);
143
+
144
+ const tick = () => {
145
+ if (document.visibilityState !== "visible") return;
146
+ void loadData();
147
+ };
148
+
149
+ const interval = setInterval(tick, 3000);
150
+
151
+ const onVisibilityChange = () => {
152
+ if (document.visibilityState === "visible") tick();
153
+ };
154
+
155
+ document.addEventListener("visibilitychange", onVisibilityChange);
156
+
157
+ return () => {
158
+ clearTimeout(t0);
159
+ clearInterval(interval);
160
+ document.removeEventListener("visibilitychange", onVisibilityChange);
161
+ };
162
+ }, [loadData]);
163
+
164
+ const filtered = items
165
+ .filter((item) => !hiddenKeys.has(conversationKey(item)))
166
+ .filter((item) => item.displayName.toLowerCase().includes(search.toLowerCase()));
167
+
168
+ const groups = filtered.filter((i) => i.type === "group");
169
+ const agents = filtered.filter((i) => i.type === "agent");
170
+
171
+ const groupsPendingTotal = groups.reduce((sum, g) => sum + g.pendingCount, 0);
172
+ const agentsPendingTotal = agents.reduce((sum, a) => sum + a.pendingCount, 0);
173
+
174
+ const isCollapsed = !!collapsed;
175
+
176
+ const collapsedGroups = useMemo(
177
+ () => items.filter((i) => i.type === "group" && !hiddenKeys.has(conversationKey(i))),
178
+ [items, hiddenKeys]
179
+ );
180
+ const collapsedAgents = useMemo(
181
+ () => items.filter((i) => i.type === "agent" && !hiddenKeys.has(conversationKey(i))),
182
+ [items, hiddenKeys]
183
+ );
184
+
185
+ const onItemContextMenu = useCallback(
186
+ (e: React.MouseEvent, item: ConversationItem) => {
187
+ e.preventDefault();
188
+ setMenu({ open: true, x: e.clientX, y: e.clientY, item });
189
+ },
190
+ []
191
+ );
192
+
193
+ const onEmptyContextMenu = useCallback(
194
+ (e: React.MouseEvent) => {
195
+ const target = e.target as HTMLElement | null;
196
+ if (target?.closest?.("[data-conversation-item='true']")) return;
197
+ e.preventDefault();
198
+ setMenu({ open: true, x: e.clientX, y: e.clientY, item: null });
199
+ },
200
+ []
201
+ );
202
+
203
+ return (
204
+ <div
205
+ className={cn(
206
+ "flex h-full min-h-0 flex-col shrink-0",
207
+ "border-r border-border/60",
208
+ "glass-surface-opaque glass-noise",
209
+ "transition-[width] duration-200 ease-out",
210
+ isCollapsed ? "w-16" : "w-72"
211
+ )}
212
+ >
213
+ {/* Header */}
214
+ <div
215
+ className={cn(
216
+ "flex items-center border-b border-border/60",
217
+ isCollapsed ? "px-2 py-3" : "px-4 py-3"
218
+ )}
219
+ >
220
+ {isCollapsed ? (
221
+ <div className="flex w-full flex-col items-center justify-center gap-2">
222
+ <Button
223
+ variant="ghost"
224
+ size="icon"
225
+ className="h-9 w-9"
226
+ onClick={onToggleCollapsed}
227
+ disabled={!onToggleCollapsed}
228
+ title="Expand sidebar"
229
+ >
230
+ <ChevronRight className="h-5 w-5" />
231
+ </Button>
232
+ <Button
233
+ variant="ghost"
234
+ size="icon"
235
+ className="h-9 w-9"
236
+ onClick={onCreateGroup}
237
+ title="Create group"
238
+ >
239
+ <Plus className="h-5 w-5" />
240
+ </Button>
241
+ </div>
242
+ ) : (
243
+ <div className="flex w-full items-center justify-between gap-2">
244
+ <h1 className="text-lg font-semibold">Cue Hub</h1>
245
+ <div className="flex items-center gap-1">
246
+ <Button
247
+ variant="ghost"
248
+ size="icon"
249
+ onClick={onToggleCollapsed}
250
+ disabled={!onToggleCollapsed}
251
+ title="Collapse sidebar"
252
+ >
253
+ <ChevronLeft className="h-5 w-5" />
254
+ </Button>
255
+ <Button
256
+ variant="ghost"
257
+ size="icon"
258
+ onClick={onCreateGroup}
259
+ title="Create group"
260
+ >
261
+ <Plus className="h-5 w-5" />
262
+ </Button>
263
+ </div>
264
+ </div>
265
+ )}
266
+ </div>
267
+
268
+ {/* Search */}
269
+ {!isCollapsed && (
270
+ <div className="px-3 py-2">
271
+ <div className="relative">
272
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
273
+ <Input
274
+ placeholder="Search"
275
+ className="pl-8 h-9 bg-white/45 border-white/40 focus-visible:border-ring"
276
+ value={search}
277
+ onChange={(e) => setSearch(e.target.value)}
278
+ />
279
+ </div>
280
+ </div>
281
+ )}
282
+
283
+ {/* List */}
284
+ {isCollapsed ? (
285
+ <ScrollArea className="flex-1 min-h-0 px-2">
286
+ <div className="py-2 space-y-2" onContextMenu={onEmptyContextMenu}>
287
+ {collapsedGroups.length > 0 && (
288
+ <div className="space-y-1">
289
+ <div className="flex items-center justify-center">
290
+ <Users className="h-4 w-4 text-muted-foreground" />
291
+ </div>
292
+ {collapsedGroups.map((item) => (
293
+ <div
294
+ key={item.id}
295
+ data-conversation-item="true"
296
+ onContextMenu={(e) => onItemContextMenu(e, item)}
297
+ >
298
+ <ConversationIconButton
299
+ item={item}
300
+ isSelected={selectedId === item.id && selectedType === "group"}
301
+ onClick={() => onSelect(item.id, "group", item.name)}
302
+ />
303
+ </div>
304
+ ))}
305
+ </div>
306
+ )}
307
+
308
+ {collapsedAgents.length > 0 && (
309
+ <div className="space-y-1">
310
+ <div className="flex items-center justify-center">
311
+ <Bot className="h-4 w-4 text-muted-foreground" />
312
+ </div>
313
+ {collapsedAgents.map((item) => (
314
+ <div
315
+ key={item.id}
316
+ data-conversation-item="true"
317
+ onContextMenu={(e) => onItemContextMenu(e, item)}
318
+ >
319
+ <ConversationIconButton
320
+ item={item}
321
+ isSelected={selectedId === item.id && selectedType === "agent"}
322
+ onClick={() => onSelect(item.id, "agent", item.name)}
323
+ />
324
+ </div>
325
+ ))}
326
+ </div>
327
+ )}
328
+
329
+ {items.length === 0 && (
330
+ <div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
331
+ <MessageCircle className="mb-2 h-7 w-7" />
332
+ </div>
333
+ )}
334
+ </div>
335
+ </ScrollArea>
336
+ ) : (
337
+ <ScrollArea className="flex-1 min-h-0 px-2">
338
+ <div onContextMenu={onEmptyContextMenu}>
339
+ {/* Groups Section */}
340
+ {groups.length > 0 && (
341
+ <Collapsible open={groupsOpen} onOpenChange={setGroupsOpen} className="mb-1">
342
+ <CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent/50 transition-colors">
343
+ <ChevronDown
344
+ className={cn(
345
+ "h-4 w-4 shrink-0 transition-transform duration-200",
346
+ groupsOpen ? "" : "-rotate-90"
347
+ )}
348
+ />
349
+ <Users className="h-4 w-4 shrink-0" />
350
+ <span>Groups</span>
351
+ {groupsPendingTotal > 0 && (
352
+ <Badge variant="destructive" className="h-5 min-w-5 px-1.5 text-xs">
353
+ {groupsPendingTotal}
354
+ </Badge>
355
+ )}
356
+ </CollapsibleTrigger>
357
+ <CollapsibleContent className="mt-1 space-y-0.5">
358
+ {groups.map((item) => (
359
+ <div
360
+ key={item.id}
361
+ data-conversation-item="true"
362
+ onContextMenu={(e) => onItemContextMenu(e, item)}
363
+ >
364
+ <ConversationItemCard
365
+ item={item}
366
+ isSelected={selectedId === item.id && selectedType === "group"}
367
+ onClick={() => onSelect(item.id, "group", item.name)}
368
+ />
369
+ </div>
370
+ ))}
371
+ </CollapsibleContent>
372
+ </Collapsible>
373
+ )}
374
+
375
+ {/* Agents Section */}
376
+ {agents.length > 0 && (
377
+ <Collapsible open={agentsOpen} onOpenChange={setAgentsOpen} className="mb-1">
378
+ <CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent/50 transition-colors">
379
+ <ChevronDown
380
+ className={cn(
381
+ "h-4 w-4 shrink-0 transition-transform duration-200",
382
+ agentsOpen ? "" : "-rotate-90"
383
+ )}
384
+ />
385
+ <Bot className="h-4 w-4 shrink-0" />
386
+ <span>Agents</span>
387
+ </CollapsibleTrigger>
388
+ <CollapsibleContent className="mt-1 space-y-0.5">
389
+ {agents.map((item) => (
390
+ <div
391
+ key={item.id}
392
+ data-conversation-item="true"
393
+ onContextMenu={(e) => onItemContextMenu(e, item)}
394
+ >
395
+ <ConversationItemCard
396
+ item={item}
397
+ isSelected={selectedId === item.id && selectedType === "agent"}
398
+ onClick={() => onSelect(item.id, "agent", item.name)}
399
+ />
400
+ </div>
401
+ ))}
402
+ </CollapsibleContent>
403
+ </Collapsible>
404
+ )}
405
+
406
+ {filtered.length === 0 && (
407
+ <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
408
+ <MessageCircle className="mb-2 h-8 w-8" />
409
+ <p className="text-sm">No conversations</p>
410
+ </div>
411
+ )}
412
+ </div>
413
+ </ScrollArea>
414
+ )}
415
+
416
+ {menu.open && (
417
+ <div
418
+ className="fixed z-50 min-w-40 rounded-lg border bg-white/90 p-1 shadow-lg backdrop-blur"
419
+ style={{ left: menu.x, top: menu.y }}
420
+ onPointerDown={(e) => e.stopPropagation()}
421
+ >
422
+ {menu.open && menu.item ? (
423
+ <button
424
+ className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent"
425
+ onClick={() => {
426
+ if (menu.item) hideConversation(menu.item);
427
+ setMenu({ open: false });
428
+ }}
429
+ >
430
+ Hide conversation
431
+ </button>
432
+ ) : (
433
+ <button
434
+ className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
435
+ onClick={() => {
436
+ restoreAllHidden();
437
+ setMenu({ open: false });
438
+ }}
439
+ disabled={hiddenKeys.size === 0}
440
+ >
441
+ Restore hidden conversations
442
+ </button>
443
+ )}
444
+ </div>
445
+ )}
446
+ </div>
447
+ );
448
+ }
449
+
450
+ function ConversationIconButton({
451
+ item,
452
+ isSelected,
453
+ onClick,
454
+ }: {
455
+ item: ConversationItem;
456
+ isSelected: boolean;
457
+ onClick: () => void;
458
+ }) {
459
+ const emoji = item.type === "group" ? "👥" : getAgentEmoji(item.name);
460
+ return (
461
+ <button
462
+ className={cn(
463
+ "relative flex h-11 w-11 items-center justify-center rounded-2xl transition",
464
+ "backdrop-blur-sm",
465
+ isSelected
466
+ ? "bg-white/60 text-accent-foreground shadow-sm ring-1 ring-white/45"
467
+ : "hover:bg-white/40"
468
+ )}
469
+ onClick={onClick}
470
+ title={item.displayName}
471
+ >
472
+ <span className="text-xl">{emoji}</span>
473
+ {item.pendingCount > 0 && (
474
+ <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-sidebar" />
475
+ )}
476
+ </button>
477
+ );
478
+ }
479
+
480
+ function ConversationItemCard({
481
+ item,
482
+ isSelected,
483
+ onClick,
484
+ }: {
485
+ item: ConversationItem;
486
+ isSelected: boolean;
487
+ onClick: () => void;
488
+ }) {
489
+ const emoji = item.type === "group" ? "👥" : getAgentEmoji(item.name);
490
+
491
+ return (
492
+ <button
493
+ className={cn(
494
+ "flex w-full items-center gap-2.5 rounded-2xl px-2.5 py-1.5 text-left transition overflow-hidden",
495
+ "backdrop-blur-sm",
496
+ isSelected
497
+ ? "bg-white/62 text-accent-foreground shadow-sm ring-1 ring-white/45"
498
+ : "hover:bg-white/40"
499
+ )}
500
+ onClick={onClick}
501
+ >
502
+ <span className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/55 ring-1 ring-white/40 text-[18px]">
503
+ {emoji}
504
+ {item.pendingCount > 0 && (
505
+ <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-destructive ring-2 ring-background" />
506
+ )}
507
+ </span>
508
+ <div className="flex-1 min-w-0">
509
+ <div className="flex items-center justify-between gap-2">
510
+ <span className="text-sm font-medium leading-5">{truncateText(item.displayName, 18)}</span>
511
+ {item.lastTime && (
512
+ <span className="text-[11px] text-muted-foreground shrink-0">
513
+ {formatTime(item.lastTime)}
514
+ </span>
515
+ )}
516
+ </div>
517
+ {item.lastMessage && (
518
+ <p className="text-[11px] text-muted-foreground whitespace-nowrap leading-4">
519
+ {truncateText(item.lastMessage.replace(/\n/g, ' '), 20)}
520
+ </p>
521
+ )}
522
+ </div>
523
+ </button>
524
+ );
525
+ }