From 68e056ac91eaa9086fef2d5ce98acb79647c3ed6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 19 Mar 2026 20:42:48 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20port=20chat=20UI=20=E2=80=94=20mod?= =?UTF-8?q?el=20selector,=20keybindings,=20thinking=20display,=20styled=20?= =?UTF-8?q?header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 1 + apps/web/src/app/(dashboard)/chat/page.tsx | 364 +++++++--- apps/web/src/components/chat/chat-input.tsx | 202 +++++- .../src/components/chat/conversation-list.tsx | 246 ++++--- .../src/components/chat/message-bubble.tsx | 258 ++++++- .../src/components/chat/streaming-message.tsx | 91 ++- apps/web/src/components/layout/app-header.tsx | 239 +++++++ apps/web/src/components/layout/topbar.tsx | 7 +- apps/web/src/lib/types.ts | 31 + docs/scratchpads/BUG-CLI-scratchpad.md | 7 + pnpm-lock.yaml | 662 ++++++++++++++++++ 11 files changed, 1848 insertions(+), 260 deletions(-) create mode 100644 apps/web/src/components/layout/app-header.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 8e875b4..91ac9a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "socket.io-client": "^4.8.0", "tailwind-merge": "^3.5.0" }, diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx index 68bea36..dec3e62 100644 --- a/apps/web/src/app/(dashboard)/chat/page.tsx +++ b/apps/web/src/app/(dashboard)/chat/page.tsx @@ -1,93 +1,172 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@/lib/api'; import { destroySocket, getSocket } from '@/lib/socket'; -import type { Conversation, Message } from '@/lib/types'; +import type { Conversation, Message, ModelInfo, ProviderInfo } from '@/lib/types'; import { ConversationList } from '@/components/chat/conversation-list'; import { MessageBubble } from '@/components/chat/message-bubble'; import { ChatInput } from '@/components/chat/chat-input'; import { StreamingMessage } from '@/components/chat/streaming-message'; +import { AppHeader } from '@/components/layout/app-header'; + +const FALLBACK_MODELS: ModelInfo[] = [ + { + id: 'claude-3-5-sonnet', + provider: 'anthropic', + name: 'claude-3.5-sonnet', + reasoning: true, + contextWindow: 200_000, + maxTokens: 8_192, + inputTypes: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: 'gpt-4.1', + provider: 'openai', + name: 'gpt-4.1', + reasoning: false, + contextWindow: 128_000, + maxTokens: 8_192, + inputTypes: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: 'gemini-2.0-flash', + provider: 'google', + name: 'gemini-2.0-flash', + reasoning: false, + contextWindow: 1_000_000, + maxTokens: 8_192, + inputTypes: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, +]; export default function ChatPage(): React.ReactElement { const [conversations, setConversations] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [models, setModels] = useState(FALLBACK_MODELS); + const [selectedModelId, setSelectedModelId] = useState(FALLBACK_MODELS[0]?.id ?? ''); const [streamingText, setStreamingText] = useState(''); + const [streamingThinking, setStreamingThinking] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const messagesEndRef = useRef(null); - - // Track the active conversation ID in a ref so socket event handlers always - // see the current value without needing to be re-registered. const activeIdRef = useRef(null); + const streamingTextRef = useRef(''); + const streamingThinkingRef = useRef(''); + activeIdRef.current = activeId; - // Accumulate streamed text in a ref so agent:end can read the full content - // without stale-closure issues. - const streamingTextRef = useRef(''); + const selectedModel = useMemo( + () => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null, + [models, selectedModelId], + ); + const selectedModelRef = useRef(selectedModel); + selectedModelRef.current = selectedModel; - // Load conversations on mount useEffect(() => { api('/api/conversations') .then(setConversations) .catch(() => {}); }, []); - // Load messages when active conversation changes + useEffect(() => { + api('/api/providers') + .then((providers) => + providers.filter((provider) => provider.available).flatMap((provider) => provider.models), + ) + .then((availableModels) => { + if (availableModels.length === 0) return; + setModels(availableModels); + setSelectedModelId((current) => + availableModels.some((model) => model.id === current) ? current : availableModels[0]!.id, + ); + }) + .catch(() => { + setModels(FALLBACK_MODELS); + }); + }, []); + useEffect(() => { if (!activeId) { setMessages([]); return; } - // Clear streaming state when switching conversations + setIsStreaming(false); setStreamingText(''); + setStreamingThinking(''); streamingTextRef.current = ''; + streamingThinkingRef.current = ''; + api(`/api/conversations/${activeId}/messages`) - .then(setMessages) + .then((fetchedMessages) => setMessages(fetchedMessages.map(normalizeMessage))) .catch(() => {}); }, [activeId]); - // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, streamingText]); + }, [messages, streamingText, streamingThinking]); - // Socket.io setup — connect once for the page lifetime useEffect(() => { const socket = getSocket(); function onAgentStart(data: { conversationId: string }): void { - // Only update state if the event belongs to the currently viewed conversation if (activeIdRef.current !== data.conversationId) return; setIsStreaming(true); setStreamingText(''); + setStreamingThinking(''); streamingTextRef.current = ''; + streamingThinkingRef.current = ''; } - function onAgentText(data: { conversationId: string; text: string }): void { + function onAgentText(data: { conversationId: string; text?: string; thinking?: string }): void { if (activeIdRef.current !== data.conversationId) return; - streamingTextRef.current += data.text; - setStreamingText((prev) => prev + data.text); + if (data.text) { + streamingTextRef.current += data.text; + setStreamingText((prev) => prev + data.text); + } + if (data.thinking) { + streamingThinkingRef.current += data.thinking; + setStreamingThinking((prev) => prev + data.thinking); + } } - function onAgentEnd(data: { conversationId: string }): void { + function onAgentEnd(data: { + conversationId: string; + thinking?: string; + model?: string; + provider?: string; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }): void { if (activeIdRef.current !== data.conversationId) return; const finalText = streamingTextRef.current; + const finalThinking = data.thinking ?? streamingThinkingRef.current; setIsStreaming(false); setStreamingText(''); + setStreamingThinking(''); streamingTextRef.current = ''; - // Append the completed assistant message to the local message list. - // The Pi agent session is in-memory so the assistant response is not - // persisted to the DB — we build the local UI state instead. + streamingThinkingRef.current = ''; + if (finalText) { setMessages((prev) => [ ...prev, { id: `assistant-${Date.now()}`, conversationId: data.conversationId, - role: 'assistant' as const, + role: 'assistant', content: finalText, + thinking: finalThinking || undefined, + model: data.model ?? selectedModelRef.current?.name, + provider: data.provider ?? selectedModelRef.current?.provider, + promptTokens: data.promptTokens, + completionTokens: data.completionTokens, + totalTokens: data.totalTokens, createdAt: new Date().toISOString(), }, ]); @@ -97,13 +176,15 @@ export default function ChatPage(): React.ReactElement { function onError(data: { error: string; conversationId?: string }): void { setIsStreaming(false); setStreamingText(''); + setStreamingThinking(''); streamingTextRef.current = ''; + streamingThinkingRef.current = ''; setMessages((prev) => [ ...prev, { id: `error-${Date.now()}`, conversationId: data.conversationId ?? '', - role: 'system' as const, + role: 'system', content: `Error: ${data.error}`, createdAt: new Date().toISOString(), }, @@ -115,7 +196,6 @@ export default function ChatPage(): React.ReactElement { socket.on('agent:end', onAgentEnd); socket.on('error', onError); - // Connect if not already connected if (!socket.connected) { socket.connect(); } @@ -125,19 +205,17 @@ export default function ChatPage(): React.ReactElement { socket.off('agent:text', onAgentText); socket.off('agent:end', onAgentEnd); socket.off('error', onError); - // Fully tear down the socket when the chat page unmounts so we get a - // fresh authenticated connection next time the page is visited. destroySocket(); }; }, []); const handleNewConversation = useCallback(async () => { - const conv = await api('/api/conversations', { + const conversation = await api('/api/conversations', { method: 'POST', body: { title: 'New conversation' }, }); - setConversations((prev) => [conv, ...prev]); - setActiveId(conv.id); + setConversations((prev) => [conversation, ...prev]); + setActiveId(conversation.id); setMessages([]); }, []); @@ -146,20 +224,22 @@ export default function ChatPage(): React.ReactElement { method: 'PATCH', body: { title }, }); - setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); + setConversations((prev) => + prev.map((conversation) => (conversation.id === id ? updated : conversation)), + ); }, []); const handleDelete = useCallback( async (id: string) => { try { await api(`/api/conversations/${id}`, { method: 'DELETE' }); - setConversations((prev) => prev.filter((c) => c.id !== id)); + setConversations((prev) => prev.filter((conversation) => conversation.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]); } - } catch (err) { - console.error('[ChatPage] Failed to delete conversation:', err); + } catch (error) { + console.error('[ChatPage] Failed to delete conversation:', error); } }, [activeId], @@ -171,8 +251,9 @@ export default function ChatPage(): React.ReactElement { method: 'PATCH', body: { archived }, }); - setConversations((prev) => prev.map((c) => (c.id === id ? updated : c))); - // If archiving the active conversation, deselect it + setConversations((prev) => + prev.map((conversation) => (conversation.id === id ? updated : conversation)), + ); if (archived && activeId === id) { setActiveId(null); setMessages([]); @@ -182,75 +263,114 @@ export default function ChatPage(): React.ReactElement { ); const handleSend = useCallback( - async (content: string) => { - let convId = activeId; + async (content: string, options?: { modelId?: string }) => { + let conversationId = activeId; - // Auto-create conversation if none selected - if (!convId) { + if (!conversationId) { const autoTitle = content.slice(0, 60); - const conv = await api('/api/conversations', { + const conversation = await api('/api/conversations', { method: 'POST', body: { title: autoTitle }, }); - setConversations((prev) => [conv, ...prev]); - setActiveId(conv.id); - convId = conv.id; + setConversations((prev) => [conversation, ...prev]); + setActiveId(conversation.id); + conversationId = conversation.id; } else { - // Auto-title: if the active conversation still has the default "New - // conversation" title and this is the first message, update the title - // from the message content. - const activeConv = conversations.find((c) => c.id === convId); - if (activeConv?.title === 'New conversation' && messages.length === 0) { + const activeConversation = conversations.find( + (conversation) => conversation.id === conversationId, + ); + if (activeConversation?.title === 'New conversation' && messages.length === 0) { const autoTitle = content.slice(0, 60); - api(`/api/conversations/${convId}`, { + api(`/api/conversations/${conversationId}`, { method: 'PATCH', body: { title: autoTitle }, }) .then((updated) => { - setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c))); + setConversations((prev) => + prev.map((conversation) => + conversation.id === conversationId ? updated : conversation, + ), + ); }) .catch(() => {}); } } - // Optimistic user message in local UI state setMessages((prev) => [ ...prev, { id: `user-${Date.now()}`, - conversationId: convId, - role: 'user' as const, + conversationId, + role: 'user', content, createdAt: new Date().toISOString(), }, ]); - // Persist the user message to the DB so conversation history is - // available when the page is reloaded or a new session starts. - api(`/api/conversations/${convId}/messages`, { + api(`/api/conversations/${conversationId}/messages`, { method: 'POST', body: { role: 'user', content }, - }).catch(() => { - // Non-fatal: the agent can still process the message even if - // REST persistence fails. - }); + }).catch(() => {}); - // Send to WebSocket — gateway creates/resumes the agent session and - // streams the response back via agent:start / agent:text / agent:end. const socket = getSocket(); if (!socket.connected) { socket.connect(); } - socket.emit('message', { conversationId: convId, content }); + socket.emit('message', { + conversationId, + content, + model: options?.modelId ?? selectedModelRef.current?.id, + }); }, - [activeId, conversations, messages], + [activeId, conversations, messages.length], ); + const handleStop = useCallback(() => { + const socket = getSocket(); + socket.emit('cancel', { conversationId: activeIdRef.current }); + + const partialText = streamingTextRef.current.trim(); + const partialThinking = streamingThinkingRef.current.trim(); + + if (partialText) { + setMessages((prev) => [ + ...prev, + { + id: `assistant-partial-${Date.now()}`, + conversationId: activeIdRef.current ?? '', + role: 'assistant', + content: partialText, + thinking: partialThinking || undefined, + model: selectedModelRef.current?.name, + provider: selectedModelRef.current?.provider, + createdAt: new Date().toISOString(), + }, + ]); + } + + setIsStreaming(false); + setStreamingText(''); + setStreamingThinking(''); + streamingTextRef.current = ''; + streamingThinkingRef.current = ''; + destroySocket(); + }, []); + + const handleEditLastMessage = useCallback((): string | null => { + const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user'); + return lastUserMessage?.content ?? null; + }, [messages]); + + const activeConversation = + conversations.find((conversation) => conversation.id === activeId) ?? null; + return ( -
+
setSidebarOpen(false)} onSelect={setActiveId} onNew={handleNewConversation} onRename={handleRename} @@ -258,36 +378,90 @@ export default function ChatPage(): React.ReactElement { onArchive={handleArchive} /> -
- {activeId ? ( - <> -
- {messages.map((msg) => ( - - ))} - {isStreaming && } -
-
- - - ) : ( -
-
-

Welcome to Mosaic Chat

-

- Select a conversation or start a new one -

- -
+
+ setSidebarOpen((prev) => !prev)} + /> + +
+
+ {messages.length === 0 && !isStreaming ? ( +
+
+
+ Mosaic Chat +
+

+ Start a new session with a better chat interface. +

+

+ Pick a model, send a prompt, and the response area will keep reasoning, + metadata, and streaming status visible without leaving the page. +

+
+
+ ) : null} + + {messages.map((message) => ( + + ))} + + {isStreaming ? ( + + ) : null} + +
- )} +
+ +
+
+ +
+
); } + +function normalizeMessage(message: Message): Message { + const metadata = message.metadata ?? {}; + + return { + ...message, + thinking: + message.thinking ?? (typeof metadata.thinking === 'string' ? metadata.thinking : undefined), + model: message.model ?? (typeof metadata.model === 'string' ? metadata.model : undefined), + provider: + message.provider ?? (typeof metadata.provider === 'string' ? metadata.provider : undefined), + promptTokens: + message.promptTokens ?? + (typeof metadata.prompt_tokens === 'number' ? metadata.prompt_tokens : undefined), + completionTokens: + message.completionTokens ?? + (typeof metadata.completion_tokens === 'number' ? metadata.completion_tokens : undefined), + totalTokens: + message.totalTokens ?? + (typeof metadata.total_tokens === 'number' ? metadata.total_tokens : undefined), + }; +} diff --git a/apps/web/src/components/chat/chat-input.tsx b/apps/web/src/components/chat/chat-input.tsx index 2f59663..4223fa4 100644 --- a/apps/web/src/components/chat/chat-input.tsx +++ b/apps/web/src/components/chat/chat-input.tsx @@ -1,52 +1,192 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ModelInfo } from '@/lib/types'; interface ChatInputProps { - onSend: (content: string) => void; - disabled?: boolean; + onSend: (content: string, options?: { modelId?: string }) => void; + onStop?: () => void; + isStreaming?: boolean; + models: ModelInfo[]; + selectedModelId: string; + onModelChange: (modelId: string) => void; + onRequestEditLastMessage?: () => string | null; } -export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement { +const MAX_HEIGHT = 220; + +export function ChatInput({ + onSend, + onStop, + isStreaming = false, + models, + selectedModelId, + onModelChange, + onRequestEditLastMessage, +}: ChatInputProps): React.ReactElement { const [value, setValue] = useState(''); const textareaRef = useRef(null); + const selectedModel = useMemo( + () => models.find((model) => model.id === selectedModelId) ?? models[0], + [models, selectedModelId], + ); - function handleSubmit(e: React.FormEvent): void { - e.preventDefault(); + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`; + }, [value]); + + useEffect(() => { + function handleGlobalFocus(event: KeyboardEvent): void { + if ( + (event.metaKey || event.ctrlKey) && + (event.key === '/' || event.key.toLowerCase() === 'k') + ) { + const target = event.target as HTMLElement | null; + if (target?.closest('input, textarea, [contenteditable="true"]')) return; + event.preventDefault(); + textareaRef.current?.focus(); + } + } + + document.addEventListener('keydown', handleGlobalFocus); + return () => document.removeEventListener('keydown', handleGlobalFocus); + }, []); + + function handleSubmit(event: React.FormEvent): void { + event.preventDefault(); const trimmed = value.trim(); - if (!trimmed || disabled) return; - onSend(trimmed); + if (!trimmed || isStreaming) return; + onSend(trimmed, { modelId: selectedModel?.id }); setValue(''); textareaRef.current?.focus(); } - function handleKeyDown(e: React.KeyboardEvent): void { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); + function handleKeyDown(event: React.KeyboardEvent): void { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + handleSubmit(event); + return; + } + + if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) { + const lastMessage = onRequestEditLastMessage(); + if (lastMessage) { + event.preventDefault(); + setValue(lastMessage); + } } } + const charCount = value.length; + const tokenEstimate = Math.ceil(charCount / 4); + return ( -
-
-