@castlekit/castle 0.3.0 → 0.3.1

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/install.sh +20 -1
  3. package/package.json +17 -2
  4. package/src/app/api/openclaw/agents/route.ts +7 -1
  5. package/src/app/api/openclaw/chat/channels/route.ts +6 -3
  6. package/src/app/api/openclaw/chat/route.ts +17 -6
  7. package/src/app/api/openclaw/chat/search/route.ts +2 -1
  8. package/src/app/api/openclaw/config/route.ts +2 -0
  9. package/src/app/api/openclaw/events/route.ts +23 -8
  10. package/src/app/api/openclaw/ping/route.ts +5 -0
  11. package/src/app/api/openclaw/session/context/route.ts +163 -0
  12. package/src/app/api/openclaw/session/status/route.ts +179 -11
  13. package/src/app/api/openclaw/sessions/route.ts +2 -0
  14. package/src/app/chat/[channelId]/page.tsx +115 -35
  15. package/src/app/globals.css +10 -0
  16. package/src/app/page.tsx +10 -8
  17. package/src/components/chat/chat-input.tsx +23 -5
  18. package/src/components/chat/message-bubble.tsx +29 -13
  19. package/src/components/chat/message-list.tsx +238 -80
  20. package/src/components/chat/session-stats-panel.tsx +391 -86
  21. package/src/components/providers/search-provider.tsx +33 -4
  22. package/src/lib/db/index.ts +12 -2
  23. package/src/lib/db/queries.ts +199 -72
  24. package/src/lib/db/schema.ts +4 -0
  25. package/src/lib/gateway-connection.ts +24 -3
  26. package/src/lib/hooks/use-chat.ts +219 -241
  27. package/src/lib/hooks/use-compaction-events.ts +132 -0
  28. package/src/lib/hooks/use-context-boundary.ts +82 -0
  29. package/src/lib/hooks/use-openclaw.ts +44 -57
  30. package/src/lib/hooks/use-search.ts +1 -0
  31. package/src/lib/hooks/use-session-stats.ts +4 -1
  32. package/src/lib/sse-singleton.ts +184 -0
  33. package/src/lib/types/chat.ts +22 -6
  34. package/src/lib/db/__tests__/queries.test.ts +0 -318
  35. package/vitest.config.ts +0 -13
@@ -1,59 +1,37 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useLayoutEffect, useRef, useCallback } from "react";
4
- import { Bot, CalendarDays, Loader2, MessageSquare } from "lucide-react";
3
+ import { useEffect, useLayoutEffect, useRef, useCallback, useState } from "react";
4
+ import { CalendarDays, ChevronUp, Loader2, MessageSquare, Zap } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
5
6
  import { MessageBubble } from "./message-bubble";
6
7
  import { SessionDivider } from "./session-divider";
7
- import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
8
8
  import { useAgentStatus, setAgentActive, getThinkingChannel, USER_STATUS_ID } from "@/lib/hooks/use-agent-status";
9
9
  import { formatDate } from "@/lib/date-utils";
10
10
  import type { ChatMessage, ChannelSession, StreamingMessage } from "@/lib/types/chat";
11
11
  import type { AgentInfo } from "./agent-mention-popup";
12
12
 
13
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
- );
14
+ // Build a lightweight fake ChatMessage for the typing-indicator MessageBubble.
15
+ // This avoids a separate component entirely — the indicator IS a MessageBubble.
16
+ function makeIndicatorMessage(agentId: string, agentName?: string): ChatMessage {
17
+ return {
18
+ id: `typing-${agentId}`,
19
+ channelId: "",
20
+ sessionId: null,
21
+ senderType: "agent",
22
+ senderId: agentId,
23
+ senderName: agentName || null,
24
+ content: "",
25
+ status: undefined as never,
26
+ mentionedAgentId: null,
27
+ runId: null,
28
+ sessionKey: null,
29
+ inputTokens: null,
30
+ outputTokens: null,
31
+ createdAt: Date.now(),
32
+ attachments: [],
33
+ reactions: [],
34
+ };
57
35
  }
58
36
 
59
37
  interface MessageListProps {
@@ -76,6 +54,12 @@ interface MessageListProps {
76
54
  channelName?: string | null;
77
55
  channelCreatedAt?: number | null;
78
56
  highlightMessageId?: string;
57
+ /** Ref that will be populated with a navigate-between-messages function */
58
+ navigateRef?: React.MutableRefObject<((direction: "up" | "down") => void) | null>;
59
+ /** ID of the oldest message still in the agent's context (compaction boundary) */
60
+ compactionBoundaryMessageId?: string | null;
61
+ /** Total number of compactions in this session */
62
+ compactionCount?: number;
79
63
  }
80
64
 
81
65
  export function MessageList({
@@ -95,6 +79,9 @@ export function MessageList({
95
79
  channelName,
96
80
  channelCreatedAt,
97
81
  highlightMessageId,
82
+ navigateRef,
83
+ compactionBoundaryMessageId,
84
+ compactionCount,
98
85
  }: MessageListProps) {
99
86
  const { statuses: agentStatuses, getStatus: getAgentStatus } = useAgentStatus();
100
87
  const userStatus = getAgentStatus(USER_STATUS_ID);
@@ -133,6 +120,13 @@ export function MessageList({
133
120
  const prevScrollHeightRef = useRef<number>(0);
134
121
  const highlightHandled = useRef<string | null>(null);
135
122
 
123
+ // "Scroll to message start" button — shows when the last agent message
124
+ // extends beyond the viewport so the user can jump to where it began.
125
+ const [showScrollToStart, setShowScrollToStart] = useState(false);
126
+ const lastAgentMsgId = useRef<string | null>(null);
127
+ // Once the user has navigated to a message's start, don't show the button again for it
128
+ const dismissedMsgId = useRef<string | null>(null);
129
+
136
130
  // Scroll helper
137
131
  const scrollToBottom = useCallback(() => {
138
132
  const container = scrollContainerRef.current;
@@ -229,9 +223,57 @@ export function MessageList({
229
223
  }
230
224
  }, [highlightMessageId, messages]);
231
225
 
226
+ // Track the last agent message ID so we can check if its top is in view.
227
+ // On initial load, auto-dismiss so the button only appears for NEW messages.
228
+ useEffect(() => {
229
+ const lastAgent = [...messages].reverse().find((m) => m.senderType === "agent");
230
+ const prevId = lastAgentMsgId.current;
231
+ lastAgentMsgId.current = lastAgent?.id ?? null;
232
+
233
+ if (!lastAgent) {
234
+ setShowScrollToStart(false);
235
+ } else if (prevId === null) {
236
+ // First time we see messages (page load) — dismiss the current one
237
+ dismissedMsgId.current = lastAgent.id;
238
+ }
239
+ }, [messages]);
240
+
241
+ // Check if the last agent message's top edge is scrolled out of view.
242
+ // Called on every scroll event via handleScroll.
243
+ const checkScrollToStart = useCallback(() => {
244
+ const container = scrollContainerRef.current;
245
+ if (!lastAgentMsgId.current || !container) {
246
+ setShowScrollToStart(false);
247
+ return;
248
+ }
249
+ // Already dismissed for this message — don't show again
250
+ if (dismissedMsgId.current === lastAgentMsgId.current) {
251
+ setShowScrollToStart(false);
252
+ return;
253
+ }
254
+ const el = document.getElementById(`msg-${lastAgentMsgId.current}`);
255
+ if (!el) {
256
+ setShowScrollToStart(false);
257
+ return;
258
+ }
259
+ const containerRect = container.getBoundingClientRect();
260
+ const elRect = el.getBoundingClientRect();
261
+ // Show button when the top of the message is above the container's top edge
262
+ // AND the bottom is still visible (user is reading the message)
263
+ const topAboveView = elRect.top < containerRect.top - 10;
264
+ const bottomStillVisible = elRect.bottom > containerRect.top;
265
+ const shouldShow = topAboveView && bottomStillVisible;
266
+ // If user scrolled to the top of the message, dismiss permanently
267
+ if (!topAboveView && dismissedMsgId.current !== lastAgentMsgId.current) {
268
+ dismissedMsgId.current = lastAgentMsgId.current;
269
+ }
270
+ setShowScrollToStart(shouldShow);
271
+ }, []);
272
+
232
273
  // Infinite scroll: up for older messages, down for newer messages
233
274
  const handleScroll = useCallback(() => {
234
275
  checkIfPinned();
276
+ checkScrollToStart();
235
277
  const container = scrollContainerRef.current;
236
278
  if (!container) return;
237
279
 
@@ -250,7 +292,7 @@ export function MessageList({
250
292
  if (scrollBottom < 100 && hasMoreAfter && !loadingNewer && onLoadNewer) {
251
293
  onLoadNewer();
252
294
  }
253
- }, [hasMore, loadingMore, onLoadMore, hasMoreAfter, loadingNewer, onLoadNewer, checkIfPinned]);
295
+ }, [hasMore, loadingMore, onLoadMore, hasMoreAfter, loadingNewer, onLoadNewer, checkIfPinned, checkScrollToStart]);
254
296
 
255
297
  const getAgentName = (agentId: string) => {
256
298
  const agent = agents.find((a) => a.id === agentId);
@@ -262,18 +304,6 @@ export function MessageList({
262
304
  return agent?.avatar;
263
305
  };
264
306
 
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
307
  // Format a date label like Slack does
278
308
  const formatDateLabel = (timestamp: number): string => {
279
309
  const date = new Date(timestamp);
@@ -333,12 +363,85 @@ export function MessageList({
333
363
  groupedContent.push(message);
334
364
  }
335
365
 
366
+ // Navigate between messages with Shift+Up/Down (also used by "Start of message" button)
367
+ const navigateToMessage = useCallback(
368
+ (direction: "up" | "down") => {
369
+ const container = scrollContainerRef.current;
370
+ if (!container || messages.length === 0) return;
371
+
372
+ const containerRect = container.getBoundingClientRect();
373
+ const threshold = 10; // px tolerance to avoid sticking on current message
374
+
375
+ if (direction === "up") {
376
+ // Find the last message whose top is above the current viewport top
377
+ for (let i = messages.length - 1; i >= 0; i--) {
378
+ const el = document.getElementById(`msg-${messages[i].id}`);
379
+ if (!el) continue;
380
+ const rect = el.getBoundingClientRect();
381
+ if (rect.top < containerRect.top - threshold) {
382
+ const offset =
383
+ rect.top - containerRect.top + container.scrollTop - 8;
384
+ container.scrollTo({ top: offset, behavior: "smooth" });
385
+ return;
386
+ }
387
+ }
388
+ // Already at the top — scroll to very top
389
+ container.scrollTo({ top: 0, behavior: "smooth" });
390
+ } else {
391
+ // Find the first message whose top is below the current viewport top
392
+ for (let i = 0; i < messages.length; i++) {
393
+ const el = document.getElementById(`msg-${messages[i].id}`);
394
+ if (!el) continue;
395
+ const rect = el.getBoundingClientRect();
396
+ if (rect.top > containerRect.top + threshold) {
397
+ const offset =
398
+ rect.top - containerRect.top + container.scrollTop - 8;
399
+ container.scrollTo({ top: offset, behavior: "smooth" });
400
+ return;
401
+ }
402
+ }
403
+ // Already at the bottom — scroll to very bottom
404
+ container.scrollTo({
405
+ top: container.scrollHeight,
406
+ behavior: "smooth",
407
+ });
408
+ }
409
+ },
410
+ [messages]
411
+ );
412
+
413
+ // Expose navigateToMessage to parent via ref
414
+ useEffect(() => {
415
+ if (navigateRef) {
416
+ navigateRef.current = navigateToMessage;
417
+ }
418
+ return () => {
419
+ if (navigateRef) {
420
+ navigateRef.current = null;
421
+ }
422
+ };
423
+ }, [navigateRef, navigateToMessage]);
424
+
425
+ if (!loading && messages.length === 0 && (!streamingMessages || streamingMessages.size === 0)) {
426
+ return (
427
+ <div className="flex-1 flex items-center justify-center">
428
+ <div className="flex flex-col items-center">
429
+ <MessageSquare className="h-9 w-9 text-foreground-secondary/40 mb-3" />
430
+ <span className="text-sm font-medium text-foreground-secondary">No messages yet</span>
431
+ <span className="text-xs text-foreground-secondary/60 mt-1">Start the conversation</span>
432
+ </div>
433
+ </div>
434
+ );
435
+ }
436
+
336
437
  return (
337
- <div
338
- ref={scrollContainerRef}
339
- className="flex-1 overflow-y-auto"
340
- onScroll={handleScroll}
341
- >
438
+ <div className="flex-1 relative overflow-hidden">
439
+ <div
440
+ ref={scrollContainerRef}
441
+ className="h-full overflow-y-auto flex flex-col"
442
+ onScroll={handleScroll}
443
+ >
444
+ <div className="flex-1" />
342
445
  <div ref={contentRef} className="py-[20px] pr-[20px]">
343
446
  {/* Loading more indicator */}
344
447
  {loadingMore && (
@@ -409,20 +512,41 @@ export function MessageList({
409
512
  && prevMessage.senderType === message.senderType
410
513
  && prevMessage.senderId === message.senderId;
411
514
 
515
+ // Determine if this message is compacted (before the boundary)
516
+ const isCompacted = compactionBoundaryMessageId
517
+ ? message.id !== compactionBoundaryMessageId &&
518
+ messages.indexOf(message) < messages.findIndex((m) => m.id === compactionBoundaryMessageId)
519
+ : false;
520
+
521
+ // Show compaction divider just before the boundary message
522
+ const showCompactionDivider = compactionBoundaryMessageId === message.id && (compactionCount ?? 0) > 0;
523
+
412
524
  return (
413
525
  <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
- />
526
+ {showCompactionDivider && (
527
+ <div className="flex items-center gap-3 py-1 my-[20px]">
528
+ <div className="flex-1 h-px bg-yellow-500/30" />
529
+ <span className="text-[11px] text-yellow-600 dark:text-yellow-400 shrink-0 flex items-center gap-1.5">
530
+ <Zap className="h-3 w-3" />
531
+ Context compacted ({compactionCount} time{compactionCount !== 1 ? "s" : ""}) — agent has a summary of messages above
532
+ </span>
533
+ <div className="flex-1 h-px bg-yellow-500/30" />
534
+ </div>
535
+ )}
536
+ <div className={cn(isCompacted && "opacity-50")}>
537
+ <MessageBubble
538
+ message={message}
539
+ isAgent={message.senderType === "agent"}
540
+ agentName={agent?.name || getAgentName(message.senderId)}
541
+ agentAvatar={agent?.avatar || getAgentAvatar(message.senderId)}
542
+ userAvatar={userAvatar}
543
+ agents={agents}
544
+ showHeader={!isSameSender}
545
+ agentStatus={message.senderType === "agent" ? getAgentStatus(message.senderId) : undefined}
546
+ userStatus={message.senderType === "user" ? userStatus : undefined}
547
+ highlighted={highlightMessageId === message.id}
548
+ />
549
+ </div>
426
550
  </div>
427
551
  );
428
552
  })}
@@ -450,11 +574,15 @@ export function MessageList({
450
574
  Array.from(streamingMessages.values())
451
575
  .filter((sm) => !messages.some((m) => m.runId === sm.runId && m.senderType === "agent"))
452
576
  .map((sm) => (
453
- <TypingIndicator
577
+ <MessageBubble
454
578
  key={`streaming-${sm.runId}`}
455
- agentId={sm.agentId}
579
+ message={makeIndicatorMessage(sm.agentId, sm.agentName || getAgentName(sm.agentId))}
580
+ isAgent
456
581
  agentName={sm.agentName || getAgentName(sm.agentId)}
457
582
  agentAvatar={getAgentAvatar(sm.agentId)}
583
+ agents={agents}
584
+ agentStatus={getAgentStatus(sm.agentId)}
585
+ isTypingIndicator
458
586
  />
459
587
  ))}
460
588
 
@@ -492,17 +620,47 @@ export function MessageList({
492
620
  || agentId;
493
621
  const fallbackAvatar = agentInfo?.avatar ?? null;
494
622
  return (
495
- <TypingIndicator
623
+ <MessageBubble
496
624
  key={`thinking-${agentId}`}
497
- agentId={agentId}
625
+ message={makeIndicatorMessage(agentId, fallbackName)}
626
+ isAgent
498
627
  agentName={fallbackName}
499
628
  agentAvatar={fallbackAvatar}
629
+ agents={agents}
630
+ agentStatus={getAgentStatus(agentId)}
631
+ isTypingIndicator
500
632
  />
501
633
  );
502
634
  })}
503
635
 
504
636
  <div ref={bottomRef} />
505
637
  </div>
638
+ </div>
639
+
640
+ {/* Scroll to message start — appears when the last agent message's top is out of view */}
641
+ {showScrollToStart && (
642
+ <button
643
+ onClick={() => {
644
+ dismissedMsgId.current = lastAgentMsgId.current;
645
+ setShowScrollToStart(false);
646
+ // Scroll directly to the last agent message's start
647
+ if (lastAgentMsgId.current) {
648
+ const el = document.getElementById(`msg-${lastAgentMsgId.current}`);
649
+ const container = scrollContainerRef.current;
650
+ if (el && container) {
651
+ const containerRect = container.getBoundingClientRect();
652
+ const elRect = el.getBoundingClientRect();
653
+ const offset = elRect.top - containerRect.top + container.scrollTop - 8;
654
+ container.scrollTo({ top: offset, behavior: "smooth" });
655
+ }
656
+ }
657
+ }}
658
+ className="absolute bottom-3 right-0 h-12 w-12 flex items-center justify-center rounded-[var(--radius-sm)] bg-surface border border-border shadow-md text-foreground-secondary hover:text-foreground hover:border-border-hover transition-all cursor-pointer z-10"
659
+ title="Scroll to start of message (Shift+↑)"
660
+ >
661
+ <ChevronUp className="h-5 w-5" />
662
+ </button>
663
+ )}
506
664
  </div>
507
665
  );
508
666
  }