diff --git a/drizzle/0006_add_multi_chat_support.sql b/drizzle/0006_add_multi_chat_support.sql new file mode 100644 index 00000000..f6030ced --- /dev/null +++ b/drizzle/0006_add_multi_chat_support.sql @@ -0,0 +1,11 @@ +-- Add multi-chat support fields to conversations table +ALTER TABLE conversations ADD COLUMN provider TEXT; +ALTER TABLE conversations ADD COLUMN is_active INTEGER DEFAULT 0; +ALTER TABLE conversations ADD COLUMN display_order INTEGER DEFAULT 0; +ALTER TABLE conversations ADD COLUMN metadata TEXT; + +-- Update existing conversations to be active (first chat in each task) +UPDATE conversations SET is_active = 1, display_order = 0 WHERE is_active IS NULL; + +-- Create index for quick active conversation lookup +CREATE INDEX IF NOT EXISTS idx_conversations_active ON conversations (task_id, is_active); \ No newline at end of file diff --git a/drizzle/0007_add_is_main_to_conversations.sql b/drizzle/0007_add_is_main_to_conversations.sql new file mode 100644 index 00000000..cfd00f95 --- /dev/null +++ b/drizzle/0007_add_is_main_to_conversations.sql @@ -0,0 +1,13 @@ +-- Add is_main column to conversations table +ALTER TABLE conversations ADD COLUMN is_main INTEGER NOT NULL DEFAULT 0; + +-- Mark first conversation of each task as main (for backward compatibility) +UPDATE conversations +SET is_main = 1 +WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at ASC) as rn + FROM conversations + ) t + WHERE rn = 1 +); \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8a9fe378..b7dfdc36 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1765592430357, "tag": "0005_add_use_worktree_to_tasks", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1765592430358, + "tag": "0006_add_multi_chat_support", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1765592430359, + "tag": "0007_add_is_main_to_conversations", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 0ed07d30..8229b6b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.16", + "better-sqlite3": "^12.6.0", "concurrently": "^8.2.2", "drizzle-kit": "^0.24.1", "electron": "^30.5.1", @@ -5681,6 +5682,21 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index 40c6dc6d..6411a63b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.16", + "better-sqlite3": "^12.6.0", "concurrently": "^8.2.2", "drizzle-kit": "^0.24.1", "electron": "^30.5.1", diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index af606816..c6e723c0 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -58,6 +58,11 @@ export const conversations = sqliteTable( .notNull() .references(() => tasks.id, { onDelete: 'cascade' }), title: text('title').notNull(), + provider: text('provider'), // AI provider for this chat (claude, codex, qwen, etc.) + isActive: integer('is_active').notNull().default(0), // 1 if this is the active chat for the task + isMain: integer('is_main').notNull().default(0), // 1 if this is the main/primary chat (gets full persistence) + displayOrder: integer('display_order').notNull().default(0), // Order in the tab bar + metadata: text('metadata'), // JSON for additional chat-specific data createdAt: text('created_at') .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -67,6 +72,7 @@ export const conversations = sqliteTable( }, (table) => ({ taskIdIdx: index('idx_conversations_task_id').on(table.taskId), + activeIdx: index('idx_conversations_active').on(table.taskId, table.isActive), // Index for quick active conversation lookup }) ); diff --git a/src/main/ipc/dbIpc.ts b/src/main/ipc/dbIpc.ts index 9928c40a..108a828c 100644 --- a/src/main/ipc/dbIpc.ts +++ b/src/main/ipc/dbIpc.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron'; import { log } from '../lib/logger'; import { databaseService } from '../services/DatabaseService'; +import fs from 'fs'; +import path from 'path'; export function registerDatabaseIpc() { ipcMain.handle('db:getProjects', async () => { @@ -111,6 +113,40 @@ export function registerDatabaseIpc() { } }); + ipcMain.handle( + 'db:cleanupSessionDirectory', + async (_, args: { taskPath: string; conversationId: string }) => { + try { + const sessionDir = path.join(args.taskPath, '.emdash-sessions', args.conversationId); + + // Check if directory exists before trying to remove it + if (fs.existsSync(sessionDir)) { + // Remove the directory and its contents + fs.rmSync(sessionDir, { recursive: true, force: true }); + log.info('Cleaned up session directory:', sessionDir); + + // Also try to remove the parent .emdash-sessions if it's empty + const parentDir = path.join(args.taskPath, '.emdash-sessions'); + try { + const entries = fs.readdirSync(parentDir); + if (entries.length === 0) { + fs.rmdirSync(parentDir); + log.info('Removed empty .emdash-sessions directory'); + } + } catch (err) { + // Parent directory removal is optional + } + } + + return { success: true }; + } catch (error) { + log.warn('Failed to cleanup session directory:', error); + // This is best-effort, don't fail the operation + return { success: true }; + } + } + ); + ipcMain.handle('db:deleteTask', async (_, taskId: string) => { try { await databaseService.deleteTask(taskId); @@ -120,4 +156,80 @@ export function registerDatabaseIpc() { return { success: false, error: (error as Error).message }; } }); + + // Multi-chat support handlers + ipcMain.handle( + 'db:createConversation', + async ( + _, + { + taskId, + title, + provider, + isMain, + }: { taskId: string; title: string; provider?: string; isMain?: boolean } + ) => { + try { + const conversation = await databaseService.createConversation( + taskId, + title, + provider, + isMain + ); + return { success: true, conversation }; + } catch (error) { + log.error('Failed to create conversation:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + + ipcMain.handle( + 'db:setActiveConversation', + async (_, { taskId, conversationId }: { taskId: string; conversationId: string }) => { + try { + await databaseService.setActiveConversation(taskId, conversationId); + return { success: true }; + } catch (error) { + log.error('Failed to set active conversation:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + + ipcMain.handle('db:getActiveConversation', async (_, taskId: string) => { + try { + const conversation = await databaseService.getActiveConversation(taskId); + return { success: true, conversation }; + } catch (error) { + log.error('Failed to get active conversation:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle( + 'db:reorderConversations', + async (_, { taskId, conversationIds }: { taskId: string; conversationIds: string[] }) => { + try { + await databaseService.reorderConversations(taskId, conversationIds); + return { success: true }; + } catch (error) { + log.error('Failed to reorder conversations:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + + ipcMain.handle( + 'db:updateConversationTitle', + async (_, { conversationId, title }: { conversationId: string; title: string }) => { + try { + await databaseService.updateConversationTitle(conversationId, title); + return { success: true }; + } catch (error) { + log.error('Failed to update conversation title:', error); + return { success: false, error: (error as Error).message }; + } + } + ); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 441d8406..211ae363 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -315,6 +315,19 @@ contextBridge.exposeInMainWorld('electronAPI', { getMessages: (conversationId: string) => ipcRenderer.invoke('db:getMessages', conversationId), deleteConversation: (conversationId: string) => ipcRenderer.invoke('db:deleteConversation', conversationId), + cleanupSessionDirectory: (args: { taskPath: string; conversationId: string }) => + ipcRenderer.invoke('db:cleanupSessionDirectory', args), + + // Multi-chat support + createConversation: (params: { taskId: string; title: string; provider?: string }) => + ipcRenderer.invoke('db:createConversation', params), + setActiveConversation: (params: { taskId: string; conversationId: string }) => + ipcRenderer.invoke('db:setActiveConversation', params), + getActiveConversation: (taskId: string) => ipcRenderer.invoke('db:getActiveConversation', taskId), + reorderConversations: (params: { taskId: string; conversationIds: string[] }) => + ipcRenderer.invoke('db:reorderConversations', params), + updateConversationTitle: (params: { conversationId: string; title: string }) => + ipcRenderer.invoke('db:updateConversationTitle', params), // Line comments management lineCommentsCreate: (input: any) => ipcRenderer.invoke('lineComments:create', input), @@ -637,6 +650,32 @@ export interface ElectronAPI { conversationId: string ) => Promise<{ success: boolean; messages?: any[]; error?: string }>; deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; + cleanupSessionDirectory: (args: { + taskPath: string; + conversationId: string; + }) => Promise<{ success: boolean }>; + + // Multi-chat support + createConversation: (params: { + taskId: string; + title: string; + provider?: string; + }) => Promise<{ success: boolean; conversation?: any; error?: string }>; + setActiveConversation: (params: { + taskId: string; + conversationId: string; + }) => Promise<{ success: boolean; error?: string }>; + getActiveConversation: ( + taskId: string + ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + reorderConversations: (params: { + taskId: string; + conversationIds: string[]; + }) => Promise<{ success: boolean; error?: string }>; + updateConversationTitle: (params: { + conversationId: string; + title: 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 dbf59272..74466b00 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -54,6 +54,11 @@ export interface Conversation { id: string; taskId: string; title: string; + provider?: string | null; + isActive?: boolean; + isMain?: boolean; + displayOrder?: number; + metadata?: string | null; createdAt: string; updatedAt: string; } @@ -319,12 +324,22 @@ export class DatabaseService { id: conversation.id, taskId: conversation.taskId, title: conversation.title, + provider: conversation.provider ?? null, + isActive: conversation.isActive ? 1 : 0, + isMain: conversation.isMain ? 1 : 0, + displayOrder: conversation.displayOrder ?? 0, + metadata: conversation.metadata ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, }) .onConflictDoUpdate({ target: conversationsTable.id, set: { title: conversation.title, + provider: conversation.provider ?? null, + isActive: conversation.isActive ? 1 : 0, + isMain: conversation.isMain ? 1 : 0, + displayOrder: conversation.displayOrder ?? 0, + metadata: conversation.metadata ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, }, }); @@ -337,7 +352,7 @@ export class DatabaseService { .select() .from(conversationsTable) .where(eq(conversationsTable.taskId, taskId)) - .orderBy(desc(conversationsTable.updatedAt)); + .orderBy(asc(conversationsTable.displayOrder), desc(conversationsTable.updatedAt)); return rows.map((row) => this.mapDrizzleConversationRow(row)); } @@ -347,6 +362,7 @@ export class DatabaseService { id: `conv-${taskId}-default`, taskId, title: 'Default Conversation', + isMain: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -369,6 +385,7 @@ export class DatabaseService { id: conversationId, taskId, title: 'Default Conversation', + isMain: true, }); const [createdRow] = await db @@ -439,6 +456,136 @@ export class DatabaseService { await db.delete(conversationsTable).where(eq(conversationsTable.id, conversationId)); } + // New multi-chat methods + async createConversation( + taskId: string, + title: string, + provider?: string, + isMain?: boolean + ): Promise { + if (this.disabled) { + return { + id: `conv-${taskId}-${Date.now()}`, + taskId, + title, + provider: provider ?? null, + isActive: true, + isMain: isMain ?? false, + displayOrder: 0, + metadata: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + const { db } = await getDrizzleClient(); + + // Get the next display order + const existingConversations = await db + .select() + .from(conversationsTable) + .where(eq(conversationsTable.taskId, taskId)); + + const maxOrder = Math.max(...existingConversations.map((c) => c.displayOrder || 0), -1); + + // Check if this should be the main conversation + // If explicitly set as main, check if one already exists + if (isMain === true) { + const hasMain = existingConversations.some((c) => c.isMain === 1); + if (hasMain) { + isMain = false; // Don't allow multiple main conversations + } + } else if (isMain === undefined) { + // If not specified, make it main only if it's the first conversation + isMain = existingConversations.length === 0; + } + + // Deactivate other conversations + await db + .update(conversationsTable) + .set({ isActive: 0 }) + .where(eq(conversationsTable.taskId, taskId)); + + // Create the new conversation + const conversationId = `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const newConversation = { + id: conversationId, + taskId, + title, + provider: provider ?? null, + isActive: true, + isMain: isMain ?? false, + displayOrder: maxOrder + 1, + }; + + await this.saveConversation(newConversation); + + // Fetch the created conversation + const [createdRow] = await db + .select() + .from(conversationsTable) + .where(eq(conversationsTable.id, conversationId)) + .limit(1); + + return this.mapDrizzleConversationRow(createdRow); + } + + async setActiveConversation(taskId: string, conversationId: string): Promise { + if (this.disabled) return; + const { db } = await getDrizzleClient(); + + await db.transaction(async (tx) => { + // Deactivate all conversations for this task + await tx + .update(conversationsTable) + .set({ isActive: 0 }) + .where(eq(conversationsTable.taskId, taskId)); + + // Activate the selected one + await tx + .update(conversationsTable) + .set({ isActive: 1, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(conversationsTable.id, conversationId)); + }); + } + + async getActiveConversation(taskId: string): Promise { + if (this.disabled) return null; + const { db } = await getDrizzleClient(); + + const results = await db + .select() + .from(conversationsTable) + .where(and(eq(conversationsTable.taskId, taskId), eq(conversationsTable.isActive, 1))) + .limit(1); + + return results[0] ? this.mapDrizzleConversationRow(results[0]) : null; + } + + async reorderConversations(taskId: string, conversationIds: string[]): Promise { + if (this.disabled) return; + const { db } = await getDrizzleClient(); + + await db.transaction(async (tx) => { + for (let i = 0; i < conversationIds.length; i++) { + await tx + .update(conversationsTable) + .set({ displayOrder: i }) + .where(eq(conversationsTable.id, conversationIds[i])); + } + }); + } + + async updateConversationTitle(conversationId: string, title: string): Promise { + if (this.disabled) return; + const { db } = await getDrizzleClient(); + + await db + .update(conversationsTable) + .set({ title, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(conversationsTable.id, conversationId)); + } + // Line comment management methods async saveLineComment( input: Omit @@ -620,6 +767,12 @@ export class DatabaseService { id: row.id, taskId: row.taskId, title: row.title, + provider: row.provider ?? null, + isActive: row.isActive === 1, + // For backward compatibility: treat missing isMain as true (assume first/only conversation is main) + isMain: row.isMain !== undefined ? row.isMain === 1 : true, + displayOrder: row.displayOrder ?? 0, + metadata: row.metadata ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index 33fdb71b..4024db1b 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -8,6 +8,7 @@ import { getAppSettings } from '../settings'; import * as telemetry from '../telemetry'; import { PROVIDER_IDS, getProvider, type ProviderId } from '../../shared/providers/registry'; import { detectAndLoadTerminalConfig } from './TerminalConfigParser'; +import { databaseService } from './DatabaseService'; const owners = new Map(); const listeners = new Set(); @@ -37,26 +38,82 @@ export function registerPtyIpc(): void { const { id, cwd, shell, env, cols, rows, autoApprove, initialPrompt, skipResume } = args; const existing = getPty(id); - // Only use resume flag if there's actually a conversation history to resume - let shouldSkipResume = skipResume || false; - if (!existing && !skipResume && shell) { - const parsed = parseProviderPty(id); - if (parsed) { - const provider = getProvider(parsed.providerId); - if (provider?.resumeFlag) { - // Check if snapshot exists before using resume flag - try { - const snapshot = await terminalSnapshotService.getSnapshot(id); - if (!snapshot || !snapshot.data) { - log.info('ptyIpc:noSnapshot - skipping resume flag', { id }); - shouldSkipResume = true; + // Determine if we should skip resume + let shouldSkipResume = skipResume; + + // Check if this is an additional (non-main) chat + // Additional chats should always skip resume as they don't have persistence + const isAdditionalChat = id.includes('-chat-') && !id.includes('-main-'); + + if (isAdditionalChat) { + // Additional chats always start fresh (no resume) + shouldSkipResume = true; + } else if (shouldSkipResume === undefined) { + // For main chats, check if this is a first-time start + // For Claude and similar providers, check if a session directory exists + if (cwd && shell) { + try { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const crypto = require('crypto'); + + // Check if this is Claude by looking at the shell + const isClaudeOrSimilar = shell.includes('claude') || shell.includes('aider'); + + if (isClaudeOrSimilar) { + // Claude stores sessions in ~/.claude/projects/ with various naming schemes + // Check both hash-based and path-based directory names + const cwdHash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16); + const claudeHashDir = path.join(os.homedir(), '.claude', 'projects', cwdHash); + + // Also check for path-based directory name (Claude's actual format) + // Replace path separators with hyphens for the directory name + const pathBasedName = cwd.replace(/\//g, '-'); + const claudePathDir = path.join(os.homedir(), '.claude', 'projects', pathBasedName); + + // Check if any Claude session directory exists for this working directory + const projectsDir = path.join(os.homedir(), '.claude', 'projects'); + let sessionExists = false; + + // Check if the hash-based directory exists + sessionExists = fs.existsSync(claudeHashDir); + + // If not, check for path-based directory + if (!sessionExists) { + sessionExists = fs.existsSync(claudePathDir); + } + + // If still not found, scan the projects directory for any matching directory + if (!sessionExists && fs.existsSync(projectsDir)) { + try { + const dirs = fs.readdirSync(projectsDir); + // Check if any directory contains part of the working directory path + const cwdParts = cwd.split('/').filter((p) => p.length > 0); + const lastParts = cwdParts.slice(-3).join('-'); // Use last 3 parts of path + sessionExists = dirs.some((dir: string) => dir.includes(lastParts)); + } catch (e) { + log.debug('pty:start error scanning Claude projects directory', { error: e }); + } } - } catch (err) { - log.warn('ptyIpc:snapshotCheckFailed - skipping resume', { id, error: err }); - shouldSkipResume = true; + + // Skip resume if no session directory exists (new task) + shouldSkipResume = !sessionExists; + } else { + // For other providers, default to not skipping (allow resume if supported) + shouldSkipResume = false; } + } catch (e) { + // On error, default to not skipping + shouldSkipResume = false; } + } else { + // If no cwd or shell, default to not skipping + shouldSkipResume = false; } + } else { + // Use the explicitly provided value + shouldSkipResume = shouldSkipResume || false; } const proc = @@ -80,7 +137,9 @@ export function registerPtyIpc(): void { cols, rows, autoApprove, - skipResume, + skipResumeRequested: skipResume, + skipResumeActual: shouldSkipResume, + isAdditionalChat, reused: !!existing, envKeys, }); @@ -213,12 +272,19 @@ function parseProviderPty(id: string): { providerId: ProviderId; taskId: string; } | null { - // Chat terminals are named `${provider}-main-${taskId}` - const match = /^([a-z0-9_-]+)-main-(.+)$/.exec(id); + // Chat terminals can be: + // - `${provider}-main-${taskId}` for main task terminals + // - `${provider}-chat-${conversationId}` for chat-specific terminals + const mainMatch = /^([a-z0-9_-]+)-main-(.+)$/.exec(id); + const chatMatch = /^([a-z0-9_-]+)-chat-(.+)$/.exec(id); + + const match = mainMatch || chatMatch; if (!match) return null; + const providerId = match[1] as ProviderId; if (!PROVIDER_IDS.includes(providerId)) return null; - const taskId = match[2]; + + const taskId = match[2]; // This is either taskId or conversationId return { providerId, taskId }; } diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 6f991c60..4c39e982 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -1,4 +1,6 @@ import os from 'os'; +import fs from 'fs'; +import path from 'path'; import type { IPty } from 'node-pty'; import { log } from '../lib/logger'; import { PROVIDERS } from '@shared/providers/registry'; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8ff0052b..acb95bd8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1404,185 +1404,194 @@ const AppContent: React.FC = () => { } } + // Seed conversation with issue context if applicable + // Only create/get conversation once, not multiple times { - if (taskMetadata?.linearIssue) { + const hasIssueContext = + taskMetadata?.linearIssue || taskMetadata?.githubIssue || taskMetadata?.jiraIssue; + let conversationId: string | undefined; + + if (hasIssueContext) { try { + // Get or create default conversation only once const convoResult = await window.electronAPI.getOrCreateDefaultConversation(newTask.id); - if (convoResult?.success && convoResult.conversation?.id) { - const issue = taskMetadata.linearIssue; - const detailParts: string[] = []; - const stateName = issue.state?.name?.trim(); - const assigneeName = - issue.assignee?.displayName?.trim() || issue.assignee?.name?.trim(); - const teamKey = issue.team?.key?.trim(); - const projectName = issue.project?.name?.trim(); - - if (stateName) detailParts.push(`State: ${stateName}`); - if (assigneeName) detailParts.push(`Assignee: ${assigneeName}`); - if (teamKey) detailParts.push(`Team: ${teamKey}`); - if (projectName) detailParts.push(`Project: ${projectName}`); - - const lines = [`Linked Linear issue: ${issue.identifier} — ${issue.title}`]; - - if (detailParts.length) { - lines.push(`Details: ${detailParts.join(' • ')}`); - } + conversationId = convoResult.conversation.id; + } + } catch (error) { + const { log } = await import('./lib/logger'); + log.error('Failed to get or create default conversation:', error as any); + } + } - if (issue.url) { - lines.push(`URL: ${issue.url}`); - } + if (conversationId && taskMetadata?.linearIssue) { + try { + const issue = taskMetadata.linearIssue; + const detailParts: string[] = []; + const stateName = issue.state?.name?.trim(); + const assigneeName = + issue.assignee?.displayName?.trim() || issue.assignee?.name?.trim(); + const teamKey = issue.team?.key?.trim(); + const projectName = issue.project?.name?.trim(); + + if (stateName) detailParts.push(`State: ${stateName}`); + if (assigneeName) detailParts.push(`Assignee: ${assigneeName}`); + if (teamKey) detailParts.push(`Team: ${teamKey}`); + if (projectName) detailParts.push(`Project: ${projectName}`); + + const lines = [`Linked Linear issue: ${issue.identifier} — ${issue.title}`]; + + if (detailParts.length) { + lines.push(`Details: ${detailParts.join(' • ')}`); + } - if ((issue as any)?.description) { - lines.push(''); - lines.push('Issue Description:'); - lines.push(String((issue as any).description).trim()); - } + if (issue.url) { + lines.push(`URL: ${issue.url}`); + } - await window.electronAPI.saveMessage({ - id: `linear-context-${newTask.id}`, - conversationId: convoResult.conversation.id, - content: lines.join('\n'), - sender: 'agent', - metadata: JSON.stringify({ - isLinearContext: true, - linearIssue: issue, - }), - }); + if ((issue as any)?.description) { + lines.push(''); + lines.push('Issue Description:'); + lines.push(String((issue as any).description).trim()); } + + await window.electronAPI.saveMessage({ + id: `linear-context-${newTask.id}`, + conversationId, + content: lines.join('\n'), + sender: 'agent', + metadata: JSON.stringify({ + isLinearContext: true, + linearIssue: issue, + }), + }); } catch (seedError) { const { log } = await import('./lib/logger'); log.error('Failed to seed task with Linear issue context:', seedError as any); } } - if (taskMetadata?.githubIssue) { - try { - const convoResult = await window.electronAPI.getOrCreateDefaultConversation(newTask.id); - if (convoResult?.success && convoResult.conversation?.id) { - const issue = taskMetadata.githubIssue; - const detailParts: string[] = []; - const stateName = issue.state?.toString()?.trim(); - const assignees = Array.isArray(issue.assignees) - ? issue.assignees - .map((a) => a?.name || a?.login) - .filter(Boolean) - .join(', ') - : ''; - const labels = Array.isArray(issue.labels) - ? issue.labels - .map((l) => l?.name) - .filter(Boolean) - .join(', ') - : ''; - if (stateName) detailParts.push(`State: ${stateName}`); - if (assignees) detailParts.push(`Assignees: ${assignees}`); - if (labels) detailParts.push(`Labels: ${labels}`); - - const lines = [`Linked GitHub issue: #${issue.number} — ${issue.title}`]; - - if (detailParts.length) { - lines.push(`Details: ${detailParts.join(' • ')}`); - } - - if (issue.url) { - lines.push(`URL: ${issue.url}`); - } + if (conversationId && taskMetadata?.githubIssue) { + try { + const issue = taskMetadata.githubIssue; + const detailParts: string[] = []; + const stateName = issue.state?.toString()?.trim(); + const assignees = Array.isArray(issue.assignees) + ? issue.assignees + .map((a) => a?.name || a?.login) + .filter(Boolean) + .join(', ') + : ''; + const labels = Array.isArray(issue.labels) + ? issue.labels + .map((l) => l?.name) + .filter(Boolean) + .join(', ') + : ''; + if (stateName) detailParts.push(`State: ${stateName}`); + if (assignees) detailParts.push(`Assignees: ${assignees}`); + if (labels) detailParts.push(`Labels: ${labels}`); + + const lines = [`Linked GitHub issue: #${issue.number} — ${issue.title}`]; + + if (detailParts.length) { + lines.push(`Details: ${detailParts.join(' • ')}`); + } - if ((issue as any)?.body) { - lines.push(''); - lines.push('Issue Description:'); - lines.push(String((issue as any).body).trim()); - } + if (issue.url) { + lines.push(`URL: ${issue.url}`); + } - await window.electronAPI.saveMessage({ - id: `github-context-${newTask.id}`, - conversationId: convoResult.conversation.id, - content: lines.join('\n'), - sender: 'agent', - metadata: JSON.stringify({ - isGitHubContext: true, - githubIssue: issue, - }), - }); + if ((issue as any)?.body) { + lines.push(''); + lines.push('Issue Description:'); + lines.push(String((issue as any).body).trim()); } + + await window.electronAPI.saveMessage({ + id: `github-context-${newTask.id}`, + conversationId, + content: lines.join('\n'), + sender: 'agent', + metadata: JSON.stringify({ + isGitHubContext: true, + githubIssue: issue, + }), + }); } catch (seedError) { const { log } = await import('./lib/logger'); log.error('Failed to seed task with GitHub issue context:', seedError as any); } } - if (taskMetadata?.jiraIssue) { - try { - const convoResult = await window.electronAPI.getOrCreateDefaultConversation(newTask.id); - if (convoResult?.success && convoResult.conversation?.id) { - const issue: any = taskMetadata.jiraIssue; - const lines: string[] = []; - const line1 = - `Linked Jira issue: ${issue.key || ''}${issue.summary ? ` — ${issue.summary}` : ''}`.trim(); - if (line1) lines.push(line1); - - const details: string[] = []; - if (issue.status?.name) details.push(`Status: ${issue.status.name}`); - if (issue.assignee?.displayName || issue.assignee?.name) - details.push(`Assignee: ${issue.assignee?.displayName || issue.assignee?.name}`); - if (issue.project?.key) details.push(`Project: ${issue.project.key}`); - if (details.length) lines.push(`Details: ${details.join(' • ')}`); - if (issue.url) lines.push(`URL: ${issue.url}`); - - await window.electronAPI.saveMessage({ - id: `jira-context-${newTask.id}`, - conversationId: convoResult.conversation.id, - content: lines.join('\n'), - sender: 'agent', - metadata: JSON.stringify({ - isJiraContext: true, - jiraIssue: issue, - }), - }); - } + if (conversationId && taskMetadata?.jiraIssue) { + try { + const issue: any = taskMetadata.jiraIssue; + const lines: string[] = []; + const line1 = + `Linked Jira issue: ${issue.key || ''}${issue.summary ? ` — ${issue.summary}` : ''}`.trim(); + if (line1) lines.push(line1); + + const details: string[] = []; + if (issue.status?.name) details.push(`Status: ${issue.status.name}`); + if (issue.assignee?.displayName || issue.assignee?.name) + details.push(`Assignee: ${issue.assignee?.displayName || issue.assignee?.name}`); + if (issue.project?.key) details.push(`Project: ${issue.project.key}`); + if (details.length) lines.push(`Details: ${details.join(' • ')}`); + if (issue.url) lines.push(`URL: ${issue.url}`); + + await window.electronAPI.saveMessage({ + id: `jira-context-${newTask.id}`, + conversationId, + content: lines.join('\n'), + sender: 'agent', + metadata: JSON.stringify({ + isJiraContext: true, + jiraIssue: issue, + }), + }); } catch (seedError) { const { log } = await import('./lib/logger'); log.error('Failed to seed task with Jira issue context:', seedError as any); } } + } - setProjects((prev) => - prev.map((project) => - project.id === selectedProject.id - ? { - ...project, - tasks: [newTask, ...(project.tasks || [])], - } - : project - ) - ); - - setSelectedProject((prev) => - prev + setProjects((prev) => + prev.map((project) => + project.id === selectedProject.id ? { - ...prev, - tasks: [newTask, ...(prev.tasks || [])], + ...project, + tasks: [newTask, ...(project.tasks || [])], } - : null - ); + : project + ) + ); - // Track task creation - const { captureTelemetry } = await import('./lib/telemetryClient'); - const isMultiAgent = (newTask.metadata as any)?.multiAgent?.enabled; - captureTelemetry('task_created', { - provider: isMultiAgent ? 'multi' : (newTask.agentId as string) || 'codex', - has_initial_prompt: !!taskMetadata?.initialPrompt, - }); + setSelectedProject((prev) => + prev + ? { + ...prev, + tasks: [newTask, ...(prev.tasks || [])], + } + : null + ); - // Set the active task and its provider (none if multi-agent) - setActiveTask(newTask); - if ((newTask.metadata as any)?.multiAgent?.enabled) { - setActiveTaskProvider(null); - } else { - // Use the saved agentId from the task, which should match primaryProvider - setActiveTaskProvider((newTask.agentId as Provider) || primaryProvider || 'codex'); - } + // Track task creation + const { captureTelemetry } = await import('./lib/telemetryClient'); + const isMultiAgentTelemetry = (newTask.metadata as any)?.multiAgent?.enabled; + captureTelemetry('task_created', { + provider: isMultiAgentTelemetry ? 'multi' : (newTask.agentId as string) || 'codex', + has_initial_prompt: !!taskMetadata?.initialPrompt, + }); + + // Set the active task and its provider (none if multi-agent) + setActiveTask(newTask); + if ((newTask.metadata as any)?.multiAgent?.enabled) { + setActiveTaskProvider(null); + } else { + // Use the saved agentId from the task, which should match primaryProvider + setActiveTaskProvider((newTask.agentId as Provider) || primaryProvider || 'codex'); } } catch (error) { const { log } = await import('./lib/logger'); diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index c148ec12..b5656434 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -1,19 +1,24 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { useReducedMotion } from 'motion/react'; +import { Plus, X } from 'lucide-react'; +import { useToast } from '../hooks/use-toast'; import { useTheme } from '../hooks/useTheme'; import { TerminalPane } from './TerminalPane'; import InstallBanner from './InstallBanner'; import { providerMeta } from '../providers/meta'; +import { providerConfig } from '../lib/providerConfig'; import ProviderDisplay from './ProviderDisplay'; import { useInitialPromptInjection } from '../hooks/useInitialPromptInjection'; import { useTaskComments } from '../hooks/useLineComments'; import { type Provider } from '../types'; import { Task } from '../types/chat'; -import { useBrowser } from '@/providers/BrowserProvider'; import { useTaskTerminals } from '@/lib/taskTerminalsStore'; import { getInstallCommandForProvider } from '@shared/providers/registry'; import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch'; import { TaskScopeProvider } from './TaskScopeContext'; +import { CreateChatModal } from './CreateChatModal'; +import { DeleteChatModal } from './DeleteChatModal'; +import { type Conversation } from '../../main/services/DatabaseService'; +import { terminalSessionRegistry } from '../terminal/SessionRegistry'; declare const window: Window & { electronAPI: { @@ -35,16 +40,47 @@ const ChatInterface: React.FC = ({ initialProvider, }) => { const { effectiveTheme } = useTheme(); + const { toast } = useToast(); const [isProviderInstalled, setIsProviderInstalled] = useState(null); const [providerStatuses, setProviderStatuses] = useState< Record >({}); - const [provider, setProvider] = useState(initialProvider || 'codex'); + const [provider, setProvider] = useState(initialProvider || 'claude'); const currentProviderStatus = providerStatuses[provider]; - const browser = useBrowser(); const [cliStartFailed, setCliStartFailed] = useState(false); - const reduceMotion = useReducedMotion(); - const terminalId = useMemo(() => `${provider}-main-${task.id}`, [provider, task.id]); + + // Multi-chat state + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); + const [conversationsLoaded, setConversationsLoaded] = useState(false); + const [showCreateChatModal, setShowCreateChatModal] = useState(false); + const [showDeleteChatModal, setShowDeleteChatModal] = useState(false); + const [chatToDelete, setChatToDelete] = useState(null); + const [installedProviders, setInstalledProviders] = useState([]); + + // Update terminal ID to include conversation ID and provider - unique per conversation + const terminalId = useMemo(() => { + // Find the active conversation to check if it's the main one + const activeConversation = conversations.find((c) => c.id === activeConversationId); + + if (activeConversation?.isMain) { + // Main conversations use task-based ID for backward compatibility + // This ensures terminal sessions persist correctly + return `${provider}-main-${task.id}`; + } else if (activeConversationId) { + // Additional conversations use conversation-specific ID + // Format: ${provider}-chat-${conversationId} + return `${provider}-chat-${activeConversationId}`; + } + // Fallback to main format if no active conversation + return `${provider}-main-${task.id}`; + }, [activeConversationId, provider, task.id, conversations]); + + // Claude needs consistent working directory to maintain session state + const terminalCwd = useMemo(() => { + return task.path; + }, [task.path]); + const { activeTerminalId } = useTaskTerminals(task.id, task.path); // Line comments for agent context injection @@ -53,9 +89,90 @@ const ChatInterface: React.FC = ({ // Auto-scroll to bottom when this task becomes active useAutoScrollOnTaskSwitch(true, task.id); + // Load conversations when task changes + useEffect(() => { + const loadConversations = async () => { + setConversationsLoaded(false); + const result = await window.electronAPI.getConversations(task.id); + + if (result.success && result.conversations && result.conversations.length > 0) { + setConversations(result.conversations); + + // Set active conversation + const active = result.conversations.find((c: Conversation) => c.isActive); + if (active) { + setActiveConversationId(active.id); + // Update provider to match the active conversation + if (active.provider) { + setProvider(active.provider as Provider); + } + } else { + // Fallback to first conversation + const firstConv = result.conversations[0]; + setActiveConversationId(firstConv.id); + // Update provider to match the first conversation + if (firstConv.provider) { + setProvider(firstConv.provider as Provider); + } + await window.electronAPI.setActiveConversation({ + taskId: task.id, + conversationId: firstConv.id, + }); + } + setConversationsLoaded(true); + } else { + // No conversations exist - create default for backward compatibility + // This ensures existing tasks always have at least one conversation + // (preserves pre-multi-chat behavior) + const defaultResult = await window.electronAPI.getOrCreateDefaultConversation(task.id); + if (defaultResult.success && defaultResult.conversation) { + // For backward compatibility: use task.agentId if available, otherwise use current provider + // This preserves the original provider choice for tasks created before multi-chat + const taskProvider = task.agentId || provider; + const conversationWithProvider = { + ...defaultResult.conversation, + provider: taskProvider, + isMain: true, + }; + setConversations([conversationWithProvider]); + setActiveConversationId(defaultResult.conversation.id); + + // Update the provider state to match + setProvider(taskProvider as Provider); + + // Save the provider to the conversation + await window.electronAPI.saveConversation(conversationWithProvider); + setConversationsLoaded(true); + } + } + }; + + loadConversations(); + }, [task.id, task.agentId]); // provider is intentionally not included as a dependency + + // Track installed providers + useEffect(() => { + const installed = Object.entries(providerStatuses) + .filter(([_, status]) => status.installed === true) + .map(([id]) => id); + setInstalledProviders(installed); + }, [providerStatuses]); + // Ref to control terminal focus imperatively if needed const terminalRef = useRef<{ focus: () => void }>(null); + // Auto-focus terminal when switching to this task + useEffect(() => { + // Small delay to ensure terminal is mounted and attached + const timer = setTimeout(() => { + const session = terminalSessionRegistry.getSession(terminalId); + if (session) { + session.focus(); + } + }, 100); + return () => clearTimeout(timer); + }, [task.id, terminalId]); + // Focus terminal when this task becomes active (for already-mounted terminals) useEffect(() => { // Small delay to ensure terminal is visible after tab switch @@ -158,9 +275,9 @@ const ChatInterface: React.FC = ({ setProvider(initialProvider); } else { const validProviders: Provider[] = [ - 'qwen', 'codex', 'claude', + 'qwen', 'droid', 'gemini', 'cursor', @@ -169,21 +286,154 @@ const ChatInterface: React.FC = ({ 'opencode', 'charm', 'auggie', + 'goose', 'kimi', + 'kilocode', 'kiro', 'rovo', + 'cline', + 'continue', + 'codebuff', + 'mistral', ]; if (last && (validProviders as string[]).includes(last)) { setProvider(last as Provider); } else { - setProvider('codex'); + setProvider('claude'); } } } catch { - setProvider(initialProvider || 'codex'); + setProvider(initialProvider || 'claude'); } }, [task.id, initialProvider]); + // Chat management handlers + const handleCreateChat = useCallback( + async (title: string, newProvider: string) => { + try { + // Don't dispose the current terminal - each chat has its own independent session + + const result = await window.electronAPI.createConversation({ + taskId: task.id, + title, + provider: newProvider, + isMain: false, // Additional chats are never main + }); + + if (result.success && result.conversation) { + // Reload conversations + const conversationsResult = await window.electronAPI.getConversations(task.id); + if (conversationsResult.success) { + setConversations(conversationsResult.conversations || []); + } + setActiveConversationId(result.conversation.id); + setProvider(newProvider as Provider); + toast({ + title: 'Chat Created', + description: `Created new chat: ${title}`, + }); + } else { + console.error('Failed to create conversation:', result.error); + toast({ + title: 'Error', + description: result.error || 'Failed to create chat', + variant: 'destructive', + }); + } + } catch (error) { + console.error('Exception creating conversation:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to create chat', + variant: 'destructive', + }); + } + }, + [task.id, toast] + ); + + const handleCreateNewChat = useCallback(() => { + setShowCreateChatModal(true); + }, []); + + const handleSwitchChat = useCallback( + async (conversationId: string) => { + // Don't dispose terminals - just switch between them + // Each chat maintains its own persistent terminal session + + await window.electronAPI.setActiveConversation({ + taskId: task.id, + conversationId, + }); + setActiveConversationId(conversationId); + + // Update provider based on conversation + const conv = conversations.find((c) => c.id === conversationId); + if (conv?.provider) { + setProvider(conv.provider as Provider); + } + }, + [task.id, conversations] + ); + + const handleCloseChat = useCallback( + (conversationId: string) => { + if (conversations.length <= 1) { + toast({ + title: 'Cannot Close', + description: 'Cannot close the last chat', + variant: 'destructive', + }); + return; + } + + // Show the delete confirmation modal + setChatToDelete(conversationId); + setShowDeleteChatModal(true); + }, + [conversations.length, toast] + ); + + const handleConfirmDeleteChat = useCallback(async () => { + if (!chatToDelete) return; + + // Only dispose the terminal when actually deleting the chat + // Find the conversation to get its provider + const convToDelete = conversations.find((c) => c.id === chatToDelete); + const convProvider = convToDelete?.provider || provider; + const terminalToDispose = `${convProvider}-chat-${chatToDelete}`; + terminalSessionRegistry.dispose(terminalToDispose); + + await window.electronAPI.deleteConversation(chatToDelete); + + // Reload conversations + const result = await window.electronAPI.getConversations(task.id); + if (result.success) { + setConversations(result.conversations || []); + // Switch to another chat if we deleted the active one + if ( + chatToDelete === activeConversationId && + result.conversations && + result.conversations.length > 0 + ) { + const newActive = result.conversations[0]; + await window.electronAPI.setActiveConversation({ + taskId: task.id, + conversationId: newActive.id, + }); + setActiveConversationId(newActive.id); + // Update provider if needed + if (newActive.provider) { + setProvider(newActive.provider as Provider); + } + } + } + + // Clear the state + setChatToDelete(null); + setShowDeleteChatModal(false); + }, [chatToDelete, conversations, provider, task.id, activeConversationId]); + // Persist last-selected provider per task (including Droid) useEffect(() => { try { @@ -464,17 +714,114 @@ const ChatInterface: React.FC = ({
+ setShowCreateChatModal(false)} + onCreateChat={handleCreateChat} + installedProviders={installedProviders} + currentProvider={provider} + existingConversations={conversations} + /> + + { + setChatToDelete(null); + setShowDeleteChatModal(false); + }} + /> +
- +
+ {conversations + .sort((a, b) => { + // Sort by display order or creation time to maintain consistent order + if (a.displayOrder !== undefined && b.displayOrder !== undefined) { + return a.displayOrder - b.displayOrder; + } + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }) + .map((conv, index) => { + const isActive = conv.id === activeConversationId; + const convProvider = conv.provider || provider; + const config = providerConfig[convProvider as Provider]; + const providerName = config?.name || convProvider; + + // Count how many chats use the same provider up to this point + const sameProviderCount = conversations + .slice(0, index + 1) + .filter((c) => (c.provider || provider) === convProvider).length; + const showNumber = + conversations.filter((c) => (c.provider || provider) === convProvider) + .length > 1; + + return ( + + )} + + ); + })} + + + + {(task.metadata?.linearIssue || + task.metadata?.githubIssue || + task.metadata?.jiraIssue) && ( + + )} +
{autoApproveEnabled && (
@@ -526,84 +873,90 @@ const ChatInterface: React.FC = ({ : '' }`} > - { - try { - window.localStorage.setItem(`provider:locked:${task.id}`, provider); - } catch {} - }} - onStartError={() => { - setCliStartFailed(true); - }} - onStartSuccess={() => { - setCliStartFailed(false); - // Mark initial injection as sent so it won't re-run on restart - if (initialInjection && !task.metadata?.initialInjectionSent) { - void window.electronAPI.saveTask({ - ...task, - metadata: { - ...task.metadata, - initialInjectionSent: true, - }, - }); + {/* Only render TerminalPane after conversations are loaded to ensure correct terminal ID and snapshot settings */} + {conversationsLoaded ? ( + c.id === activeConversationId)?.isMain } - }} - variant={ - effectiveTheme === 'dark' || effectiveTheme === 'dark-black' ? 'dark' : 'light' - } - themeOverride={ - provider === 'charm' - ? { - background: - effectiveTheme === 'dark-black' - ? '#0a0a0a' - : effectiveTheme === 'dark' - ? '#1f2937' - : '#ffffff', - selectionBackground: 'rgba(96, 165, 250, 0.35)', - selectionForeground: effectiveTheme === 'light' ? '#0f172a' : '#f9fafb', - } - : provider === 'mistral' + onActivity={() => { + try { + window.localStorage.setItem(`provider:locked:${task.id}`, provider); + } catch {} + }} + onStartError={() => { + setCliStartFailed(true); + }} + onStartSuccess={() => { + setCliStartFailed(false); + // Mark initial injection as sent so it won't re-run on restart + if (initialInjection && !task.metadata?.initialInjectionSent) { + void window.electronAPI.saveTask({ + ...task, + metadata: { + ...task.metadata, + initialInjectionSent: true, + }, + }); + } + }} + variant={ + effectiveTheme === 'dark' || effectiveTheme === 'dark-black' ? 'dark' : 'light' + } + themeOverride={ + provider === 'charm' ? { background: effectiveTheme === 'dark-black' - ? '#141820' + ? '#0a0a0a' : effectiveTheme === 'dark' - ? '#202938' + ? '#1f2937' : '#ffffff', selectionBackground: 'rgba(96, 165, 250, 0.35)', selectionForeground: effectiveTheme === 'light' ? '#0f172a' : '#f9fafb', } - : effectiveTheme === 'dark-black' + : provider === 'mistral' ? { - background: '#000000', + background: + effectiveTheme === 'dark-black' + ? '#141820' + : effectiveTheme === 'dark' + ? '#202938' + : '#ffffff', selectionBackground: 'rgba(96, 165, 250, 0.35)', - selectionForeground: '#f9fafb', + selectionForeground: effectiveTheme === 'light' ? '#0f172a' : '#f9fafb', } - : undefined - } - contentFilter={ - provider === 'charm' && - effectiveTheme !== 'dark' && - effectiveTheme !== 'dark-black' - ? 'invert(1) hue-rotate(180deg) brightness(1.1) contrast(1.05)' - : undefined - } - initialPrompt={ - providerMeta[provider]?.initialPromptFlag !== undefined && - !task.metadata?.initialInjectionSent - ? (initialInjection ?? undefined) - : undefined - } - className="h-full w-full" - /> + : effectiveTheme === 'dark-black' + ? { + background: '#000000', + selectionBackground: 'rgba(96, 165, 250, 0.35)', + selectionForeground: '#f9fafb', + } + : undefined + } + contentFilter={ + provider === 'charm' && + effectiveTheme !== 'dark' && + effectiveTheme !== 'dark-black' + ? 'invert(1) hue-rotate(180deg) brightness(1.1) contrast(1.05)' + : undefined + } + initialPrompt={ + providerMeta[provider]?.initialPromptFlag !== undefined && + !task.metadata?.initialInjectionSent + ? (initialInjection ?? undefined) + : undefined + } + className="h-full w-full" + /> + ) : null}
diff --git a/src/renderer/components/ChatTabs.tsx b/src/renderer/components/ChatTabs.tsx new file mode 100644 index 00000000..48105041 --- /dev/null +++ b/src/renderer/components/ChatTabs.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { X, Edit2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { providerConfig } from '../lib/providerConfig'; +import type { Provider } from '../types'; + +interface ChatTab { + id: string; + title: string; + provider?: string | null; + isActive: boolean; + messageCount?: number; +} + +interface ChatTabsProps { + tabs: ChatTab[]; + activeTabId: string | null; + onTabClick: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onRenameTab: (tabId: string, newTitle: string) => void; + onDuplicateTab?: (tabId: string) => void; +} + +export function ChatTabs({ + tabs, + activeTabId, + onTabClick, + onCloseTab, + onRenameTab, +}: ChatTabsProps) { + const [draggedTab, setDraggedTab] = useState(null); + + const handleDragStart = (e: React.DragEvent, tabId: string) => { + setDraggedTab(tabId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (e: React.DragEvent, targetTabId: string) => { + e.preventDefault(); + if (draggedTab && draggedTab !== targetTabId) { + // TODO: Implement reordering logic + // Will reorder ${draggedTab} to position of ${targetTabId} + } + setDraggedTab(null); + }; + + const handleRename = (tabId: string, currentTitle: string) => { + const newTitle = prompt('Rename chat:', currentTitle); + if (newTitle && newTitle.trim() && newTitle !== currentTitle) { + onRenameTab(tabId, newTitle.trim()); + } + }; + + return ( +
+ {tabs.map((tab) => { + const config = tab.provider ? providerConfig[tab.provider as Provider] : null; + return ( +
handleDragStart(e, tab.id)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, tab.id)} + className={cn( + 'group flex cursor-pointer items-center gap-2 rounded-md px-3 py-1.5', + 'min-w-[120px] max-w-[200px] flex-shrink-0 transition-colors', + 'hover:bg-muted/80', + tab.id === activeTabId && 'bg-primary text-primary-foreground hover:bg-primary/90' + )} + onClick={() => onTabClick(tab.id)} + > + {config && ( + + )} + + {tab.title} + + +
+ + + {tabs.length > 1 && ( + + )} +
+
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/CreateChatModal.tsx b/src/renderer/components/CreateChatModal.tsx new file mode 100644 index 00000000..3e33c036 --- /dev/null +++ b/src/renderer/components/CreateChatModal.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Label } from './ui/label'; +import { Separator } from './ui/separator'; +import { MultiProviderDropdown } from './MultiProviderDropdown'; +import type { Provider } from '../types'; +import type { ProviderRun } from '../types/chat'; +import type { Conversation } from '../../main/services/DatabaseService'; + +interface CreateChatModalProps { + isOpen: boolean; + onClose: () => void; + onCreateChat: (title: string, provider: string) => void; + installedProviders: string[]; + currentProvider?: string; + existingConversations?: Conversation[]; +} + +export function CreateChatModal({ + isOpen, + onClose, + onCreateChat, + installedProviders, + currentProvider, + existingConversations = [], +}: CreateChatModalProps) { + const [providerRuns, setProviderRuns] = useState([ + { provider: (currentProvider || 'claude') as Provider, runs: 1 }, + ]); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + // Extract providers that are already in use + const usedProviders = useMemo(() => { + const providers = new Set(); + existingConversations.forEach((conv) => { + if (conv.provider) { + providers.add(conv.provider); + } + }); + return providers; + }, [existingConversations]); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setError(null); + + // Find first available provider (installed but not already used) + const availableProviders = installedProviders.filter((p) => !usedProviders.has(p)); + + if (availableProviders.length > 0) { + // Prefer current provider if it's available, otherwise use first available + const defaultProvider = availableProviders.includes(currentProvider || '') + ? currentProvider + : availableProviders[0]; + setProviderRuns([{ provider: defaultProvider as Provider, runs: 1 }]); + } else { + // All providers are in use - this shouldn't normally happen but handle gracefully + setProviderRuns([]); + setError('All installed providers are already in use for this task'); + } + } + }, [isOpen, currentProvider, installedProviders, usedProviders]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (providerRuns.length === 0) { + setError('Please select a provider'); + return; + } + + setIsCreating(true); + try { + // For multi-chat, we only use single provider + const provider = providerRuns[0].provider; + // Simple title for internal use (not displayed in UI) + const chatTitle = `Chat ${Date.now()}`; + onCreateChat(chatTitle, provider); + onClose(); + + // Reset state + setError(null); + } catch (error) { + console.error('Failed to create chat:', error); + setError('Failed to create chat'); + } finally { + setIsCreating(false); + } + }; + + // Filter available providers to only installed and not already used + const defaultProvider = useMemo(() => { + const availableProviders = installedProviders.filter((p) => !usedProviders.has(p)); + if (currentProvider && availableProviders.includes(currentProvider)) { + return currentProvider as Provider; + } + return (availableProviders[0] || 'claude') as Provider; + }, [currentProvider, installedProviders, usedProviders]); + + return ( + !open && !isCreating && onClose()}> + + + New Chat + + Start a new conversation with a different AI provider + + + + + +
+
+ + +
+ {error &&

{error}

} + + + + +
+
+
+ ); +} diff --git a/src/renderer/components/DeleteChatModal.tsx b/src/renderer/components/DeleteChatModal.tsx new file mode 100644 index 00000000..950a2040 --- /dev/null +++ b/src/renderer/components/DeleteChatModal.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Trash2 } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './ui/alert-dialog'; + +interface DeleteChatModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onCancel?: () => void; +} + +export const DeleteChatModal: React.FC = ({ + open, + onOpenChange, + onConfirm, + onCancel, +}) => { + const handleCancel = () => { + onOpenChange(false); + onCancel?.(); + }; + + const handleConfirm = () => { + onOpenChange(false); + onConfirm(); + }; + + return ( + + + +
+ Delete Chat? +
+
+ +
+ + This will permanently delete this chat and all its messages. This action cannot be + undone. + +
+ + + Cancel + + + Delete Chat + + +
+
+ ); +}; + +export default DeleteChatModal; diff --git a/src/renderer/components/MultiProviderDropdown.tsx b/src/renderer/components/MultiProviderDropdown.tsx index 33d1387d..64cce57c 100644 --- a/src/renderer/components/MultiProviderDropdown.tsx +++ b/src/renderer/components/MultiProviderDropdown.tsx @@ -15,6 +15,7 @@ interface MultiProviderDropdownProps { onChange: (providerRuns: ProviderRun[]) => void; defaultProvider?: Provider; className?: string; + disabledProviders?: string[]; } export const MultiProviderDropdown: React.FC = ({ @@ -22,6 +23,7 @@ export const MultiProviderDropdown: React.FC = ({ onChange, defaultProvider = 'claude', className = '', + disabledProviders = [], }) => { // Sort providers with default provider first const sortedProviders = Object.entries(providerConfig).sort(([keyA], [keyB]) => { @@ -37,6 +39,9 @@ export const MultiProviderDropdown: React.FC = ({ // Checkbox: always add/remove (multi-select) const toggleProvider = (provider: Provider) => { + // Don't allow toggling disabled providers + if (disabledProviders.includes(provider)) return; + if (selectedProviders.has(provider)) { if (providerRuns.length > 1) { onChange(providerRuns.filter((pr) => pr.provider !== provider)); @@ -48,6 +53,9 @@ export const MultiProviderDropdown: React.FC = ({ // Row click: switch when single, add when multiple const handleRowClick = (provider: Provider) => { + // Don't allow selecting disabled providers + if (disabledProviders.includes(provider)) return; + if (selectedProviders.has(provider)) return; if (providerRuns.length === 1) { onChange([{ provider, runs: 1 }]); @@ -111,8 +119,9 @@ export const MultiProviderDropdown: React.FC = ({ const provider = key as Provider; const isSelected = selectedProviders.has(provider); const isLastSelected = isSelected && providerRuns.length === 1; + const isDisabled = disabledProviders.includes(provider); - return ( + return !isDisabled ? ( = ({ )}
+ ) : ( + /* Disabled providers with tooltip */ + + +
+
+ + {config.alt} + + {config.name} + (in use) + +
+
+
+ +

This provider already has an active chat in this task

+
+
); })} diff --git a/src/renderer/components/TerminalPane.tsx b/src/renderer/components/TerminalPane.tsx index a51aca77..a221c245 100644 --- a/src/renderer/components/TerminalPane.tsx +++ b/src/renderer/components/TerminalPane.tsx @@ -26,6 +26,7 @@ type Props = { keepAlive?: boolean; autoApprove?: boolean; initialPrompt?: string; + disableSnapshots?: boolean; // If true, don't save/restore terminal snapshots (for non-main chats) onActivity?: () => void; onStartError?: (message: string) => void; onStartSuccess?: () => void; @@ -48,6 +49,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( keepAlive = true, autoApprove, initialPrompt, + disableSnapshots = false, onActivity, onStartError, onStartSuccess, @@ -119,6 +121,7 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( theme, autoApprove, initialPrompt, + disableSnapshots, onLinkClick: handleLinkClick, }); sessionRef.current = session; diff --git a/src/renderer/lib/monacoDiffConfig.ts b/src/renderer/lib/monacoDiffConfig.ts index 2569397e..e387ccda 100644 --- a/src/renderer/lib/monacoDiffConfig.ts +++ b/src/renderer/lib/monacoDiffConfig.ts @@ -72,7 +72,6 @@ export function configureDiffEditorDiagnostics( } try { - // @ts-ignore - This API might not exist in older versions if (monaco.editor.setModelMarkers) { // Clear existing markers for this model monaco.editor.setModelMarkers(model, 'typescript', []); diff --git a/src/renderer/providers/BrowserProvider.tsx b/src/renderer/providers/BrowserProvider.tsx index 15d405eb..2081fc60 100644 --- a/src/renderer/providers/BrowserProvider.tsx +++ b/src/renderer/providers/BrowserProvider.tsx @@ -170,9 +170,7 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({ child return ( {children} - {/* Portal: inline consumer component owns the actual webview element */}
- {/* Hidden ref target used by BrowserPane to bind the underlying */} ); diff --git a/src/renderer/terminal/SessionRegistry.ts b/src/renderer/terminal/SessionRegistry.ts index 04737ebc..ecc35c5e 100644 --- a/src/renderer/terminal/SessionRegistry.ts +++ b/src/renderer/terminal/SessionRegistry.ts @@ -16,6 +16,7 @@ interface AttachOptions { theme: SessionTheme; autoApprove?: boolean; initialPrompt?: string; + disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -65,6 +66,7 @@ class SessionRegistry { telemetry: null, autoApprove: options.autoApprove, initialPrompt: options.initialPrompt, + disableSnapshots: options.disableSnapshots, onLinkClick: options.onLinkClick, }; diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 23430503..6329f1f8 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -32,6 +32,7 @@ export interface TerminalSessionOptions { telemetry?: { track: (event: string, payload?: Record) => void } | null; autoApprove?: boolean; initialPrompt?: string; + disableSnapshots?: boolean; onLinkClick?: (url: string) => void; } @@ -172,7 +173,10 @@ export class TerminalSessionManager { ); void this.restoreSnapshot().finally(() => this.connectPty()); - this.startSnapshotTimer(); + // Only start snapshot timer if snapshots are enabled (main chats only) + if (!this.options.disableSnapshots) { + this.startSnapshotTimer(); + } } attach(container: HTMLElement) { @@ -225,7 +229,10 @@ export class TerminalSessionManager { this.resizeObserver = null; ensureTerminalHost().appendChild(this.container); this.attachedContainer = null; - void this.captureSnapshot('detach'); + // Only capture snapshot on detach if snapshots are enabled + if (!this.options.disableSnapshots) { + void this.captureSnapshot('detach'); + } } } @@ -238,7 +245,10 @@ export class TerminalSessionManager { this.disposed = true; this.detach(); this.stopSnapshotTimer(); - void this.captureSnapshot('dispose'); + // Only capture final snapshot if snapshots are enabled + if (!this.options.disableSnapshots) { + void this.captureSnapshot('dispose'); + } // Clean up stored viewport position when session is disposed viewportPositions.delete(this.id); try { @@ -438,6 +448,11 @@ export class TerminalSessionManager { private connectPty() { const { taskId, cwd, shell, env, initialSize, autoApprove, initialPrompt } = this.options; const id = taskId; + + // Let the backend determine whether to skip resume based on session existence + // The backend will check if a Claude session directory exists + const skipResume = undefined; + void window.electronAPI .ptyStart({ id, @@ -448,6 +463,7 @@ export class TerminalSessionManager { rows: initialSize.rows, autoApprove, initialPrompt, + skipResume, }) .then((result) => { if (result?.ok) { @@ -512,11 +528,15 @@ export class TerminalSessionManager { /** * Check if this terminal ID is a provider CLI that supports native resume. - * Provider CLIs use the format: `${provider}-main-${taskId}` + * Provider CLIs use the format: + * - `${provider}-main-${taskId}` for main task terminals + * - `${provider}-chat-${conversationId}` for chat-specific terminals * If the provider has a resumeFlag, we skip snapshot restoration to avoid duplicate history. */ private isProviderWithResume(id: string): boolean { - const match = /^([a-z0-9_-]+)-main-(.+)$/.exec(id); + const mainMatch = /^([a-z0-9_-]+)-main-(.+)$/.exec(id); + const chatMatch = /^([a-z0-9_-]+)-chat-(.+)$/.exec(id); + const match = mainMatch || chatMatch; if (!match) return false; const providerId = match[1] as ProviderId; if (!PROVIDER_IDS.includes(providerId)) return false; @@ -527,6 +547,12 @@ export class TerminalSessionManager { private async restoreSnapshot(): Promise { if (!window.electronAPI.ptyGetSnapshot) return; + // Skip snapshot restoration for non-main chats + if (this.options.disableSnapshots) { + log.debug('terminalSession:skippingSnapshotForNonMainChat', { id: this.id }); + return; + } + // Skip snapshot restoration for providers with native resume capability // The CLI will handle resuming the conversation, so we don't want duplicate history if (this.isProviderWithResume(this.id)) { @@ -572,6 +598,8 @@ export class TerminalSessionManager { private captureSnapshot(reason: 'interval' | 'detach' | 'dispose'): Promise { if (!window.electronAPI.ptySaveSnapshot) return Promise.resolve(); if (this.disposed) return Promise.resolve(); + // Skip snapshots for non-main chats + if (this.options.disableSnapshots) return Promise.resolve(); if (reason === 'detach' && this.lastSnapshotReason === 'detach' && this.lastSnapshotAt) { const elapsed = Date.now() - this.lastSnapshotAt; if (elapsed < 1500) return Promise.resolve(); diff --git a/src/renderer/types/chat.ts b/src/renderer/types/chat.ts index 169b6ad6..e756b383 100644 --- a/src/renderer/types/chat.ts +++ b/src/renderer/types/chat.ts @@ -47,4 +47,7 @@ export interface Task { status: 'active' | 'idle' | 'running'; metadata?: TaskMetadata | null; useWorktree?: boolean; + createdAt?: string; + updatedAt?: string; + agentId?: string; } diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 56eb67d6..f7290506 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -749,7 +749,16 @@ declare global { deleteProject: (projectId: string) => Promise<{ success: boolean; error?: string }>; deleteTask: (taskId: string) => Promise<{ success: boolean; error?: string }>; - // Message operations + // Conversation and Message operations + saveConversation: (conversation: any) => Promise<{ success: boolean; error?: string }>; + getConversations: ( + taskId: string + ) => Promise<{ success: boolean; conversations?: any[]; error?: string }>; + deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; + cleanupSessionDirectory: (args: { + taskPath: string; + conversationId: string; + }) => Promise<{ success: boolean }>; saveMessage: (message: any) => Promise<{ success: boolean; error?: string }>; getMessages: ( conversationId: string @@ -758,6 +767,29 @@ declare global { taskId: string ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + // Multi-chat support + createConversation: (params: { + taskId: string; + title: string; + provider?: string; + isMain?: boolean; + }) => Promise<{ success: boolean; conversation?: any; error?: string }>; + setActiveConversation: (params: { + taskId: string; + conversationId: string; + }) => Promise<{ success: boolean; error?: string }>; + getActiveConversation: ( + taskId: string + ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + reorderConversations: (params: { + taskId: string; + conversationIds: string[]; + }) => Promise<{ success: boolean; error?: string }>; + updateConversationTitle: (params: { + conversationId: string; + title: string; + }) => Promise<{ success: boolean; error?: string }>; + // Debug helpers debugAppendLog: ( filePath: string,