)}
@@ -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');