@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.
- package/LICENSE +21 -0
- package/install.sh +20 -1
- package/package.json +17 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- 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 {
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
<
|
|
577
|
+
<MessageBubble
|
|
454
578
|
key={`streaming-${sm.runId}`}
|
|
455
|
-
|
|
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
|
-
<
|
|
623
|
+
<MessageBubble
|
|
496
624
|
key={`thinking-${agentId}`}
|
|
497
|
-
|
|
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
|
}
|