From b4d2f7ea76980e7046065dfb2a17814f24798453 Mon Sep 17 00:00:00 2001 From: pufit Date: Sat, 27 Jun 2026 00:10:28 -0400 Subject: [PATCH] Fix file-upload 404 on a new chat by materializing the session first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "+" button mints a client-only "virtual" session that isn't persisted in the DB until the first message is sent. Attaching a file to such a chat uploaded against that temp id, so POST /api/files/upload hit the handler's `if not session: 404 "Session not found"` guard — a 404 that looked like a missing route but was application-level. Extract a shared `ensureRealSession()` store action that materializes the virtual session (POST /api/sessions, adopt the server id, carry the draft across) and call it before uploading, so the upload targets a real session row. sendMessage now reuses the same action instead of its own inline materialization. --- web/src/components/Chat/ChatInput.tsx | 10 ++++- web/src/stores/chatStore.ts | 61 ++++++++++++++++++--------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 56c6455..eabc7c1 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -58,6 +58,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const clearQuotes = useChatStore(s => s.clearQuotes); const setDraft = useChatStore(s => s.setDraft); const activeSession = useChatStore(s => s.activeSession); + const ensureRealSession = useChatStore(s => s.ensureRealSession); const isNewChat = useChatStore(s => s.messages.length === 0); // ── Model picker ── @@ -169,7 +170,12 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { // Upload all files try { - const result = await api.uploadFiles(files, activeSession); + // A brand-new chat is only a client-side "virtual" session until its + // first message. The upload endpoint requires a persisted session, so + // materialize it first and upload against the real server id — otherwise + // the temp id 404s ("Session not found"). + const sid = await ensureRealSession(); + const result = await api.uploadFiles(files, sid); setAttachments(prev => prev.map(a => { const idx = newAttachments.findIndex(n => n.id === a.id); if (idx >= 0 && result.files[idx]) { @@ -191,7 +197,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { return a; })); } - }, [activeSession]); + }, [ensureRealSession]); const removeAttachment = useCallback((id: string) => { setAttachments(prev => { diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 27e26bb..17df747 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -141,6 +141,13 @@ interface ChatState { loadSessions: () => Promise; switchSession: (id: string) => Promise; createSession: () => Promise; + /** + * Materialize the virtual "new chat" in the API and adopt the server-minted + * id, returning it. No-op (returns the active id unchanged) once the chat is + * already a real, persisted session. Pass `running: true` when a message is + * being sent at the same time so the sidebar row shows the spinner instantly. + */ + ensureRealSession: (running?: boolean) => Promise; discardVirtualSession: () => void; setDraft: (sessionId: string, text: string) => void; deleteSession: (id: string) => Promise; @@ -458,6 +465,39 @@ export const useChatStore = create((set, get) => ({ await get().switchSession(id); }, + ensureRealSession: async (running = false) => { + const session = get().activeSession; + const vs = get().virtualSession; + // Already a real, persisted session (or no virtual chat) — nothing to do. + if (!vs || vs.id !== session) return session; + // Create it server-side (deferred from the + click) and adopt the + // server-minted id, so anything needing a persisted session — the first + // message OR a file upload before it — targets a real row, not the + // client-only temp id (which the backend has never seen → 404). + const real: Session = await api.createSession(); + set((state) => { + const drafts = { ...state.drafts }; + // Carry any unsent draft text across to the real id so the composer, + // which reloads from drafts[activeSession] on id change, doesn't blank. + const carried = drafts[vs.id]; + delete drafts[vs.id]; + if (carried !== undefined) drafts[real.id] = carried; + 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: running, updated_at: new Date().toISOString() }, + ...state.sessions, + ], + }; + }); + return real.id; + }, + discardVirtualSession: () => { const vs = get().virtualSession; if (!vs) return; @@ -592,29 +632,12 @@ export const useChatStore = create((set, get) => ({ isStreaming: true, agentStatus: { state: 'thinking' as const }, })); - // First message in a virtual "new chat": create it in the API now + // First message in a virtual "new chat": materialize 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, - ], - }; - }); + session = await get().ensureRealSession(true); } catch (e) { console.error('Failed to create session:', e); set((state) => ({