diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 7d0217a..b648649 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback, type KeyboardEvent, type ClipboardEvent, type DragEvent } from 'react'; +import { useState, useRef, useEffect, useLayoutEffect, useCallback, type KeyboardEvent, type ClipboardEvent, type DragEvent } from 'react'; import { Send, Square, X, Plus, Trash2, Sparkles, HelpCircle, StickyNote, Paperclip, FileText, Loader2 } from 'lucide-react'; import { useChatStore } from '../../stores/chatStore'; import type { QuoteAction, QuoteEntry } from '../../stores/chatStore'; @@ -55,6 +55,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const removeQuote = useChatStore(s => s.removeQuote); const updateQuoteInstruction = useChatStore(s => s.updateQuoteInstruction); const clearQuotes = useChatStore(s => s.clearQuotes); + const setDraft = useChatStore(s => s.setDraft); const activeSession = useChatStore(s => s.activeSession); const isNewChat = useChatStore(s => s.messages.length === 0); @@ -92,6 +93,23 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { cancelRewrite(false); }, [activeSession, cancelRewrite]); + // Load this chat's saved draft when switching sessions — an empty box for a + // chat with no draft, the unfinished text for one that has it. Reads via + // getState so a draft mutation (the keystrokes below) doesn't reload mid-edit. + // Focus the composer on every switch so you can start typing right away. + useEffect(() => { + setInput(useChatStore.getState().drafts[activeSession] ?? ''); + if (activeSession) setTimeout(() => textareaRef.current?.focus(), 0); + }, [activeSession]); + + // Keep the textarea height in sync with its content (typing + draft load). + useLayoutEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; + }, [input]); + // Esc anywhere dismisses the preview (cancels an in-flight rewrite). useEffect(() => { if (!rewriteActive) return; @@ -201,6 +219,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { onSend(message, fileIds.length > 0 ? fileIds : undefined, imageBlocks.length > 0 ? imageBlocks : undefined); setInput(''); + setDraft(activeSession, ''); clearQuotes(); // Clean up previews attachments.forEach(a => { if (a.preview) URL.revokeObjectURL(a.preview); }); @@ -208,7 +227,6 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { rewriteAbortRef.current?.abort(); rewriteAbortRef.current = null; setRewrite({ status: 'idle' }); - if (textareaRef.current) textareaRef.current.style.height = 'auto'; }; /** Request a rewrite and open the preview card. Sends nothing by itself. */ @@ -318,14 +336,6 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { } }; - const handleInput = () => { - const el = textareaRef.current; - if (el) { - el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 200) + 'px'; - } - }; - return (
{ setInput(e.target.value); handleInput(); }} + onChange={(e) => { setInput(e.target.value); setDraft(activeSession, e.target.value); }} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={ diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index cc0c49c..f3af36e 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -45,7 +45,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, const debounceRef = useRef | null>(null); const inputRef = useRef(null); - const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar } = useChatStore(); + const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, virtualSession, discardVirtualSession } = useChatStore(); const isSearching = localQuery.trim().length > 0; @@ -198,6 +198,34 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
) : ( <> + {/* Virtual "new chat" — pinned at the very top until the first + message materializes it server-side. */} + {virtualSession && ( +
onSelect(virtualSession.id)} + className={`group flex items-center gap-2 px-3 py-1.5 mx-1 mt-1 rounded-md cursor-pointer text-sm transition-colors + ${virtualSession.id === activeSession + ? 'bg-accent/10 text-text' + : 'text-text-muted hover:bg-surface-raised hover:text-text-secondary' + }`} + > + +
+
New chat
+
+ {virtualSession.id === activeSession && activeIsRunning && ( + + )} + +
+ )} + {/* Pinned running sessions */} {pinnedRunning.length > 0 && (
@@ -241,7 +269,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect, )} {/* Normal date-grouped view */} - {groupedConversations.length === 0 && pinnedRunning.length === 0 && pinnedStarred.length === 0 && ( + {groupedConversations.length === 0 && pinnedRunning.length === 0 && pinnedStarred.length === 0 && !virtualSession && (
No conversations yet
)} @@ -385,6 +413,8 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam const [renameValue, setRenameValue] = useState(''); const menuRef = useRef(null); const inputRef = useRef(null); + // Unsent draft for this chat (hidden on the active one — its text is in the box). + const hasDraft = useChatStore(s => !!(s.drafts[session.id] || '').trim()); // Close menu on outside click useEffect(() => { @@ -447,6 +477,13 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
{cleanTitle(session)}
+ {/* Unsent draft marker */} + {hasDraft && !isActive && ( + + + + )} + {/* Status indicator (always visible) */} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 43eff42..47687df 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -30,7 +30,7 @@ function formatModelLabel(model: string): string { export function ChatPage() { const { sessionId } = useParams(); const { - sessions, activeSession, messages, + sessions, activeSession, virtualSession, messages, streamingBlocks, isStreaming, loading, agentStatus, contextUsage, currentTodos, currentCCTasks, sidebarCollapsed, panels, @@ -113,7 +113,9 @@ export function ChatPage() { {sidebarCollapsed ? : } - {sessions.find(s => s.id === activeSession)?.title || activeSession} + {virtualSession?.id === activeSession + ? 'New chat' + : (sessions.find(s => s.id === activeSession)?.title || activeSession)} {(() => { const model = sessions.find(s => s.id === activeSession)?.model; diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index ca904f5..0a9dfd1 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -53,9 +53,28 @@ const QUOTE_DEFAULTS: Record = { let _quoteId = 0; +// WS event types that mutate the *active* chat view — stream tokens, panels, +// interaction prompts, file changes. They're dropped when their session_id +// doesn't match the active session: a reconnect binds the socket to the +// channel's last real session (server.py get_last_session), which — while a +// not-yet-sent "new chat" is on screen — differs from it, and the replayed +// buffer would otherwise hijack the view with a phantom "Thinking…" and a +// disabled composer. Sidebar/list events (session_running, session_updated, …) +// stay unguarded so background sessions keep updating their row. +const VIEW_SCOPED_EVENTS = new Set([ + 'thinking', 'token', 'tool_use', 'tool_result', 'done', 'stopped', 'error', + 'wakeup', 'auto_turn', 'session_status', 'plan_update', 'subagent_start', + 'subagent_complete', 'hoa_progress', 'interaction', 'file_changed', +]); + interface ChatState { sessions: Session[]; activeSession: string; + // Not-yet-persisted "new chat" from the + button. Materializes in the API + // on the first sent message; rendered pinned at the top of the sidebar. + virtualSession: Session | null; + // Per-session unsent input text, keyed by session id (incl. the virtual one). + drafts: Record; messages: ChatMessage[]; // Streaming state — blocks built incrementally streamingBlocks: MessageBlock[]; @@ -112,7 +131,9 @@ interface ChatState { loadSessions: () => Promise; switchSession: (id: string) => Promise; - createSession: (title?: string) => Promise; + createSession: () => Promise; + discardVirtualSession: () => void; + setDraft: (sessionId: string, text: string) => void; deleteSession: (id: string) => Promise; renameSession: (id: string, title: string) => Promise; toggleStar: (id: string) => Promise; @@ -145,6 +166,8 @@ interface ChatState { export const useChatStore = create((set, get) => ({ sessions: [], activeSession: '', + virtualSession: null, + drafts: {}, messages: [], streamingBlocks: [], isStreaming: false, @@ -330,6 +353,17 @@ export const useChatStore = create((set, get) => ({ }, switchSession: async (id: string) => { + // Leaving an untouched (empty-draft) virtual chat discards it, so the + // sidebar never accumulates empty "New chat" entries. + const vs = get().virtualSession; + if (vs && get().activeSession === vs.id && id !== vs.id + && !(get().drafts[vs.id] || '').trim()) { + set((s) => { + const drafts = { ...s.drafts }; + delete drafts[vs.id]; + return { virtualSession: null, drafts }; + }); + } if (id === get().activeSession && get().messages.length > 0) return; // Clear all auto-close timers clearAllAutoCloseTimers(); @@ -340,6 +374,14 @@ export const useChatStore = create((set, get) => ({ panels: [], activePanelId: null, panelVisible: false, modifiedFiles: [], modifiedFilesCount: 0, backgroundTasks: [], }); + // A virtual chat isn't known to the server (it's created on first send), + // so don't announce a switch to it — that would raise "Session not found" + // and drop the socket. The active-session event guard isolates the view + // from the previously-bound session, and there's nothing to fetch. + if (id === get().virtualSession?.id) { + set({ loading: false }); + return; + } ws.switchSession(id); try { const data = await api.getMessages(id); @@ -376,16 +418,46 @@ export const useChatStore = create((set, get) => ({ } }, - createSession: async (title?: string) => { - try { - const session = await api.createSession(title); - await get().loadSessions(); - await get().switchSession(session.id); - } catch (e) { - console.error('Failed to create session:', e); + createSession: async () => { + // The + button no longer hits the API: it mints a local "virtual" chat + // that's created server-side (POST) only on its first message, then adopts + // the server id. Reuse an existing unsent one rather than stacking empty + // chats. The temp id is a full UUID so it never collides with a real + // server id (uuid4()[:8]) and is never sent to the backend. + const existing = get().virtualSession; + if (existing) { + if (get().activeSession !== existing.id) await get().switchSession(existing.id); + return; + } + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const virtual: Session = { + id, title: '', source: 'web', status: 'created', + updated_at: now, is_running: false, + }; + set({ virtualSession: virtual }); + await get().switchSession(id); + }, + + discardVirtualSession: () => { + const vs = get().virtualSession; + if (!vs) return; + set((s) => { + const drafts = { ...s.drafts }; + delete drafts[vs.id]; + return { virtualSession: null, drafts }; + }); + // If it was the active chat, fall back to the most recent real session. + if (get().activeSession === vs.id) { + const remaining = get().sessions; + if (remaining.length > 0) get().switchSession(remaining[0].id); + else set({ activeSession: '', messages: [] }); } }, + setDraft: (sessionId: string, text: string) => + set((s) => ({ drafts: { ...s.drafts, [sessionId]: text } })), + deleteSession: async (id: string) => { try { await api.deleteSession(id); @@ -455,8 +527,8 @@ export const useChatStore = create((set, get) => ({ set({ searchQuery: '', searchResults: null, searchLoading: false }); }, - sendMessage: (content: string, fileIds?: string[], imageBlocks?: Array<{ url: string; filename: string; media_type: string }>) => { - const session = get().activeSession; + sendMessage: async (content: string, fileIds?: string[], imageBlocks?: Array<{ url: string; filename: string; media_type: string }>) => { + let session = get().activeSession; const blocks: import('../types/chat').MessageBlock[] = []; if (content) blocks.push({ type: 'text', content }); if (imageBlocks) { @@ -464,15 +536,53 @@ export const useChatStore = create((set, get) => ({ blocks.push({ type: 'image', url: img.url, filename: img.filename, media_type: img.media_type }); } } + const vs = get().virtualSession; // Optimistic update: append the user message, flip to streaming. If the // socket isn't open, send() returns 'queued' (will flush on reconnect) // or 'dropped' (revert below). set((state) => ({ - messages: [...state.messages, { role: 'user', blocks }], + messages: [...state.messages, { role: 'user' as const, blocks }], streamingBlocks: [], isStreaming: true, - agentStatus: { state: 'thinking' }, + agentStatus: { state: 'thinking' as const }, })); + // First message in a virtual "new chat": create it in the API now + // (deferred from the + click) and adopt the server-minted id for this turn, + // so it becomes a real, selectable session that survives switching away. + if (vs && vs.id === session) { + try { + const real: Session = await api.createSession(); + session = real.id; + set((state) => { + const drafts = { ...state.drafts }; + delete drafts[vs.id]; + return { + // Don't yank the view if the user navigated away during the POST. + ...(state.activeSession === vs.id ? { activeSession: real.id } : {}), + virtualSession: null, + drafts, + // POST /api/sessions returns a partial row (no updated_at); fill + // the fields the sidebar needs so date-grouping doesn't choke. + sessions: [ + { ...real, title: 'New chat', is_running: true, updated_at: new Date().toISOString() }, + ...state.sessions, + ], + }; + }); + } catch (e) { + console.error('Failed to create session:', e); + set((state) => ({ + messages: [ + ...state.messages.slice(0, -1), + { role: 'assistant' as const, blocks: [{ type: 'text', content: 'Error: could not start the chat. Please retry.' }] }, + ], + streamingBlocks: [], + isStreaming: false, + agentStatus: { state: 'idle' }, + })); + return; + } + } const status = ws.sendMessage(content, session, fileIds); if (status === 'dropped') { // The message could not reach the server. Revert the optimistic @@ -505,6 +615,8 @@ export const useChatStore = create((set, get) => ({ // ------------------------------------------------------------------ // handleWSMessage: (msg: WSMessage) => { + const sid = (msg as { session_id?: string }).session_id; + if (sid && sid !== get().activeSession && VIEW_SCOPED_EVENTS.has(msg.type)) return; switch (msg.type) { // Streaming case 'thinking': return handleThinking(msg, get, set); diff --git a/web/src/utils/dateGroups.ts b/web/src/utils/dateGroups.ts index 16db585..1e7100e 100644 --- a/web/src/utils/dateGroups.ts +++ b/web/src/utils/dateGroups.ts @@ -10,6 +10,7 @@ * naturally produces the correct top-to-bottom group sequence. */ export function getDateGroup(updatedAt: string): string { + if (!updatedAt) return 'Recent'; const now = new Date(); const date = new Date(updatedAt.includes('T') ? updatedAt : updatedAt.replace(' ', 'T') + 'Z');