@castlekit/castle 0.1.5 → 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.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- 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
|
+
}
|