diff --git a/src/main/ipc/dbIpc.ts b/src/main/ipc/dbIpc.ts index 6d8cdb55..320ac013 100644 --- a/src/main/ipc/dbIpc.ts +++ b/src/main/ipc/dbIpc.ts @@ -102,6 +102,19 @@ export function registerDatabaseIpc() { } }); + ipcMain.handle( + 'db:updateConversation', + async (_, conversationId: string, updates: Partial<{ title: string }>) => { + try { + await databaseService.updateConversation(conversationId, updates); + return { success: true }; + } catch (error) { + log.error('Failed to update conversation:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + ipcMain.handle('db:deleteConversation', async (_, conversationId: string) => { try { await databaseService.deleteConversation(conversationId); diff --git a/src/main/preload.ts b/src/main/preload.ts index 4bfa33bb..2644cb95 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -47,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', { rows?: number; autoApprove?: boolean; initialPrompt?: string; + skipResume?: boolean; }) => ipcRenderer.invoke('pty:start', opts), ptyInput: (args: { id: string; data: string }) => ipcRenderer.send('pty:input', args), ptyResize: (args: { id: string; cols: number; rows: number }) => @@ -286,10 +287,12 @@ contextBridge.exposeInMainWorld('electronAPI', { getConversations: (workspaceId: string) => ipcRenderer.invoke('db:getConversations', workspaceId), getOrCreateDefaultConversation: (workspaceId: string) => ipcRenderer.invoke('db:getOrCreateDefaultConversation', workspaceId), - saveMessage: (message: any) => ipcRenderer.invoke('db:saveMessage', message), - getMessages: (conversationId: string) => ipcRenderer.invoke('db:getMessages', conversationId), + updateConversation: (conversationId: string, updates: Partial<{ title: string }>) => + ipcRenderer.invoke('db:updateConversation', conversationId, updates), deleteConversation: (conversationId: string) => ipcRenderer.invoke('db:deleteConversation', conversationId), + saveMessage: (message: any) => ipcRenderer.invoke('db:saveMessage', message), + getMessages: (conversationId: string) => ipcRenderer.invoke('db:getMessages', conversationId), // Debug helpers debugAppendLog: (filePath: string, content: string, options?: { reset?: boolean }) => @@ -639,11 +642,15 @@ export interface ElectronAPI { getOrCreateDefaultConversation: ( workspaceId: string ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + updateConversation: ( + conversationId: string, + updates: Partial<{ title: string }> + ) => Promise<{ success: boolean; error?: string }>; + deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; saveMessage: (message: any) => Promise<{ success: boolean; error?: string }>; getMessages: ( conversationId: string ) => Promise<{ success: boolean; messages?: any[]; error?: string }>; - deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; // Host preview (non-container) hostPreviewStart: (args: { diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index bcb1eb70..0e3ba3fe 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -313,7 +313,7 @@ export class DatabaseService { .select() .from(conversationsTable) .where(eq(conversationsTable.workspaceId, workspaceId)) - .orderBy(desc(conversationsTable.updatedAt)); + .orderBy(asc(conversationsTable.createdAt)); return rows.map((row) => this.mapDrizzleConversationRow(row)); } @@ -322,7 +322,7 @@ export class DatabaseService { return { id: `conv-${workspaceId}-default`, workspaceId, - title: 'Default Conversation', + title: 'Main Chat', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -344,7 +344,7 @@ export class DatabaseService { await this.saveConversation({ id: conversationId, workspaceId, - title: 'Default Conversation', + title: 'Main Chat', }); const [createdRow] = await db @@ -360,7 +360,7 @@ export class DatabaseService { return { id: conversationId, workspaceId, - title: 'Default Conversation', + title: 'Main Chat', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -409,6 +409,21 @@ export class DatabaseService { return rows.map((row) => this.mapDrizzleMessageRow(row)); } + async updateConversation( + conversationId: string, + updates: Partial> + ): Promise { + if (this.disabled) return; + const { db } = await getDrizzleClient(); + await db + .update(conversationsTable) + .set({ + ...updates, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(conversationsTable.id, conversationId)); + } + async deleteConversation(conversationId: string): Promise { if (this.disabled) return; const { db } = await getDrizzleClient(); diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index e9ce3718..91720d0e 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -41,7 +41,7 @@ export function startPty(options: { rows = 24, autoApprove, initialPrompt, - skipResume, + skipResume = false, } = options; const defaultShell = getDefaultShell(); diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 44ba9578..6c3b502d 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -24,6 +24,8 @@ import { useBrowser } from '@/providers/BrowserProvider'; import { useWorkspaceTerminals } from '@/lib/workspaceTerminalsStore'; import { getInstallCommandForProvider } from '@shared/providers/registry'; import { useAutoScrollOnWorkspaceSwitch } from '@/hooks/useAutoScrollOnWorkspaceSwitch'; +import { useConversations } from '@/hooks/useConversations'; +import { ConversationTabs } from './ConversationTabs'; declare const window: Window & { electronAPI: { @@ -58,7 +60,23 @@ const ChatInterface: React.FC = ({ getContainerRunState(workspace.id) ); const reduceMotion = useReducedMotion(); - const terminalId = useMemo(() => `${provider}-main-${workspace.id}`, [provider, workspace.id]); + + // Multi-conversation support + const { + conversations, + activeConversationId, + activeConversation, + isLoading: conversationsLoading, + createConversation, + selectConversation, + renameConversation, + deleteConversation, + } = useConversations(workspace.id); + + const terminalId = useMemo( + () => `${provider}-conv-${activeConversationId || workspace.id}`, + [provider, activeConversationId, workspace.id] + ); const [portsExpanded, setPortsExpanded] = useState(false); const { activeTerminalId } = useWorkspaceTerminals(workspace.id, workspace.path); @@ -650,12 +668,45 @@ const ChatInterface: React.FC = ({ ); }, [containerState, portsExpanded, reduceMotion, workspace.id, workspace.path]); + // Debug: Log conversation state + useEffect(() => { + console.log('[ChatInterface] Conversations state:', { + loading: conversationsLoading, + count: conversations.length, + activeId: activeConversationId, + conversations: conversations.map(c => ({ id: c.id, title: c.title })) + }); + }, [conversationsLoading, conversations, activeConversationId]); + if (!isTerminal) { return null; } return (
+ {/* Conversation tabs at the top */} + {!conversationsLoading && conversations.length > 0 && ( + { + // Create new conversation + await createConversation(); + + // If a specific provider was selected (not Terminal option), switch to it + if (selectedProvider !== null) { + setProvider(selectedProvider as Provider); + } + }} + onRenameConversation={renameConversation} + onDeleteConversation={deleteConversation} + providerIcon={providerMeta[provider]?.icon} + providerLabel={providerMeta[provider]?.label} + /> + )} +
diff --git a/src/renderer/components/ConversationTabs.tsx b/src/renderer/components/ConversationTabs.tsx new file mode 100644 index 00000000..ab98f3e7 --- /dev/null +++ b/src/renderer/components/ConversationTabs.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { X, Terminal } from 'lucide-react'; +import type { Conversation } from '../types/chat'; +import { cn } from '../lib/utils'; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from './ui/tooltip'; +import { NewConversationButton } from './NewConversationButton'; +import type { ProviderId } from '@shared/providers/registry'; + +interface ConversationTabsProps { + conversations: Conversation[]; + activeConversationId: string | null; + onSelectConversation: (id: string) => void; + workspaceId: string; + onCreateConversationWithProvider: (provider: ProviderId | null) => void; + onRenameConversation: (id: string, newTitle: string) => void; + onDeleteConversation: (id: string) => void; + isBusy?: boolean; + providerIcon?: string; + providerLabel?: string; +} + +export const ConversationTabs: React.FC = ({ + conversations, + activeConversationId, + onSelectConversation, + workspaceId, + onCreateConversationWithProvider, + onRenameConversation, + onDeleteConversation, + isBusy = false, + providerIcon, + providerLabel, +}) => { + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + const [hoveredId, setHoveredId] = useState(null); + + const handleDoubleClick = (conversation: Conversation) => { + setEditingId(conversation.id); + setEditingTitle(conversation.title); + }; + + const handleRenameSubmit = (id: string) => { + if (editingTitle.trim() && editingTitle !== conversations.find(c => c.id === id)?.title) { + onRenameConversation(id, editingTitle.trim()); + } + setEditingId(null); + setEditingTitle(''); + }; + + const handleRenameKeyDown = (e: React.KeyboardEvent, id: string) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRenameSubmit(id); + } else if (e.key === 'Escape') { + setEditingId(null); + setEditingTitle(''); + } + }; + + return ( + +
+
+ {conversations.map(conversation => { + const isActive = conversation.id === activeConversationId; + const isEditing = editingId === conversation.id; + const isHovered = hoveredId === conversation.id; + + return ( +
setHoveredId(conversation.id)} + onMouseLeave={() => setHoveredId(null)} + > + {/* Active indicator (bottom border) */} + {isActive && ( +
+ )} + + {/* Title or edit input */} + {isEditing ? ( + setEditingTitle(e.target.value)} + onBlur={() => handleRenameSubmit(conversation.id)} + onKeyDown={e => handleRenameKeyDown(e, conversation.id)} + className="h-6 w-32 rounded border border-border bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" + autoFocus + onFocus={e => e.target.select()} + /> + ) : ( +
+ +
+ )} + + {/* Gradient fade on hover */} + {!isEditing && isHovered && conversations.length > 1 && ( +
+ )} + + {/* Close button (show only on hover) - positioned absolutely relative to tab */} + {!isEditing && isHovered && conversations.length > 1 && ( + + + + + + Delete conversation + + + )} +
+ ); + })} + + {/* New conversation button with provider selector */} + +
+
+ + ); +}; diff --git a/src/renderer/components/NewConversationButton.tsx b/src/renderer/components/NewConversationButton.tsx new file mode 100644 index 00000000..1cf5fc22 --- /dev/null +++ b/src/renderer/components/NewConversationButton.tsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from 'react'; +import { Plus } from 'lucide-react'; +import { PROVIDER_IDS, type ProviderId } from '@shared/providers/registry'; +import { cn } from '../lib/utils'; + +interface NewConversationButtonProps { + workspaceId: string; + onProviderSelect: (provider: ProviderId | null) => void; + isBusy?: boolean; + className?: string; +} + +export const NewConversationButton: React.FC = ({ + workspaceId, + onProviderSelect, + isBusy = false, + className, +}) => { + const [defaultProvider, setDefaultProvider] = useState('claude'); + + // Load default provider from localStorage + useEffect(() => { + try { + const lastKey = `provider:last:${workspaceId}`; + const last = window.localStorage.getItem(lastKey) as ProviderId | null; + + if (last && PROVIDER_IDS.includes(last as any)) { + setDefaultProvider(last); + } else { + setDefaultProvider('claude'); + window.localStorage.setItem(lastKey, 'claude'); + } + } catch (error) { + console.error('Failed to load last provider:', error); + setDefaultProvider('claude'); + } + }, [workspaceId]); + + const handleClick = () => { + if (isBusy) return; + onProviderSelect(defaultProvider); + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/renderer/hooks/useConversations.ts b/src/renderer/hooks/useConversations.ts new file mode 100644 index 00000000..47004a6d --- /dev/null +++ b/src/renderer/hooks/useConversations.ts @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { Conversation } from '../types/chat'; + +interface UseConversationsResult { + conversations: Conversation[]; + activeConversationId: string | null; + activeConversation: Conversation | null; + isLoading: boolean; + createConversation: (title?: string) => Promise; + selectConversation: (id: string) => void; + renameConversation: (id: string, title: string) => Promise; + deleteConversation: (id: string) => Promise; +} + +export function useConversations(workspaceId: string): UseConversationsResult { + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Load conversations for this workspace + useEffect(() => { + let cancelled = false; + + const loadConversations = async () => { + try { + setIsLoading(true); + const result = await window.electronAPI.getConversations(workspaceId); + + if (cancelled) return; + + if (result.success && result.conversations && result.conversations.length > 0) { + setConversations(result.conversations); + + // Try to restore last active from localStorage + const lastActiveKey = `activeConversation:${workspaceId}`; + const lastActive = localStorage.getItem(lastActiveKey); + + if (lastActive && result.conversations.some((c: Conversation) => c.id === lastActive)) { + setActiveConversationId(lastActive); + } else { + // Select most recent + setActiveConversationId(result.conversations[0].id); + } + } else { + // No conversations exist, create a default one + const defaultResult = await window.electronAPI.getOrCreateDefaultConversation(workspaceId); + if (!cancelled && defaultResult.success && defaultResult.conversation) { + setConversations([defaultResult.conversation]); + setActiveConversationId(defaultResult.conversation.id); + } + } + } catch (error) { + console.error('Failed to load conversations:', error); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + loadConversations(); + + return () => { + cancelled = true; + }; + }, [workspaceId]); // Only reload when workspace changes + + // Persist active conversation ID to localStorage + useEffect(() => { + if (activeConversationId) { + const key = `activeConversation:${workspaceId}`; + localStorage.setItem(key, activeConversationId); + } + }, [activeConversationId, workspaceId]); + + const createConversation = useCallback( + async (title?: string): Promise => { + // Calculate the next chat number (Chat 2, Chat 3, etc.) + const chatNumbers = conversations + .map(c => { + const match = c.title.match(/^Chat (\d+)$/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + const nextNumber = chatNumbers.length > 0 ? Math.max(...chatNumbers) + 1 : 2; + const newTitle = title || `Chat ${nextNumber}`; + const conversationId = `conv-${workspaceId}-${Date.now()}`; + + try { + const result = await window.electronAPI.saveConversation({ + id: conversationId, + workspaceId, + title: newTitle, + }); + + if (result.success) { + const newConversation: Conversation = { + id: conversationId, + workspaceId, + title: newTitle, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Add new conversation to the right (end of array) + setConversations(prev => [...prev, newConversation]); + setActiveConversationId(conversationId); + return newConversation; + } + } catch (error) { + console.error('Failed to create conversation:', error); + } + + return null; + }, + [workspaceId, conversations] + ); + + const selectConversation = useCallback((id: string) => { + setActiveConversationId(id); + }, []); + + const renameConversation = useCallback(async (id: string, title: string) => { + try { + const result = await window.electronAPI.updateConversation(id, { title }); + + if (result.success) { + setConversations(prev => + prev.map(c => (c.id === id ? { ...c, title, updatedAt: new Date().toISOString() } : c)) + ); + } + } catch (error) { + console.error('Failed to rename conversation:', error); + } + }, []); + + const deleteConversation = useCallback( + async (id: string) => { + // Prevent deleting the last conversation + if (conversations.length <= 1) { + console.warn('Cannot delete the last conversation'); + return; + } + + try { + const result = await window.electronAPI.deleteConversation(id); + + if (result.success) { + setConversations(prev => prev.filter(c => c.id !== id)); + + // If we deleted the active conversation, switch to the most recent remaining one + if (activeConversationId === id) { + const remaining = conversations.filter(c => c.id !== id); + if (remaining.length > 0) { + setActiveConversationId(remaining[0].id); + } + } + } + } catch (error) { + console.error('Failed to delete conversation:', error); + } + }, + [conversations, activeConversationId] + ); + + const activeConversation = conversations.find(c => c.id === activeConversationId) || null; + + return { + conversations, + activeConversationId, + activeConversation, + isLoading, + createConversation, + selectConversation, + renameConversation, + deleteConversation, + }; +} diff --git a/src/renderer/terminal/SessionRegistry.ts b/src/renderer/terminal/SessionRegistry.ts index 9f86b39e..0161f326 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -64,6 +64,8 @@ class SessionRegistry { telemetry: null, autoApprove: options.autoApprove, initialPrompt: options.initialPrompt, + // Let ptyIpc decide whether to skip resume based on snapshot existence + // Don't force skipResume here - the IPC layer handles this intelligently }; const session = new TerminalSessionManager(sessionOptions); diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 1245ec91..d9d1722b 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -30,6 +30,7 @@ export interface TerminalSessionOptions { telemetry?: { track: (event: string, payload?: Record) => void } | null; autoApprove?: boolean; initialPrompt?: string; + skipResume?: boolean; } type CleanupFn = () => void; @@ -401,7 +402,7 @@ export class TerminalSessionManager { } private connectPty() { - const { workspaceId, cwd, shell, env, initialSize, autoApprove, initialPrompt } = this.options; + const { workspaceId, cwd, shell, env, initialSize, autoApprove, initialPrompt, skipResume } = this.options; const id = workspaceId; void window.electronAPI .ptyStart({ @@ -413,6 +414,7 @@ export class TerminalSessionManager { rows: initialSize.rows, autoApprove, initialPrompt, + skipResume, }) .then((result) => { if (result?.ok) { diff --git a/src/renderer/types/chat.ts b/src/renderer/types/chat.ts index 9db4a124..6cfaa400 100644 --- a/src/renderer/types/chat.ts +++ b/src/renderer/types/chat.ts @@ -48,6 +48,14 @@ export interface Workspace { metadata?: WorkspaceMetadata | null; } +export interface Conversation { + id: string; + workspaceId: string; + title: string; + createdAt: string; + updatedAt: string; +} + export interface Message { id: string; content: string; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 4b68092a..5ee843ae 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -587,14 +587,25 @@ declare global { deleteProject: (projectId: string) => Promise<{ success: boolean; error?: string }>; deleteWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + // Conversation operations + saveConversation: (conversation: any) => Promise<{ success: boolean; error?: string }>; + getConversations: ( + workspaceId: string + ) => Promise<{ success: boolean; conversations?: any[]; error?: string }>; + getOrCreateDefaultConversation: ( + workspaceId: string + ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + updateConversation: ( + conversationId: string, + updates: Partial<{ title: string }> + ) => Promise<{ success: boolean; error?: string }>; + deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; + // Message operations saveMessage: (message: any) => Promise<{ success: boolean; error?: string }>; getMessages: ( conversationId: string ) => Promise<{ success: boolean; messages?: any[]; error?: string }>; - getOrCreateDefaultConversation: ( - workspaceId: string - ) => Promise<{ success: boolean; conversation?: any; error?: string }>; // Debug helpers debugAppendLog: ( @@ -915,14 +926,25 @@ export interface ElectronAPI { deleteProject: (projectId: string) => Promise<{ success: boolean; error?: string }>; deleteWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + // Conversation operations + saveConversation: (conversation: any) => Promise<{ success: boolean; error?: string }>; + getConversations: ( + workspaceId: string + ) => Promise<{ success: boolean; conversations?: any[]; error?: string }>; + getOrCreateDefaultConversation: ( + workspaceId: string + ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + updateConversation: ( + conversationId: string, + updates: Partial<{ title: string }> + ) => Promise<{ success: boolean; error?: string }>; + deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; + // Message operations saveMessage: (message: any) => Promise<{ success: boolean; error?: string }>; getMessages: ( conversationId: string ) => Promise<{ success: boolean; messages?: any[]; error?: string }>; - getOrCreateDefaultConversation: ( - workspaceId: string - ) => Promise<{ success: boolean; conversation?: any; error?: string }>; // Debug helpers debugAppendLog: (