Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down Expand Up @@ -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]) {
Expand All @@ -191,7 +197,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
return a;
}));
}
}, [activeSession]);
}, [ensureRealSession]);

const removeAttachment = useCallback((id: string) => {
setAttachments(prev => {
Expand Down
61 changes: 42 additions & 19 deletions web/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ interface ChatState {
loadSessions: () => Promise<void>;
switchSession: (id: string) => Promise<void>;
createSession: () => Promise<void>;
/**
* 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<string>;
discardVirtualSession: () => void;
setDraft: (sessionId: string, text: string) => void;
deleteSession: (id: string) => Promise<void>;
Expand Down Expand Up @@ -458,6 +465,39 @@ export const useChatStore = create<ChatState>((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;
Expand Down Expand Up @@ -592,29 +632,12 @@ export const useChatStore = create<ChatState>((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) => ({
Expand Down
Loading