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
32 changes: 21 additions & 11 deletions web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -201,14 +219,14 @@ 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); });
setAttachments([]);
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. */
Expand Down Expand Up @@ -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 (
<div
className="border-t border-border-subtle bg-bg shrink-0 relative"
Expand Down Expand Up @@ -435,7 +445,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); handleInput(); }}
onChange={(e) => { setInput(e.target.value); setDraft(activeSession, e.target.value); }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
Expand Down
41 changes: 39 additions & 2 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(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;

Expand Down Expand Up @@ -198,6 +198,34 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onSelect,
</div>
) : (
<>
{/* Virtual "new chat" — pinned at the very top until the first
message materializes it server-side. */}
{virtualSession && (
<div
onClick={() => 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'
}`}
>
<MessageSquare size={13} className="shrink-0 opacity-50" />
<div className="flex-1 min-w-0">
<div className="truncate text-[13px] italic">New chat</div>
</div>
{virtualSession.id === activeSession && activeIsRunning && (
<Loader2 size={12} className="shrink-0 text-accent animate-spin" />
)}
<button
onClick={(e) => { e.stopPropagation(); discardVirtualSession(); }}
className="p-0.5 text-text-faint hover:text-text-muted opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer shrink-0"
title="Discard new chat"
>
<X size={13} />
</button>
</div>
)}

{/* Pinned running sessions */}
{pinnedRunning.length > 0 && (
<div>
Expand Down Expand Up @@ -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 && (
<div className="px-3 py-2 text-[11px] text-text-faint">No conversations yet</div>
)}

Expand Down Expand Up @@ -385,6 +413,8 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
const [renameValue, setRenameValue] = useState('');
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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(() => {
Expand Down Expand Up @@ -447,6 +477,13 @@ function SessionItem({ session, isActive, isRunning, onSelect, onDelete, onRenam
<div className="truncate text-[13px]">{cleanTitle(session)}</div>
</div>

{/* Unsent draft marker */}
{hasDraft && !isActive && (
<span title="Unsent draft" className="shrink-0 flex items-center">
<Pencil size={11} className="text-text-faint" />
</span>
)}

{/* Status indicator (always visible) */}
<StatusIndicator session={session} isActive={isActive} isRunning={isRunning} />

Expand Down
6 changes: 4 additions & 2 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -113,7 +113,9 @@ export function ChatPage() {
{sidebarCollapsed ? <PanelLeftOpen size={15} /> : <PanelLeftClose size={15} />}
</button>
<span className="font-medium text-[15px]">
{sessions.find(s => s.id === activeSession)?.title || activeSession}
{virtualSession?.id === activeSession
? 'New chat'
: (sessions.find(s => s.id === activeSession)?.title || activeSession)}
</span>
{(() => {
const model = sessions.find(s => s.id === activeSession)?.model;
Expand Down
136 changes: 124 additions & 12 deletions web/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,28 @@ const QUOTE_DEFAULTS: Record<QuoteAction, string> = {

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<WSMessage['type']>([
'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<string, string>;
messages: ChatMessage[];
// Streaming state — blocks built incrementally
streamingBlocks: MessageBlock[];
Expand Down Expand Up @@ -112,7 +131,9 @@ interface ChatState {

loadSessions: () => Promise<void>;
switchSession: (id: string) => Promise<void>;
createSession: (title?: string) => Promise<void>;
createSession: () => Promise<void>;
discardVirtualSession: () => void;
setDraft: (sessionId: string, text: string) => void;
deleteSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
toggleStar: (id: string) => Promise<void>;
Expand Down Expand Up @@ -145,6 +166,8 @@ interface ChatState {
export const useChatStore = create<ChatState>((set, get) => ({
sessions: [],
activeSession: '',
virtualSession: null,
drafts: {},
messages: [],
streamingBlocks: [],
isStreaming: false,
Expand Down Expand Up @@ -330,6 +353,17 @@ export const useChatStore = create<ChatState>((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();
Expand All @@ -340,6 +374,14 @@ export const useChatStore = create<ChatState>((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);
Expand Down Expand Up @@ -376,16 +418,46 @@ export const useChatStore = create<ChatState>((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);
Expand Down Expand Up @@ -455,24 +527,62 @@ export const useChatStore = create<ChatState>((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) {
for (const img of imageBlocks) {
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
Expand Down Expand Up @@ -505,6 +615,8 @@ export const useChatStore = create<ChatState>((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);
Expand Down
1 change: 1 addition & 0 deletions web/src/utils/dateGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down