Skip to content

Commit 1c85544

Browse files
authored
Split SME chat list and add auto-scroll handling (#422)
- Move message rendering into a memoized list component - Add scroll-aware auto-follow behavior during streaming and send states - Render streaming text as plain text and simplify bubble updates
1 parent 5d656de commit 1c85544

3 files changed

Lines changed: 108 additions & 63 deletions

File tree

apps/web/src/components/sme/SmeChatWorkspace.tsx

Lines changed: 3 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
1313
import { toastManager } from "~/components/ui/toast";
1414

1515
import { SmeConversationDialog } from "./SmeConversationDialog";
16-
import { SmeMessageBubble } from "./SmeMessageBubble";
16+
import { SmeMessageList } from "./SmeMessageList";
1717
import { getSmeAuthMethodLabel, SME_PROVIDER_LABELS } from "./smeConversationConfig";
1818

19-
const EMPTY_MESSAGES: SmeMessage[] = [];
20-
2119
interface SmeChatWorkspaceProps {
2220
conversationId: string | null;
2321
onToggleKnowledge: () => void;
@@ -36,17 +34,9 @@ export function SmeChatWorkspace({
3634
() => conversations.find((item) => item.conversationId === conversationId) ?? null,
3735
[conversationId, conversations],
3836
);
39-
const messages = useSmeStore((state) =>
40-
conversationId
41-
? (state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES)
42-
: EMPTY_MESSAGES,
43-
);
4437
const conversationError = useSmeStore((state) =>
4538
conversationId ? state.errorsByConversation[conversationId] : undefined,
4639
);
47-
const streamingConversationId = useSmeStore((state) => state.streamingConversationId);
48-
const streamingMessageId = useSmeStore((state) => state.streamingMessageId);
49-
const streamingText = useSmeStore((state) => state.streamingText);
5040
const addUserMessage = useSmeStore((state) => state.addUserMessage);
5141
const clearStream = useSmeStore((state) => state.clearStream);
5242
const setMessages = useSmeStore((state) => state.setMessages);
@@ -55,7 +45,6 @@ export function SmeChatWorkspace({
5545
const [sending, setSending] = useState(false);
5646
const [dialogOpen, setDialogOpen] = useState(false);
5747
const [bannerDismissed, setBannerDismissed] = useState(false);
58-
const messagesEndRef = useRef<HTMLDivElement>(null);
5948
const textareaRef = useRef<HTMLTextAreaElement>(null);
6049
const serverConfigQuery = useQuery(serverConfigQueryOptions());
6150
const validationQuery = useQuery({
@@ -96,10 +85,6 @@ export function SmeChatWorkspace({
9685
setBannerDismissed(false);
9786
}, [conversationId]);
9887

99-
useEffect(() => {
100-
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
101-
}, [messages, streamingText]);
102-
10388
useEffect(() => {
10489
const textarea = textareaRef.current;
10590
if (!textarea) return;
@@ -116,7 +101,7 @@ export function SmeChatWorkspace({
116101
setInputText("");
117102
setSending(true);
118103
setConversationError(conversationId, undefined);
119-
const previousMessages = messages;
104+
const previousMessages = useSmeStore.getState().messagesByConversation[conversationId] ?? [];
120105

121106
addUserMessage(conversationId, {
122107
messageId: `temp-${Date.now()}` as SmeMessageId,
@@ -174,7 +159,6 @@ export function SmeChatWorkspace({
174159
conversation,
175160
conversationId,
176161
inputText,
177-
messages,
178162
providerOptions,
179163
sendDisabled,
180164
setConversationError,
@@ -289,47 +273,7 @@ export function SmeChatWorkspace({
289273
) : null}
290274
</div>
291275

292-
<div className="flex-1 overflow-y-auto">
293-
<div className="mx-auto max-w-3xl">
294-
{messages.map((message) => (
295-
<SmeMessageBubble key={message.messageId} message={message} />
296-
))}
297-
{streamingConversationId === conversationId && streamingText ? (
298-
<SmeMessageBubble
299-
message={
300-
{
301-
messageId: (streamingMessageId ?? "streaming") as SmeMessageId,
302-
conversationId: conversationId as SmeConversationId,
303-
role: "assistant",
304-
text: streamingText,
305-
isStreaming: true,
306-
createdAt: new Date().toISOString(),
307-
updatedAt: new Date().toISOString(),
308-
} as SmeMessage
309-
}
310-
/>
311-
) : null}
312-
{sending && !streamingText ? (
313-
<div className="flex items-center gap-4 px-4 py-5">
314-
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-primary/80 to-primary text-primary-foreground">
315-
<SparklesIcon className="size-4" />
316-
</div>
317-
<div className="space-y-1">
318-
<p className="text-xs font-medium text-muted-foreground">SME Assistant</p>
319-
<div className="flex items-center gap-1.5">
320-
<div className="flex gap-1">
321-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:0ms]" />
322-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:150ms]" />
323-
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:300ms]" />
324-
</div>
325-
<span className="text-xs text-muted-foreground">Thinking...</span>
326-
</div>
327-
</div>
328-
</div>
329-
) : null}
330-
<div ref={messagesEndRef} />
331-
</div>
332-
</div>
276+
<SmeMessageList conversationId={conversationId} sending={sending} />
333277

334278
<div className="px-4 pb-4 pt-2">
335279
<div className="mx-auto max-w-3xl">

apps/web/src/components/sme/SmeMessageBubble.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lazy, Suspense } from "react";
1+
import { lazy, memo, Suspense } from "react";
22
import { UserIcon, SparklesIcon } from "lucide-react";
33
import type { SmeMessage } from "@okcode/contracts";
44

@@ -10,8 +10,9 @@ interface SmeMessageBubbleProps {
1010
message: SmeMessage;
1111
}
1212

13-
export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
13+
export const SmeMessageBubble = memo(function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
1414
const isUser = message.role === "user";
15+
const renderPlainText = isUser || Boolean(message.isStreaming);
1516

1617
return (
1718
<div
@@ -40,7 +41,7 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
4041
isUser ? "bg-primary text-primary-foreground" : "bg-muted/60 text-foreground",
4142
)}
4243
>
43-
{isUser ? (
44+
{renderPlainText ? (
4445
<div className="whitespace-pre-wrap break-words">{message.text}</div>
4546
) : (
4647
<Suspense
@@ -62,4 +63,4 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
6263
</div>
6364
</div>
6465
);
65-
}
66+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { memo, useEffect, useRef } from "react";
2+
import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts";
3+
4+
import { isScrollContainerNearBottom } from "~/chat-scroll";
5+
import { useSmeStore } from "~/smeStore";
6+
7+
import { SmeMessageBubble } from "./SmeMessageBubble";
8+
9+
const EMPTY_MESSAGES: SmeMessage[] = [];
10+
11+
interface SmeMessageListProps {
12+
conversationId: string;
13+
sending: boolean;
14+
}
15+
16+
export const SmeMessageList = memo(function SmeMessageList({
17+
conversationId,
18+
sending,
19+
}: SmeMessageListProps) {
20+
const messages = useSmeStore(
21+
(state) => state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES,
22+
);
23+
const streamingConversationId = useSmeStore((state) => state.streamingConversationId);
24+
const streamingMessageId = useSmeStore((state) => state.streamingMessageId);
25+
const streamingText = useSmeStore((state) => state.streamingText);
26+
const scrollContainerRef = useRef<HTMLDivElement>(null);
27+
const messagesEndRef = useRef<HTMLDivElement>(null);
28+
const shouldAutoScrollRef = useRef(true);
29+
30+
useEffect(() => {
31+
const scrollContainer = scrollContainerRef.current;
32+
if (!scrollContainer) return;
33+
34+
shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
35+
36+
const handleScroll = () => {
37+
shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
38+
};
39+
40+
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
41+
return () => {
42+
scrollContainer.removeEventListener("scroll", handleScroll);
43+
};
44+
}, []);
45+
46+
useEffect(() => {
47+
if (!shouldAutoScrollRef.current) {
48+
return;
49+
}
50+
51+
messagesEndRef.current?.scrollIntoView({
52+
behavior: streamingConversationId === conversationId ? "auto" : "smooth",
53+
block: "end",
54+
});
55+
}, [conversationId, messages, streamingConversationId, streamingText]);
56+
57+
return (
58+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
59+
<div className="mx-auto max-w-3xl">
60+
{messages.map((message) => (
61+
<SmeMessageBubble key={message.messageId} message={message} />
62+
))}
63+
{streamingConversationId === conversationId && streamingText ? (
64+
<SmeMessageBubble
65+
message={
66+
{
67+
messageId: (streamingMessageId ?? "streaming") as SmeMessageId,
68+
conversationId: conversationId as SmeConversationId,
69+
role: "assistant",
70+
text: streamingText,
71+
isStreaming: true,
72+
createdAt: new Date().toISOString(),
73+
updatedAt: new Date().toISOString(),
74+
} as SmeMessage
75+
}
76+
/>
77+
) : null}
78+
{sending && streamingConversationId !== conversationId ? (
79+
<div className="flex items-center gap-4 px-4 py-5">
80+
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-primary/80 to-primary text-primary-foreground">
81+
<div className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin" />
82+
</div>
83+
<div className="space-y-1">
84+
<p className="text-xs font-medium text-muted-foreground">SME Assistant</p>
85+
<div className="flex items-center gap-1.5">
86+
<div className="flex gap-1">
87+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:0ms]" />
88+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:150ms]" />
89+
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:300ms]" />
90+
</div>
91+
<span className="text-xs text-muted-foreground">Thinking...</span>
92+
</div>
93+
</div>
94+
</div>
95+
) : null}
96+
<div ref={messagesEndRef} />
97+
</div>
98+
</div>
99+
);
100+
});

0 commit comments

Comments
 (0)