From 347ad53f5a06b7943ed70f3a57677bfbd0613b80 Mon Sep 17 00:00:00 2001 From: Pervakov Grigorii Date: Mon, 22 Jun 2026 17:26:57 +0200 Subject: [PATCH] web/chat: lazy "new chat" creation + per-chat draft persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button no longer POSTs a session up front. It mints a local "virtual" chat (temp UUID, never sent to the backend) and defers the POST /api/sessions to the first message, then adopts the server-minted id for the turn — so a session is created only when you actually send, and it becomes a real, selectable chat that survives switching away. Unsent input is stored per session id, so switching chats shows an empty composer and returning restores the unfinished message (fixing the single shared textarea that leaked text across sessions). A pencil glyph marks non-active chats that hold a draft, the virtual chat pins to the top of the sidebar, and switching to any chat focuses the composer. Also scope view-mutating WS events (stream/panels/interaction) to the active session, so a reconnect that rebinds the socket to another session can't hijack the current view with a phantom "Thinking..." badge or a disabled composer. POST /api/sessions returns a partial row (no updated_at), so the promoted session fills the fields the sidebar needs locally, and getDateGroup tolerates a missing date instead of throwing. Frontend-only; no API or schema changes. --- web/src/components/Chat/ChatInput.tsx | 32 +++-- web/src/components/Chat/SessionSidebar.tsx | 41 ++++++- web/src/pages/ChatPage.tsx | 6 +- web/src/stores/chatStore.ts | 136 +++++++++++++++++++-- web/src/utils/dateGroups.ts | 1 + 5 files changed, 189 insertions(+), 27 deletions(-) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 7d0217a1..b6486493 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 cc0c49c2..f3af36e4 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 43eff42f..47687df0 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 ca904f5b..0a9dfd11 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 16db585e..1e7100ea 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');