Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 3 additions & 59 deletions apps/web/src/components/sme/SmeChatWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
import { toastManager } from "~/components/ui/toast";

import { SmeConversationDialog } from "./SmeConversationDialog";
import { SmeMessageBubble } from "./SmeMessageBubble";
import { SmeMessageList } from "./SmeMessageList";
import { getSmeAuthMethodLabel, SME_PROVIDER_LABELS } from "./smeConversationConfig";

const EMPTY_MESSAGES: SmeMessage[] = [];

interface SmeChatWorkspaceProps {
conversationId: string | null;
onToggleKnowledge: () => void;
Expand All @@ -36,17 +34,9 @@ export function SmeChatWorkspace({
() => conversations.find((item) => item.conversationId === conversationId) ?? null,
[conversationId, conversations],
);
const messages = useSmeStore((state) =>
conversationId
? (state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES)
: EMPTY_MESSAGES,
);
const conversationError = useSmeStore((state) =>
conversationId ? state.errorsByConversation[conversationId] : undefined,
);
const streamingConversationId = useSmeStore((state) => state.streamingConversationId);
const streamingMessageId = useSmeStore((state) => state.streamingMessageId);
const streamingText = useSmeStore((state) => state.streamingText);
const addUserMessage = useSmeStore((state) => state.addUserMessage);
const clearStream = useSmeStore((state) => state.clearStream);
const setMessages = useSmeStore((state) => state.setMessages);
Expand All @@ -55,7 +45,6 @@ export function SmeChatWorkspace({
const [sending, setSending] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [bannerDismissed, setBannerDismissed] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const serverConfigQuery = useQuery(serverConfigQueryOptions());
const validationQuery = useQuery({
Expand Down Expand Up @@ -96,10 +85,6 @@ export function SmeChatWorkspace({
setBannerDismissed(false);
}, [conversationId]);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamingText]);

useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
Expand All @@ -116,7 +101,7 @@ export function SmeChatWorkspace({
setInputText("");
setSending(true);
setConversationError(conversationId, undefined);
const previousMessages = messages;
const previousMessages = useSmeStore.getState().messagesByConversation[conversationId] ?? [];

addUserMessage(conversationId, {
messageId: `temp-${Date.now()}` as SmeMessageId,
Expand Down Expand Up @@ -174,7 +159,6 @@ export function SmeChatWorkspace({
conversation,
conversationId,
inputText,
messages,
providerOptions,
sendDisabled,
setConversationError,
Expand Down Expand Up @@ -289,47 +273,7 @@ export function SmeChatWorkspace({
) : null}
</div>

<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl">
{messages.map((message) => (
<SmeMessageBubble key={message.messageId} message={message} />
))}
{streamingConversationId === conversationId && streamingText ? (
<SmeMessageBubble
message={
{
messageId: (streamingMessageId ?? "streaming") as SmeMessageId,
conversationId: conversationId as SmeConversationId,
role: "assistant",
text: streamingText,
isStreaming: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as SmeMessage
}
/>
) : null}
{sending && !streamingText ? (
<div className="flex items-center gap-4 px-4 py-5">
<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">
<SparklesIcon className="size-4" />
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">SME Assistant</p>
<div className="flex items-center gap-1.5">
<div className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:0ms]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:150ms]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:300ms]" />
</div>
<span className="text-xs text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
<SmeMessageList conversationId={conversationId} sending={sending} />

<div className="px-4 pb-4 pt-2">
<div className="mx-auto max-w-3xl">
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/sme/SmeMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense } from "react";
import { lazy, memo, Suspense } from "react";
import { UserIcon, SparklesIcon } from "lucide-react";
import type { SmeMessage } from "@okcode/contracts";

Expand All @@ -10,8 +10,9 @@ interface SmeMessageBubbleProps {
message: SmeMessage;
}

export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
export const SmeMessageBubble = memo(function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
const isUser = message.role === "user";
const renderPlainText = isUser || Boolean(message.isStreaming);

return (
<div
Expand Down Expand Up @@ -40,7 +41,7 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
isUser ? "bg-primary text-primary-foreground" : "bg-muted/60 text-foreground",
)}
>
{isUser ? (
{renderPlainText ? (
<div className="whitespace-pre-wrap break-words">{message.text}</div>
) : (
<Suspense
Expand All @@ -62,4 +63,4 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
</div>
</div>
);
}
});
100 changes: 100 additions & 0 deletions apps/web/src/components/sme/SmeMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { memo, useEffect, useRef } from "react";
import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts";

import { isScrollContainerNearBottom } from "~/chat-scroll";
import { useSmeStore } from "~/smeStore";

import { SmeMessageBubble } from "./SmeMessageBubble";

const EMPTY_MESSAGES: SmeMessage[] = [];

interface SmeMessageListProps {
conversationId: string;
sending: boolean;
}

export const SmeMessageList = memo(function SmeMessageList({
conversationId,
sending,
}: SmeMessageListProps) {
const messages = useSmeStore(
(state) => state.messagesByConversation[conversationId] ?? EMPTY_MESSAGES,
);
const streamingConversationId = useSmeStore((state) => state.streamingConversationId);
const streamingMessageId = useSmeStore((state) => state.streamingMessageId);
const streamingText = useSmeStore((state) => state.streamingText);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);

useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;

shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);

const handleScroll = () => {
shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
};

scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
};
}, []);

useEffect(() => {
if (!shouldAutoScrollRef.current) {
return;
}

messagesEndRef.current?.scrollIntoView({
behavior: streamingConversationId === conversationId ? "auto" : "smooth",
block: "end",
});
}, [conversationId, messages, streamingConversationId, streamingText]);

return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl">
{messages.map((message) => (
<SmeMessageBubble key={message.messageId} message={message} />
))}
{streamingConversationId === conversationId && streamingText ? (
<SmeMessageBubble
message={
{
messageId: (streamingMessageId ?? "streaming") as SmeMessageId,
conversationId: conversationId as SmeConversationId,
role: "assistant",
text: streamingText,
isStreaming: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as SmeMessage
}
/>
) : null}
{sending && streamingConversationId !== conversationId ? (
<div className="flex items-center gap-4 px-4 py-5">
<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">
<div className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin" />
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">SME Assistant</p>
<div className="flex items-center gap-1.5">
<div className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:0ms]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:150ms]" />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50 [animation-delay:300ms]" />
</div>
<span className="text-xs text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
) : null}
<div ref={messagesEndRef} />
</div>
</div>
);
});
Loading