From f4b4ba4c5449e936fd860536a3fb3523c3be5452 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 25 Feb 2026 20:38:49 -0600 Subject: [PATCH] feat(web): implement SSE chat streaming with real-time token rendering - Implement streamChatMessage() using fetch ReadableStream for SSE parsing - Update useChat hook with streaming state, abort support, and fallback to non-streaming - Add streaming indicator (blinking cursor) in MessageList during token streaming - Update ChatInput with Stop button during streaming, disable input while streaming - Add CSS animations for streaming cursor - Fix message ID uniqueness to prevent collisions in rapid sends - Update tests for streaming path with makeStreamSucceed/makeStreamFail helpers Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/globals.css | 22 ++ apps/web/src/components/chat/Chat.test.tsx | 2 + apps/web/src/components/chat/Chat.tsx | 34 +- apps/web/src/components/chat/ChatInput.tsx | 94 +++-- apps/web/src/components/chat/MessageList.tsx | 136 ++++--- apps/web/src/hooks/useChat.test.ts | 366 ++++++++++++++----- apps/web/src/hooks/useChat.ts | 303 ++++++++++----- apps/web/src/lib/api/chat.ts | 137 ++++++- 8 files changed, 797 insertions(+), 297 deletions(-) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 090d5d2..40c2fd5 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -765,6 +765,28 @@ body::before { animation: scaleIn 0.1s ease-out; } +/* Streaming cursor for real-time token rendering */ +@keyframes streaming-cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.streaming-cursor { + display: inline-block; + width: 2px; + height: 1em; + background-color: rgb(var(--accent-primary)); + border-radius: 1px; + animation: streaming-cursor-blink 1s step-end infinite; + vertical-align: text-bottom; + margin-left: 1px; +} + /* ----------------------------------------------------------------------------- Dashboard Layout — Responsive Grids ----------------------------------------------------------------------------- */ diff --git a/apps/web/src/components/chat/Chat.test.tsx b/apps/web/src/components/chat/Chat.test.tsx index 8004250..958888a 100644 --- a/apps/web/src/components/chat/Chat.test.tsx +++ b/apps/web/src/components/chat/Chat.test.tsx @@ -64,10 +64,12 @@ function createMockUseChatReturn( }, ], isLoading: false, + isStreaming: false, error: null, conversationId: null, conversationTitle: null, sendMessage: vi.fn().mockResolvedValue(undefined), + abortStream: vi.fn(), loadConversation: vi.fn().mockResolvedValue(undefined), startNewConversation: vi.fn(), setMessages: vi.fn(), diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index 1cf0c6e..a8edcbf 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -59,14 +59,15 @@ export const Chat = forwardRef(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(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(null); const inputRef = useRef(null); @@ -91,7 +84,10 @@ export const Chat = forwardRef(function Chat( const quipTimerRef = useRef(null); const quipIntervalRef = useRef(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 => { await loadConversation(cId); @@ -110,7 +106,6 @@ export const Chat = forwardRef(function Chat( scrollToBottom(); }, [messages, scrollToBottom]); - // Notify parent of conversation changes useEffect(() => { if (conversationId && conversationTitle) { onConversationChange?.(conversationId, { @@ -125,7 +120,6 @@ export const Chat = forwardRef(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(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(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(function Chat( [sendMessage] ); - // Show loading state while auth is loading if (authLoading) { return (
(function Chat(
@@ -294,6 +286,8 @@ export const Chat = forwardRef(function Chat( onSend={handleSendMessage} disabled={isChatLoading || !user} inputRef={inputRef} + isStreaming={isStreaming} + onStopStreaming={abortStream} />
diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx index 87cc91b..721f6b5 100644 --- a/apps/web/src/components/chat/ChatInput.tsx +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -7,13 +7,20 @@ interface ChatInputProps { onSend: (message: string) => void; disabled?: boolean; inputRef?: RefObject; + isStreaming?: boolean; + onStopStreaming?: () => void; } -export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React.JSX.Element { +export function ChatInput({ + onSend, + disabled, + inputRef, + isStreaming = false, + onStopStreaming, +}: ChatInputProps): React.JSX.Element { const [message, setMessage] = useState(""); const [version, setVersion] = useState(null); - // Fetch version from static version.json (generated at build time) useEffect(() => { interface VersionData { version?: string; @@ -24,7 +31,6 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React .then((res) => res.json() as Promise) .then((data) => { if (data.version) { - // Format as "version+commit" for full build identification const fullVersion = data.commit ? `${data.version}+${data.commit}` : data.version; setVersion(fullVersion); } @@ -35,20 +41,22 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React }, []); const handleSubmit = useCallback(() => { - if (message.trim() && !disabled) { + if (message.trim() && !disabled && !isStreaming) { onSend(message); setMessage(""); } - }, [message, onSend, disabled]); + }, [message, onSend, disabled, isStreaming]); + + const handleStop = useCallback(() => { + onStopStreaming?.(); + }, [onStopStreaming]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { - // Enter to send (without Shift) if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } - // Ctrl/Cmd + Enter to send (alternative) if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSubmit(); @@ -61,6 +69,7 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React const maxCharacters = 4000; const isNearLimit = characterCount > maxCharacters * 0.9; const isOverLimit = characterCount > maxCharacters; + const isInputDisabled = disabled ?? false; return (
@@ -69,7 +78,10 @@ export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps): React className="relative rounded-lg border transition-all duration-150" style={{ backgroundColor: "rgb(var(--surface-0))", - borderColor: disabled ? "rgb(var(--border-default))" : "rgb(var(--border-strong))", + borderColor: + isInputDisabled || isStreaming + ? "rgb(var(--border-default))" + : "rgb(var(--border-strong))", }} >