@castlekit/castle 0.1.6 → 0.3.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.
Files changed (60) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +20 -3
  4. package/src/app/api/avatars/[id]/route.ts +57 -7
  5. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  6. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  7. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  8. package/src/app/api/openclaw/chat/route.ts +272 -0
  9. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  10. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  11. package/src/app/api/openclaw/logs/route.ts +17 -3
  12. package/src/app/api/openclaw/restart/route.ts +6 -1
  13. package/src/app/api/openclaw/session/status/route.ts +42 -0
  14. package/src/app/api/settings/avatar/route.ts +190 -0
  15. package/src/app/api/settings/route.ts +88 -0
  16. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  17. package/src/app/chat/[channelId]/page.tsx +305 -0
  18. package/src/app/chat/layout.tsx +96 -0
  19. package/src/app/chat/page.tsx +52 -0
  20. package/src/app/globals.css +89 -2
  21. package/src/app/layout.tsx +7 -1
  22. package/src/app/page.tsx +49 -17
  23. package/src/app/settings/page.tsx +300 -0
  24. package/src/components/chat/agent-mention-popup.tsx +89 -0
  25. package/src/components/chat/archived-channels.tsx +190 -0
  26. package/src/components/chat/channel-list.tsx +140 -0
  27. package/src/components/chat/chat-input.tsx +310 -0
  28. package/src/components/chat/create-channel-dialog.tsx +171 -0
  29. package/src/components/chat/markdown-content.tsx +205 -0
  30. package/src/components/chat/message-bubble.tsx +152 -0
  31. package/src/components/chat/message-list.tsx +508 -0
  32. package/src/components/chat/message-queue.tsx +68 -0
  33. package/src/components/chat/session-divider.tsx +61 -0
  34. package/src/components/chat/session-stats-panel.tsx +139 -0
  35. package/src/components/chat/storage-indicator.tsx +76 -0
  36. package/src/components/layout/sidebar.tsx +126 -45
  37. package/src/components/layout/user-menu.tsx +29 -4
  38. package/src/components/providers/presence-provider.tsx +8 -0
  39. package/src/components/providers/search-provider.tsx +81 -0
  40. package/src/components/search/search-dialog.tsx +269 -0
  41. package/src/components/ui/avatar.tsx +11 -9
  42. package/src/components/ui/dialog.tsx +10 -4
  43. package/src/components/ui/tooltip.tsx +25 -8
  44. package/src/components/ui/twemoji-text.tsx +37 -0
  45. package/src/lib/api-security.ts +125 -0
  46. package/src/lib/date-utils.ts +79 -0
  47. package/src/lib/db/__tests__/queries.test.ts +318 -0
  48. package/src/lib/db/index.ts +642 -0
  49. package/src/lib/db/queries.ts +1017 -0
  50. package/src/lib/db/schema.ts +160 -0
  51. package/src/lib/hooks/use-agent-status.ts +251 -0
  52. package/src/lib/hooks/use-chat.ts +775 -0
  53. package/src/lib/hooks/use-openclaw.ts +105 -70
  54. package/src/lib/hooks/use-search.ts +113 -0
  55. package/src/lib/hooks/use-session-stats.ts +57 -0
  56. package/src/lib/hooks/use-user-settings.ts +46 -0
  57. package/src/lib/types/chat.ts +186 -0
  58. package/src/lib/types/search.ts +60 -0
  59. package/src/middleware.ts +52 -0
  60. package/vitest.config.ts +13 -0
@@ -0,0 +1,508 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useCallback } from "react";
4
+ import { Bot, CalendarDays, Loader2, MessageSquare } from "lucide-react";
5
+ import { MessageBubble } from "./message-bubble";
6
+ import { SessionDivider } from "./session-divider";
7
+ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
8
+ import { useAgentStatus, setAgentActive, getThinkingChannel, USER_STATUS_ID } from "@/lib/hooks/use-agent-status";
9
+ import { formatDate } from "@/lib/date-utils";
10
+ import type { ChatMessage, ChannelSession, StreamingMessage } from "@/lib/types/chat";
11
+ import type { AgentInfo } from "./agent-mention-popup";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Reusable typing indicator (bouncing dots with agent avatar)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function TypingIndicator({
18
+ agentId,
19
+ agentName,
20
+ agentAvatar,
21
+ }: {
22
+ agentId: string;
23
+ agentName?: string;
24
+ agentAvatar?: string | null;
25
+ }) {
26
+ const { getStatus } = useAgentStatus();
27
+ const status = getStatus(agentId);
28
+ const avatarStatus = ({ thinking: "away", active: "online", idle: "offline" } as const)[status];
29
+
30
+ return (
31
+ <div className="flex gap-3 mt-4">
32
+ <div className="mt-0.5">
33
+ <Avatar size="sm" status={avatarStatus} statusPulse={status === "thinking"}>
34
+ {agentAvatar ? (
35
+ <AvatarImage src={agentAvatar} alt={agentName || "Agent"} />
36
+ ) : (
37
+ <AvatarFallback className="bg-accent/20 text-accent">
38
+ <Bot className="w-4 h-4" />
39
+ </AvatarFallback>
40
+ )}
41
+ </Avatar>
42
+ </div>
43
+ <div className="flex flex-col">
44
+ <div className="flex items-center gap-2 mb-0.5">
45
+ <span className="font-bold text-[15px] text-foreground">
46
+ {agentName || agentId}
47
+ </span>
48
+ </div>
49
+ <div className="flex items-center gap-1 py-1">
50
+ <span className="w-2 h-2 bg-foreground-secondary/60 rounded-full animate-bounce [animation-delay:-0.3s]" />
51
+ <span className="w-2 h-2 bg-foreground-secondary/60 rounded-full animate-bounce [animation-delay:-0.15s]" />
52
+ <span className="w-2 h-2 bg-foreground-secondary/60 rounded-full animate-bounce" />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ interface MessageListProps {
60
+ messages: ChatMessage[];
61
+ sessions?: ChannelSession[];
62
+ loading?: boolean;
63
+ loadingMore?: boolean;
64
+ hasMore?: boolean;
65
+ agents: AgentInfo[];
66
+ userAvatar?: string | null;
67
+ streamingMessages?: Map<string, StreamingMessage>;
68
+ onLoadMore?: () => void;
69
+ /** Whether there are newer messages to load (anchor mode) */
70
+ hasMoreAfter?: boolean;
71
+ /** Callback to load newer messages (anchor mode) */
72
+ onLoadNewer?: () => void;
73
+ /** Whether newer messages are currently loading */
74
+ loadingNewer?: boolean;
75
+ channelId?: string;
76
+ channelName?: string | null;
77
+ channelCreatedAt?: number | null;
78
+ highlightMessageId?: string;
79
+ }
80
+
81
+ export function MessageList({
82
+ messages,
83
+ sessions = [],
84
+ loading,
85
+ loadingMore,
86
+ hasMore,
87
+ agents,
88
+ userAvatar,
89
+ streamingMessages,
90
+ onLoadMore,
91
+ hasMoreAfter,
92
+ onLoadNewer,
93
+ loadingNewer,
94
+ channelId,
95
+ channelName,
96
+ channelCreatedAt,
97
+ highlightMessageId,
98
+ }: MessageListProps) {
99
+ const { statuses: agentStatuses, getStatus: getAgentStatus } = useAgentStatus();
100
+ const userStatus = getAgentStatus(USER_STATUS_ID);
101
+
102
+ // Clear stale "thinking" status on mount/update.
103
+ // If the agent finished its response while the user was on another page,
104
+ // the SSE listener was closed so setAgentActive() never fired.
105
+ // Detect this by checking if the "thinking" agent already has a completed
106
+ // message after the last user message, and transition them to "active".
107
+ // IMPORTANT: only check agents that are thinking in THIS channel to avoid
108
+ // clearing a thinking status that belongs to a different channel.
109
+ useEffect(() => {
110
+ if (messages.length === 0) return;
111
+ for (const [agentId, status] of Object.entries(agentStatuses)) {
112
+ if (status !== "thinking" || agentId === USER_STATUS_ID) continue;
113
+ // Only clear if the agent is thinking in this specific channel
114
+ const thinkingIn = getThinkingChannel(agentId);
115
+ if (thinkingIn && thinkingIn !== channelId) continue;
116
+ const lastUserIdx = messages.findLastIndex((m) => m.senderType === "user");
117
+ const lastAgentIdx = messages.findLastIndex(
118
+ (m) => m.senderType === "agent" && m.senderId === agentId
119
+ );
120
+ if (lastAgentIdx > lastUserIdx) {
121
+ // Agent already responded — clear the stale "thinking" status
122
+ setAgentActive(agentId);
123
+ }
124
+ }
125
+ }, [messages, agentStatuses, channelId]);
126
+
127
+ const bottomRef = useRef<HTMLDivElement>(null);
128
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
129
+ const contentRef = useRef<HTMLDivElement>(null);
130
+ const isInitialLoad = useRef(true);
131
+ const pinnedToBottom = useRef(true);
132
+ const isLoadingOlder = useRef(false);
133
+ const prevScrollHeightRef = useRef<number>(0);
134
+ const highlightHandled = useRef<string | null>(null);
135
+
136
+ // Scroll helper
137
+ const scrollToBottom = useCallback(() => {
138
+ const container = scrollContainerRef.current;
139
+ if (container) {
140
+ container.scrollTop = container.scrollHeight;
141
+ }
142
+ }, []);
143
+
144
+ // Track if user has scrolled away from bottom
145
+ const checkIfPinned = useCallback(() => {
146
+ const container = scrollContainerRef.current;
147
+ if (!container) return;
148
+ const threshold = 50;
149
+ pinnedToBottom.current =
150
+ container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
151
+ }, []);
152
+
153
+ // ResizeObserver: auto-scroll when content OR container size changes while pinned.
154
+ // The container can resize when the input box below shrinks (e.g. text cleared on send).
155
+ // When that happens the browser caps scrollTop and content appears to shift down.
156
+ useEffect(() => {
157
+ const content = contentRef.current;
158
+ const container = scrollContainerRef.current;
159
+ if (!content || !container) return;
160
+
161
+ const observer = new ResizeObserver(() => {
162
+ if (pinnedToBottom.current) {
163
+ scrollToBottom();
164
+ }
165
+ });
166
+ observer.observe(content);
167
+ observer.observe(container);
168
+ return () => observer.disconnect();
169
+ }, [scrollToBottom]);
170
+
171
+ // Initial scroll before paint — skip if we have a highlight target
172
+ useLayoutEffect(() => {
173
+ if (isInitialLoad.current && messages.length > 0) {
174
+ if (!highlightMessageId) {
175
+ pinnedToBottom.current = true;
176
+ scrollToBottom();
177
+ requestAnimationFrame(scrollToBottom);
178
+ }
179
+ isInitialLoad.current = false;
180
+ }
181
+ }, [messages.length, scrollToBottom, highlightMessageId]);
182
+
183
+ // Re-pin to bottom when new messages arrive or streaming changes,
184
+ // but NOT when loading older messages via infinite scroll,
185
+ // and NOT when a highlight target is active.
186
+ useLayoutEffect(() => {
187
+ if (!isInitialLoad.current) {
188
+ if (isLoadingOlder.current) {
189
+ // Restore scroll position after older messages are prepended.
190
+ // The new content pushes everything down, so we adjust scrollTop
191
+ // by the difference in scrollHeight to keep the user at the same spot.
192
+ const container = scrollContainerRef.current;
193
+ if (container && prevScrollHeightRef.current > 0) {
194
+ const newScrollHeight = container.scrollHeight;
195
+ const delta = newScrollHeight - prevScrollHeightRef.current;
196
+ container.scrollTop += delta;
197
+ }
198
+ prevScrollHeightRef.current = 0;
199
+ isLoadingOlder.current = false;
200
+ } else if (highlightMessageId) {
201
+ // Don't auto-pin while a highlight target is active —
202
+ // let the highlight scroll and user scrolling manage pinning.
203
+ } else if (pinnedToBottom.current) {
204
+ // Only re-pin if already pinned (e.g. new incoming message while at bottom).
205
+ // Don't force-pin when user has scrolled away.
206
+ }
207
+ }
208
+ }, [messages, streamingMessages, highlightMessageId]);
209
+
210
+ // EVERY render: if pinned, scroll to bottom BEFORE paint.
211
+ useLayoutEffect(() => {
212
+ if (pinnedToBottom.current) {
213
+ scrollToBottom();
214
+ }
215
+ });
216
+
217
+ // Scroll to highlighted message — MUST be the LAST useLayoutEffect so it
218
+ // runs after all the pinning/scrolling effects above and gets the final say.
219
+ useLayoutEffect(() => {
220
+ if (!highlightMessageId) return;
221
+ if (highlightMessageId === highlightHandled.current) return;
222
+ if (messages.length === 0) return;
223
+
224
+ const el = document.getElementById(`msg-${highlightMessageId}`);
225
+ if (el) {
226
+ highlightHandled.current = highlightMessageId;
227
+ pinnedToBottom.current = false;
228
+ el.scrollIntoView({ behavior: "instant", block: "center" });
229
+ }
230
+ }, [highlightMessageId, messages]);
231
+
232
+ // Infinite scroll: up for older messages, down for newer messages
233
+ const handleScroll = useCallback(() => {
234
+ checkIfPinned();
235
+ const container = scrollContainerRef.current;
236
+ if (!container) return;
237
+
238
+ const { scrollTop, scrollHeight, clientHeight } = container;
239
+
240
+ // Load older messages when scrolling near the top
241
+ if (scrollTop < 200 && hasMore && !loadingMore && onLoadMore) {
242
+ isLoadingOlder.current = true;
243
+ prevScrollHeightRef.current = scrollHeight;
244
+ pinnedToBottom.current = false;
245
+ onLoadMore();
246
+ }
247
+
248
+ // Load newer messages when scrolling near the bottom (anchor mode)
249
+ const scrollBottom = scrollHeight - scrollTop - clientHeight;
250
+ if (scrollBottom < 100 && hasMoreAfter && !loadingNewer && onLoadNewer) {
251
+ onLoadNewer();
252
+ }
253
+ }, [hasMore, loadingMore, onLoadMore, hasMoreAfter, loadingNewer, onLoadNewer, checkIfPinned]);
254
+
255
+ const getAgentName = (agentId: string) => {
256
+ const agent = agents.find((a) => a.id === agentId);
257
+ return agent?.name;
258
+ };
259
+
260
+ const getAgentAvatar = (agentId: string) => {
261
+ const agent = agents.find((a) => a.id === agentId);
262
+ return agent?.avatar;
263
+ };
264
+
265
+ if (!loading && messages.length === 0 && (!streamingMessages || streamingMessages.size === 0)) {
266
+ return (
267
+ <div className="flex-1 flex items-center justify-center">
268
+ <div className="flex flex-col items-center">
269
+ <MessageSquare className="h-9 w-9 text-foreground-secondary/40 mb-3" />
270
+ <span className="text-sm font-medium text-foreground-secondary">No messages yet</span>
271
+ <span className="text-xs text-foreground-secondary/60 mt-1">Start the conversation</span>
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ // Format a date label like Slack does
278
+ const formatDateLabel = (timestamp: number): string => {
279
+ const date = new Date(timestamp);
280
+ const now = new Date();
281
+ const yesterday = new Date(now);
282
+ yesterday.setDate(yesterday.getDate() - 1);
283
+
284
+ const isToday = date.toDateString() === now.toDateString();
285
+ const isYesterday = date.toDateString() === yesterday.toDateString();
286
+
287
+ if (isToday) return "Today";
288
+ if (isYesterday) return "Yesterday";
289
+
290
+ return formatDate(timestamp);
291
+ };
292
+
293
+ // Get the date string (YYYY-MM-DD) for grouping
294
+ const getDateKey = (timestamp: number): string =>
295
+ new Date(timestamp).toDateString();
296
+
297
+ // Group messages and insert date + session dividers
298
+ type GroupItem =
299
+ | ChatMessage
300
+ | { type: "date"; label: string; key: string }
301
+ | { type: "divider"; session: ChannelSession };
302
+
303
+ const groupedContent: GroupItem[] = [];
304
+ let currentDateKey: string | null = null;
305
+ let currentSessionId: string | null = null;
306
+
307
+ const sortedSessions = [...sessions].sort((a, b) => a.startedAt - b.startedAt);
308
+
309
+ for (const message of messages) {
310
+ // Insert date separator when the day changes
311
+ const dateKey = getDateKey(message.createdAt);
312
+ if (dateKey !== currentDateKey) {
313
+ groupedContent.push({
314
+ type: "date",
315
+ label: formatDateLabel(message.createdAt),
316
+ key: dateKey,
317
+ });
318
+ currentDateKey = dateKey;
319
+ }
320
+
321
+ // Insert session divider when session changes
322
+ if (message.sessionId && message.sessionId !== currentSessionId) {
323
+ if (currentSessionId) {
324
+ const endedSession = sortedSessions.find(
325
+ (s) => s.id === currentSessionId && s.endedAt
326
+ );
327
+ if (endedSession) {
328
+ groupedContent.push({ type: "divider", session: endedSession });
329
+ }
330
+ }
331
+ currentSessionId = message.sessionId;
332
+ }
333
+ groupedContent.push(message);
334
+ }
335
+
336
+ return (
337
+ <div
338
+ ref={scrollContainerRef}
339
+ className="flex-1 overflow-y-auto"
340
+ onScroll={handleScroll}
341
+ >
342
+ <div ref={contentRef} className="py-[20px] pr-[20px]">
343
+ {/* Loading more indicator */}
344
+ {loadingMore && (
345
+ <div className="flex justify-center py-2">
346
+ <Loader2 className="h-4 w-4 animate-spin text-foreground-secondary" />
347
+ </div>
348
+ )}
349
+
350
+ {hasMore && !loadingMore && (
351
+ <div className="flex justify-center py-2">
352
+ <button
353
+ onClick={onLoadMore}
354
+ className="text-xs text-foreground-secondary hover:text-foreground transition-colors"
355
+ >
356
+ Load older messages
357
+ </button>
358
+ </div>
359
+ )}
360
+
361
+ {/* Channel origin marker — shown when all history is loaded */}
362
+ {!hasMore && channelCreatedAt && (
363
+ <div className="flex flex-col items-center py-8 mb-2">
364
+ <CalendarDays className="h-9 w-9 text-foreground-secondary/40 mb-3" />
365
+ <span className="text-sm font-medium text-foreground-secondary">
366
+ Channel created on {formatDate(new Date(channelCreatedAt).getTime())}
367
+ </span>
368
+ <span className="text-xs text-foreground-secondary/60 mt-1">
369
+ This is the very beginning of the conversation
370
+ </span>
371
+ </div>
372
+ )}
373
+
374
+ {groupedContent.map((item, index) => {
375
+ if ("type" in item) {
376
+ if (item.type === "date") {
377
+ return (
378
+ <div
379
+ key={`date-${item.key}`}
380
+ className="flex items-center gap-3 py-1 my-[30px]"
381
+ >
382
+ <div className="flex-1 h-px bg-border" />
383
+ <span className="text-xs font-medium text-foreground-secondary shrink-0">
384
+ {item.label}
385
+ </span>
386
+ <div className="flex-1 h-px bg-border" />
387
+ </div>
388
+ );
389
+ }
390
+ if (item.type === "divider") {
391
+ return (
392
+ <SessionDivider
393
+ key={`divider-${item.session.id}`}
394
+ session={item.session}
395
+ />
396
+ );
397
+ }
398
+ }
399
+
400
+ const message = item as ChatMessage;
401
+ const agent = message.senderType === "agent"
402
+ ? agents.find((a) => a.id === message.senderId)
403
+ : undefined;
404
+
405
+ // Check if previous item was a message from the same sender (for grouping)
406
+ const prevItem = index > 0 ? groupedContent[index - 1] : null;
407
+ const prevMessage = prevItem && !("type" in prevItem) ? prevItem as ChatMessage : null;
408
+ const isSameSender = prevMessage
409
+ && prevMessage.senderType === message.senderType
410
+ && prevMessage.senderId === message.senderId;
411
+
412
+ return (
413
+ <div key={message.id} id={`msg-${message.id}`}>
414
+ <MessageBubble
415
+ message={message}
416
+ isAgent={message.senderType === "agent"}
417
+ agentName={agent?.name || getAgentName(message.senderId)}
418
+ agentAvatar={agent?.avatar || getAgentAvatar(message.senderId)}
419
+ userAvatar={userAvatar}
420
+ agents={agents}
421
+ showHeader={!isSameSender}
422
+ agentStatus={message.senderType === "agent" ? getAgentStatus(message.senderId) : undefined}
423
+ userStatus={message.senderType === "user" ? userStatus : undefined}
424
+ highlighted={highlightMessageId === message.id}
425
+ />
426
+ </div>
427
+ );
428
+ })}
429
+
430
+ {/* Loading newer messages indicator (anchor mode) */}
431
+ {loadingNewer && (
432
+ <div className="flex justify-center py-2">
433
+ <Loader2 className="h-4 w-4 animate-spin text-foreground-secondary" />
434
+ </div>
435
+ )}
436
+
437
+ {hasMoreAfter && !loadingNewer && (
438
+ <div className="flex justify-center py-2">
439
+ <button
440
+ onClick={onLoadNewer}
441
+ className="text-xs text-foreground-secondary hover:text-foreground transition-colors"
442
+ >
443
+ Load newer messages
444
+ </button>
445
+ </div>
446
+ )}
447
+
448
+ {/* Typing indicator — shows dots until the final message is persisted */}
449
+ {streamingMessages &&
450
+ Array.from(streamingMessages.values())
451
+ .filter((sm) => !messages.some((m) => m.runId === sm.runId && m.senderType === "agent"))
452
+ .map((sm) => (
453
+ <TypingIndicator
454
+ key={`streaming-${sm.runId}`}
455
+ agentId={sm.agentId}
456
+ agentName={sm.agentName || getAgentName(sm.agentId)}
457
+ agentAvatar={getAgentAvatar(sm.agentId)}
458
+ />
459
+ ))}
460
+
461
+ {/* Fallback: show dots for agents in "thinking" state without active streaming.
462
+ This covers the case where the user navigated away and came back —
463
+ streamingMessages is empty but the agent status is still "thinking" in the DB.
464
+ We iterate agentStatuses directly instead of the agents prop because the
465
+ agents list (from useOpenClaw) may still be empty on the first render if
466
+ the gateway connection hasn't been confirmed yet. */}
467
+ {Object.entries(agentStatuses)
468
+ .filter(([agentId, status]) => {
469
+ if (status !== "thinking" || agentId === USER_STATUS_ID) return false;
470
+ // Only show indicator for agents thinking in THIS channel
471
+ const thinkingIn = getThinkingChannel(agentId);
472
+ if (thinkingIn && thinkingIn !== channelId) return false;
473
+ // Skip if already shown via streaming messages above
474
+ if (streamingMessages?.size) {
475
+ const alreadyStreaming = Array.from(streamingMessages.values())
476
+ .some((sm) => sm.agentId === agentId);
477
+ if (alreadyStreaming) return false;
478
+ }
479
+ // Skip if agent already responded after the last user message
480
+ const lastUserIdx = messages.findLastIndex((m) => m.senderType === "user");
481
+ const lastAgentIdx = messages.findLastIndex(
482
+ (m) => m.senderType === "agent" && m.senderId === agentId
483
+ );
484
+ if (lastAgentIdx > lastUserIdx) return false;
485
+ return true;
486
+ })
487
+ .map(([agentId]) => {
488
+ // Try to get display info from the agents prop first, then from messages
489
+ const agentInfo = agents.find((a) => a.id === agentId);
490
+ const fallbackName = agentInfo?.name
491
+ || messages.findLast((m) => m.senderType === "agent" && m.senderId === agentId)?.senderName
492
+ || agentId;
493
+ const fallbackAvatar = agentInfo?.avatar ?? null;
494
+ return (
495
+ <TypingIndicator
496
+ key={`thinking-${agentId}`}
497
+ agentId={agentId}
498
+ agentName={fallbackName}
499
+ agentAvatar={fallbackAvatar}
500
+ />
501
+ );
502
+ })}
503
+
504
+ <div ref={bottomRef} />
505
+ </div>
506
+ </div>
507
+ );
508
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { X, Send, Loader2 } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface MessageQueueProps {
8
+ messages: string[];
9
+ onRemove: (index: number) => void;
10
+ onSendAll: () => void;
11
+ sending?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ export function MessageQueue({ messages, onRemove, onSendAll, sending, className }: MessageQueueProps) {
16
+ if (messages.length === 0) {
17
+ return null;
18
+ }
19
+
20
+ return (
21
+ <div className={cn("space-y-3", className)}>
22
+ {/* Header */}
23
+ <div className="flex items-center justify-between">
24
+ <span className="text-sm font-medium text-foreground-secondary">
25
+ Queued ({messages.length})
26
+ </span>
27
+ <Button
28
+ variant="primary"
29
+ size="sm"
30
+ onClick={onSendAll}
31
+ disabled={sending}
32
+ className="h-7"
33
+ >
34
+ {sending ? (
35
+ <>
36
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
37
+ Sending...
38
+ </>
39
+ ) : (
40
+ <>
41
+ <Send className="h-3 w-3 mr-1" />
42
+ Send All
43
+ </>
44
+ )}
45
+ </Button>
46
+ </div>
47
+
48
+ {/* Queued messages */}
49
+ <div className="space-y-2">
50
+ {messages.map((message, index) => (
51
+ <div
52
+ key={index}
53
+ className="relative p-3 rounded-xl border border-dashed border-border bg-surface-hover/50"
54
+ >
55
+ <button
56
+ onClick={() => onRemove(index)}
57
+ className="absolute top-2 right-2 p-1 rounded-full hover:bg-surface-hover text-foreground-secondary hover:text-foreground"
58
+ disabled={sending}
59
+ >
60
+ <X className="h-3 w-3" />
61
+ </button>
62
+ <p className="text-sm pr-6 line-clamp-2">{message}</p>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronUp } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { formatDateTime } from "@/lib/date-utils";
7
+ import type { ChannelSession } from "@/lib/types/chat";
8
+
9
+ interface SessionDividerProps {
10
+ session: ChannelSession;
11
+ className?: string;
12
+ }
13
+
14
+ export function SessionDivider({ session, className }: SessionDividerProps) {
15
+ const [expanded, setExpanded] = useState(false);
16
+
17
+ const endedDate = session.endedAt
18
+ ? formatDateTime(session.endedAt)
19
+ : "Ongoing";
20
+
21
+ return (
22
+ <div className={cn("relative py-4", className)}>
23
+ {/* Divider line */}
24
+ <div className="absolute inset-x-0 top-1/2 h-px bg-border" />
25
+
26
+ {/* Content */}
27
+ <div className="relative flex items-center justify-center">
28
+ <div className="bg-surface px-4 py-2 rounded-full border border-border">
29
+ <button
30
+ onClick={() => setExpanded(!expanded)}
31
+ className="flex items-center gap-2 text-xs text-foreground-secondary hover:text-foreground transition-colors"
32
+ >
33
+ <span>Session ended {endedDate}</span>
34
+ {session.summary && (
35
+ expanded ? (
36
+ <ChevronUp className="h-3 w-3" />
37
+ ) : (
38
+ <ChevronDown className="h-3 w-3" />
39
+ )
40
+ )}
41
+ </button>
42
+ </div>
43
+ </div>
44
+
45
+ {/* Expanded summary */}
46
+ {expanded && session.summary && (
47
+ <div className="mt-3 mx-auto max-w-xl p-4 rounded-xl bg-surface-hover border border-border">
48
+ <p className="text-sm text-foreground-secondary leading-relaxed">
49
+ <span className="font-medium text-foreground">Summary:</span>{" "}
50
+ {session.summary}
51
+ </p>
52
+ <div className="mt-2 flex items-center gap-4 text-xs text-foreground-secondary/60">
53
+ <span>
54
+ {session.totalInputTokens.toLocaleString()} in / {session.totalOutputTokens.toLocaleString()} out
55
+ </span>
56
+ </div>
57
+ </div>
58
+ )}
59
+ </div>
60
+ );
61
+ }