feat(web): implement SSE chat streaming with real-time token rendering (#516)
Some checks failed
ci/woodpecker/push/web Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #516.
This commit is contained in:
2026-02-26 02:39:43 +00:00
committed by jason.woltje
parent 6290fc3d53
commit 7de0e734b0
8 changed files with 797 additions and 297 deletions

View File

@@ -59,14 +59,15 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const { user, isLoading: authLoading } = useAuth();
// Use the chat hook for state management
const {
messages,
isLoading: isChatLoading,
isStreaming,
error,
conversationId,
conversationTitle,
sendMessage,
abortStream,
loadConversation,
startNewConversation,
clearError,
@@ -75,15 +76,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }),
});
// Connect to WebSocket for real-time updates (when we have a user)
const { isConnected: isWsConnected } = useWebSocket(
user?.id ?? "", // Use user ID as workspace ID for now
"", // Token not needed since we use cookies
{
// Future: Add handlers for chat-related events
// onChatMessage: (msg) => { ... }
}
);
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -91,7 +84,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Expose methods to parent via ref
// Identify the streaming message (last assistant message while streaming)
const streamingMessageId =
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
useImperativeHandle(ref, () => ({
loadConversation: async (cId: string): Promise<void> => {
await loadConversation(cId);
@@ -110,7 +106,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
scrollToBottom();
}, [messages, scrollToBottom]);
// Notify parent of conversation changes
useEffect(() => {
if (conversationId && conversationTitle) {
onConversationChange?.(conversationId, {
@@ -125,7 +120,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
}
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
// Global keyboard shortcut: Ctrl+/ to focus input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
@@ -139,20 +133,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
};
}, []);
// Show loading quips
// Show loading quips only during non-streaming load (initial fetch wait)
useEffect(() => {
if (isChatLoading) {
// Show first quip after 3 seconds
if (isChatLoading && !isStreaming) {
quipTimerRef.current = setTimeout(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 3000);
// Change quip every 5 seconds
quipIntervalRef.current = setInterval(() => {
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
}, 5000);
} else {
// Clear timers when loading stops
if (quipTimerRef.current) {
clearTimeout(quipTimerRef.current);
quipTimerRef.current = null;
@@ -168,7 +159,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
};
}, [isChatLoading]);
}, [isChatLoading, isStreaming]);
const handleSendMessage = useCallback(
async (content: string) => {
@@ -177,7 +168,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
[sendMessage]
);
// Show loading state while auth is loading
if (authLoading) {
return (
<div
@@ -227,6 +217,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
<MessageList
messages={messages as (Message & { thinking?: string })[]}
isLoading={isChatLoading}
isStreaming={isStreaming}
{...(streamingMessageId != null ? { streamingMessageId } : {})}
loadingQuip={loadingQuip}
/>
<div ref={messagesEndRef} />
@@ -294,6 +286,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
onSend={handleSendMessage}
disabled={isChatLoading || !user}
inputRef={inputRef}
isStreaming={isStreaming}
onStopStreaming={abortStream}
/>
</div>
</div>