From 9270e6d5f09d3091f6a2867da9b4632b7362521d Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:13:57 -0800 Subject: [PATCH 1/9] feat(wip): implement multi-chat conversations per task --- drizzle/0006_add_multi_chat_support.sql | 11 + drizzle/meta/_journal.json | 7 + package-lock.json | 20 +- package.json | 1 + src/main/db/schema.ts | 5 + src/main/ipc/dbIpc.ts | 66 ++++ src/main/preload.ts | 33 ++ src/main/services/DatabaseService.ts | 133 +++++++- src/main/services/ptyIpc.ts | 50 ++- src/renderer/App.tsx | 301 ++++++++--------- src/renderer/components/ChatInterface.tsx | 308 +++++++++++++++++- src/renderer/components/ChatTabs.tsx | 125 +++++++ src/renderer/components/CreateChatModal.tsx | 174 ++++++++++ src/renderer/lib/monacoDiffConfig.ts | 1 - .../terminal/TerminalSessionManager.ts | 14 +- src/renderer/types/chat.ts | 3 + src/renderer/types/electron-api.d.ts | 29 +- 17 files changed, 1109 insertions(+), 172 deletions(-) create mode 100644 drizzle/0006_add_multi_chat_support.sql create mode 100644 src/renderer/components/ChatTabs.tsx create mode 100644 src/renderer/components/CreateChatModal.tsx 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/meta/_journal.json b/drizzle/meta/_journal.json index 8a9fe378..93705ba0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "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 } ] } diff --git a/package-lock.json b/package-lock.json index 4ad44648..2d491f37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "emdash", - "version": "0.3.50", + "version": "0.3.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "emdash", - "version": "0.3.50", + "version": "0.3.48", "hasInstallScript": true, "dependencies": { "@monaco-editor/react": "^4.7.0", @@ -71,6 +71,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", @@ -5587,6 +5588,21 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz", + "integrity": "sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==", + "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 4c03025f..1997e9c8 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..92a5e526 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -58,6 +58,10 @@ 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 + 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 +71,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..1b437818 100644 --- a/src/main/ipc/dbIpc.ts +++ b/src/main/ipc/dbIpc.ts @@ -120,4 +120,70 @@ export function registerDatabaseIpc() { return { success: false, error: (error as Error).message }; } }); + + // Multi-chat support handlers + ipcMain.handle( + 'db:createConversation', + async ( + _, + { taskId, title, provider }: { taskId: string; title: string; provider?: string } + ) => { + try { + const conversation = await databaseService.createConversation(taskId, title, provider); + 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 e9f9f2e9..a1e55b75 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -312,6 +312,17 @@ contextBridge.exposeInMainWorld('electronAPI', { deleteConversation: (conversationId: string) => ipcRenderer.invoke('db:deleteConversation', conversationId), + // 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), lineCommentsGet: (args: { taskId: string; filePath?: string }) => @@ -634,6 +645,28 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; messages?: any[]; error?: string }>; deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>; + // 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: { taskId: string; diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index a51d18cd..d8ee62c2 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -53,6 +53,10 @@ export interface Conversation { id: string; taskId: string; title: string; + provider?: string | null; + isActive?: boolean; + displayOrder?: number; + metadata?: string | null; createdAt: string; updatedAt: string; } @@ -308,12 +312,20 @@ export class DatabaseService { id: conversation.id, taskId: conversation.taskId, title: conversation.title, + provider: conversation.provider ?? null, + isActive: conversation.isActive ? 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, + displayOrder: conversation.displayOrder ?? 0, + metadata: conversation.metadata ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, }, }); @@ -326,7 +338,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)); } @@ -428,6 +440,121 @@ export class DatabaseService { await db.delete(conversationsTable).where(eq(conversationsTable.id, conversationId)); } + // New multi-chat methods + async createConversation( + taskId: string, + title: string, + provider?: string + ): Promise { + if (this.disabled) { + return { + id: `conv-${taskId}-${Date.now()}`, + taskId, + title, + provider: provider ?? null, + isActive: true, + 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); + + // 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, + 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 @@ -609,6 +736,10 @@ export class DatabaseService { id: row.id, taskId: row.taskId, title: row.title, + provider: row.provider ?? null, + isActive: row.isActive === 1, + 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 27fa63be..a31b70ab 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -7,6 +7,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(); @@ -43,16 +44,36 @@ export function registerPtyIpc(): void { 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 }); + // For chat terminals, check if conversation has messages + // For main terminals, check if snapshot exists + const isChatTerminal = id.includes('-chat-'); + + if (isChatTerminal) { + // For chat terminals, check if the conversation has any messages + // If it's a brand new chat, skip the resume flag + try { + const conversationId = parsed.taskId; // For chat terminals, taskId is actually conversationId + const messages = await databaseService.getMessages(conversationId); + if (!messages || messages.length === 0) { + log.info('ptyIpc:newChat - skipping resume flag for new conversation', { id }); + shouldSkipResume = true; + } + } catch (err) { + log.warn('ptyIpc:messageCheckFailed - skipping resume', { id, error: err }); + shouldSkipResume = true; + } + } else { + // For main terminals, check if snapshot exists + try { + const snapshot = await terminalSnapshotService.getSnapshot(id); + if (!snapshot || !snapshot.data) { + log.info('ptyIpc:noSnapshot - skipping resume flag', { id }); + shouldSkipResume = true; + } + } catch (err) { + log.warn('ptyIpc:snapshotCheckFailed - skipping resume', { id, error: err }); shouldSkipResume = true; } - } catch (err) { - log.warn('ptyIpc:snapshotCheckFailed - skipping resume', { id, error: err }); - shouldSkipResume = true; } } } @@ -198,12 +219,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/renderer/App.tsx b/src/renderer/App.tsx index 1f24bdf8..2a4b714a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1331,185 +1331,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 5293b559..e25ea7d9 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -1,10 +1,12 @@ import React, { useEffect, useState, useMemo, useCallback } 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'; @@ -16,6 +18,8 @@ import { getInstallCommandForProvider } from '@shared/providers/registry'; import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch'; import { terminalSessionRegistry } from '../terminal/SessionRegistry'; import { TaskScopeProvider } from './TaskScopeContext'; +import { CreateChatModal } from './CreateChatModal'; +import { type Conversation } from '../../main/services/DatabaseService'; declare const window: Window & { electronAPI: { @@ -47,7 +51,23 @@ const ChatInterface: React.FC = ({ 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 [showCreateChatModal, setShowCreateChatModal] = useState(false); + const [installedProviders, setInstalledProviders] = useState([]); + + // Update terminal ID to include conversation ID and provider - unique per conversation + const terminalId = useMemo(() => { + if (activeConversationId) { + // Include provider in the ID so the backend can determine the CLI to use + // Format: ${provider}-chat-${conversationId} + return `${provider}-chat-${activeConversationId}`; + } + return `${provider}-main-${task.id}`; + }, [activeConversationId, provider, task.id]); + const { activeTerminalId } = useTaskTerminals(task.id, task.path); // Line comments for agent context injection @@ -56,6 +76,65 @@ 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 () => { + 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, + }); + } + } 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) { + // Update the default conversation to have the current provider + const conversationWithProvider = { + ...defaultResult.conversation, + provider: provider, + }; + setConversations([conversationWithProvider]); + setActiveConversationId(defaultResult.conversation.id); + + // Save the provider to the conversation + await window.electronAPI.saveConversation(conversationWithProvider); + } + } + }; + + loadConversations(); + }, [task.id]); + + // Track installed providers + useEffect(() => { + const installed = Object.entries(providerStatuses) + .filter(([_, status]) => status.installed === true) + .map(([id]) => id); + setInstalledProviders(installed); + }, [providerStatuses]); + // Auto-focus terminal when switching to this task useEffect(() => { // Small delay to ensure terminal is mounted and attached @@ -188,6 +267,141 @@ const ChatInterface: React.FC = ({ } }, [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, + }); + + 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( + async (conversationId: string) => { + if (conversations.length <= 1) { + toast({ + title: 'Cannot Close', + description: 'Cannot close the last chat', + variant: 'destructive', + }); + return; + } + + const confirm = window.confirm( + 'Delete this chat and all its messages? This action cannot be undone.' + ); + if (!confirm) return; + + // Only dispose the terminal when actually deleting the chat + // Find the conversation to get its provider + const convToDelete = conversations.find((c) => c.id === conversationId); + const convProvider = convToDelete?.provider || provider; + const terminalToDispose = `${convProvider}-chat-${conversationId}`; + terminalSessionRegistry.dispose(terminalToDispose); + + await window.electronAPI.deleteConversation(conversationId); + + // 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 ( + conversationId === 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); + } + } + } + }, + [task.id, conversations, activeConversationId, toast, provider] + ); + + const handleRenameChat = useCallback( + async (conversationId: string, newTitle: string) => { + await window.electronAPI.updateConversationTitle({ + conversationId, + title: newTitle, + }); + + // Reload conversations + const result = await window.electronAPI.getConversations(task.id); + if (result.success) { + setConversations(result.conversations || []); + } + }, + [task.id] + ); + // Persist last-selected provider per task (including Droid) useEffect(() => { try { @@ -468,17 +682,94 @@ const ChatInterface: React.FC = ({
+ {/* Create Chat Modal */} + setShowCreateChatModal(false)} + onCreateChat={handleCreateChat} + installedProviders={installedProviders} + currentProvider={provider} + taskName={task.name} + /> +
- +
+ {/* Show all chats as tabs - initial chat on left, new ones to the right */} + {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) => { + const isActive = conv.id === activeConversationId; + const convProvider = conv.provider || provider; + const config = providerConfig[convProvider as Provider]; + const providerName = config?.name || convProvider; + + return ( + + )} + + ); + })} + + + + {/* Show issue badges separately */} + {(task.metadata?.linearIssue || + task.metadata?.githubIssue || + task.metadata?.jiraIssue) && ( + + )} +
{autoApproveEnabled && (
@@ -530,6 +821,7 @@ const ChatInterface: React.FC = ({ : '' }`} > + {/* Always render TerminalPane since we always have at least one conversation */} void; + onCloseTab: (tabId: string) => void; + onRenameTab: (tabId: string, newTitle: string) => void; + onDuplicateTab?: (tabId: string) => void; +} + +export function ChatTabs({ + tabs, + activeTabId, + onTabClick, + onCloseTab, + onRenameTab, + onDuplicateTab, +}: 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 ( +
+ {/* Render existing tabs */} + {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} + + +
+ {/* Rename button */} + + + {/* Close button - only show if not the last tab */} + {tabs.length > 1 && ( + + )} +
+
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/CreateChatModal.tsx b/src/renderer/components/CreateChatModal.tsx new file mode 100644 index 00000000..1fc936d1 --- /dev/null +++ b/src/renderer/components/CreateChatModal.tsx @@ -0,0 +1,174 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { SlugInput } from './ui/slug-input'; +import { Label } from './ui/label'; +import { Separator } from './ui/separator'; +import { MultiProviderDropdown } from './MultiProviderDropdown'; +import { providerConfig } from '../lib/providerConfig'; +import type { Provider } from '../types'; +import type { ProviderRun } from '../types/chat'; + +interface CreateChatModalProps { + isOpen: boolean; + onClose: () => void; + onCreateChat: (title: string, provider: string) => void; + installedProviders: string[]; + currentProvider?: string; + taskName?: string; +} + +export function CreateChatModal({ + isOpen, + onClose, + onCreateChat, + installedProviders, + currentProvider, + taskName = 'Task', +}: CreateChatModalProps) { + const [chatName, setChatName] = useState(''); + const [providerRuns, setProviderRuns] = useState([ + { provider: (currentProvider || 'claude') as Provider, runs: 1 }, + ]); + const [isCreating, setIsCreating] = useState(false); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(null); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + // Generate a simple default name + const date = new Date() + .toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + .toLowerCase() + .replace(' ', '-'); + setChatName(`chat-${date}`); + setTouched(false); + setError(null); + + // Set default provider + if (currentProvider && installedProviders.includes(currentProvider)) { + setProviderRuns([{ provider: currentProvider as Provider, runs: 1 }]); + } else if (installedProviders.length > 0) { + const firstInstalled = installedProviders[0]; + setProviderRuns([{ provider: firstInstalled as Provider, runs: 1 }]); + } + } + }, [isOpen, currentProvider, installedProviders]); + + const validate = (name: string): string | null => { + if (!name.trim()) { + return 'Chat name is required'; + } + if (name.length < 2) { + return 'Chat name must be at least 2 characters'; + } + return null; + }; + + const handleNameChange = (value: string) => { + setChatName(value); + if (touched) { + setError(validate(value)); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setTouched(true); + + const err = validate(chatName); + if (err) { + setError(err); + return; + } + + 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; + onCreateChat(chatName, provider); + onClose(); + + // Reset state + setChatName(''); + setTouched(false); + 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 ones + const defaultProvider = useMemo(() => { + if (currentProvider && installedProviders.includes(currentProvider)) { + return currentProvider as Provider; + } + return (installedProviders[0] || 'claude') as Provider; + }, [currentProvider, installedProviders]); + + return ( + !open && !isCreating && onClose()}> + + + New Chat + + {taskName} • Create a new conversation + + + + + +
+
+ + setTouched(true)} + placeholder="bug-fix-auth" + maxLength={64} + className={`w-full ${touched && error ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive' : ''}`} + aria-invalid={touched && !!error} + autoFocus + /> + {touched && error &&

{error}

} +
+ +
+ + +
+ + + + +
+
+
+ ); +} 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/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 71861351..4e0c54ea 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -415,6 +415,11 @@ export class TerminalSessionManager { private connectPty() { const { taskId, cwd, shell, env, initialSize, autoApprove, initialPrompt } = this.options; const id = taskId; + + // Don't automatically skip resume for chat terminals + // Let the backend decide based on whether conversation history exists + const skipResume = undefined; + void window.electronAPI .ptyStart({ id, @@ -425,6 +430,7 @@ export class TerminalSessionManager { rows: initialSize.rows, autoApprove, initialPrompt, + skipResume, }) .then((result) => { if (result?.ok) { @@ -489,11 +495,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; diff --git a/src/renderer/types/chat.ts b/src/renderer/types/chat.ts index e8a77e46..d5f81a07 100644 --- a/src/renderer/types/chat.ts +++ b/src/renderer/types/chat.ts @@ -47,6 +47,9 @@ export interface Task { status: 'active' | 'idle' | 'running'; metadata?: TaskMetadata | null; useWorktree?: boolean; + createdAt?: string; + updatedAt?: string; + agentId?: string; } export interface Message { diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index ae820fdd..b8dd114a 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -739,7 +739,12 @@ 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 }>; saveMessage: (message: any) => Promise<{ success: boolean; error?: string }>; getMessages: ( conversationId: string @@ -748,6 +753,28 @@ declare global { taskId: string ) => Promise<{ success: boolean; conversation?: any; error?: string }>; + // 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 }>; + // Debug helpers debugAppendLog: ( filePath: string, From 993adcaebed0510ed66e1196d1fd7d69099a8756 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:49:27 -0800 Subject: [PATCH 2/9] ui: remove naming field in create chat modal --- src/renderer/components/ChatInterface.tsx | 2 - src/renderer/components/CreateChatModal.tsx | 67 +++------------------ 2 files changed, 7 insertions(+), 62 deletions(-) diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 39e9d835..ae0f49ba 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -693,14 +693,12 @@ const ChatInterface: React.FC = ({
- {/* Create Chat Modal */} setShowCreateChatModal(false)} onCreateChat={handleCreateChat} installedProviders={installedProviders} currentProvider={provider} - taskName={task.name} />
diff --git a/src/renderer/components/CreateChatModal.tsx b/src/renderer/components/CreateChatModal.tsx index 1fc936d1..0cac28fd 100644 --- a/src/renderer/components/CreateChatModal.tsx +++ b/src/renderer/components/CreateChatModal.tsx @@ -8,11 +8,9 @@ import { DialogTitle, } from './ui/dialog'; import { Button } from './ui/button'; -import { SlugInput } from './ui/slug-input'; import { Label } from './ui/label'; import { Separator } from './ui/separator'; import { MultiProviderDropdown } from './MultiProviderDropdown'; -import { providerConfig } from '../lib/providerConfig'; import type { Provider } from '../types'; import type { ProviderRun } from '../types/chat'; @@ -22,7 +20,6 @@ interface CreateChatModalProps { onCreateChat: (title: string, provider: string) => void; installedProviders: string[]; currentProvider?: string; - taskName?: string; } export function CreateChatModal({ @@ -31,26 +28,16 @@ export function CreateChatModal({ onCreateChat, installedProviders, currentProvider, - taskName = 'Task', }: CreateChatModalProps) { - const [chatName, setChatName] = useState(''); const [providerRuns, setProviderRuns] = useState([ { provider: (currentProvider || 'claude') as Provider, runs: 1 }, ]); const [isCreating, setIsCreating] = useState(false); - const [touched, setTouched] = useState(false); const [error, setError] = useState(null); // Reset state when modal opens useEffect(() => { if (isOpen) { - // Generate a simple default name - const date = new Date() - .toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - .toLowerCase() - .replace(' ', '-'); - setChatName(`chat-${date}`); - setTouched(false); setError(null); // Set default provider @@ -63,32 +50,8 @@ export function CreateChatModal({ } }, [isOpen, currentProvider, installedProviders]); - const validate = (name: string): string | null => { - if (!name.trim()) { - return 'Chat name is required'; - } - if (name.length < 2) { - return 'Chat name must be at least 2 characters'; - } - return null; - }; - - const handleNameChange = (value: string) => { - setChatName(value); - if (touched) { - setError(validate(value)); - } - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setTouched(true); - - const err = validate(chatName); - if (err) { - setError(err); - return; - } if (providerRuns.length === 0) { setError('Please select a provider'); @@ -99,12 +62,13 @@ export function CreateChatModal({ try { // For multi-chat, we only use single provider const provider = providerRuns[0].provider; - onCreateChat(chatName, provider); + // Generate a simple auto-incremented title + const timestamp = Date.now(); + const chatTitle = `Chat ${timestamp}`; + onCreateChat(chatTitle, provider); onClose(); // Reset state - setChatName(''); - setTouched(false); setError(null); } catch (error) { console.error('Failed to create chat:', error); @@ -128,39 +92,22 @@ export function CreateChatModal({ New Chat - {taskName} • Create a new conversation + Start a new conversation with a different AI provider
-
- - setTouched(true)} - placeholder="bug-fix-auth" - maxLength={64} - className={`w-full ${touched && error ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive' : ''}`} - aria-invalid={touched && !!error} - autoFocus - /> - {touched && error &&

{error}

} -
-
- +
+ {error &&

{error}

} - {/* Show issue badges separately */} {(task.metadata?.linearIssue || task.metadata?.githubIssue || task.metadata?.jiraIssue) && ( @@ -830,85 +874,90 @@ const ChatInterface: React.FC = ({ : '' }`} > - {/* Always render TerminalPane since we always have at least one conversation */} - { - 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/CreateChatModal.tsx b/src/renderer/components/CreateChatModal.tsx index 0cac28fd..3e33c036 100644 --- a/src/renderer/components/CreateChatModal.tsx +++ b/src/renderer/components/CreateChatModal.tsx @@ -13,6 +13,7 @@ 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; @@ -20,6 +21,7 @@ interface CreateChatModalProps { onCreateChat: (title: string, provider: string) => void; installedProviders: string[]; currentProvider?: string; + existingConversations?: Conversation[]; } export function CreateChatModal({ @@ -28,6 +30,7 @@ export function CreateChatModal({ onCreateChat, installedProviders, currentProvider, + existingConversations = [], }: CreateChatModalProps) { const [providerRuns, setProviderRuns] = useState([ { provider: (currentProvider || 'claude') as Provider, runs: 1 }, @@ -35,20 +38,38 @@ export function CreateChatModal({ 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); - // Set default provider - if (currentProvider && installedProviders.includes(currentProvider)) { - setProviderRuns([{ provider: currentProvider as Provider, runs: 1 }]); - } else if (installedProviders.length > 0) { - const firstInstalled = installedProviders[0]; - setProviderRuns([{ provider: firstInstalled as Provider, runs: 1 }]); + // 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]); + }, [isOpen, currentProvider, installedProviders, usedProviders]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -62,9 +83,8 @@ export function CreateChatModal({ try { // For multi-chat, we only use single provider const provider = providerRuns[0].provider; - // Generate a simple auto-incremented title - const timestamp = Date.now(); - const chatTitle = `Chat ${timestamp}`; + // Simple title for internal use (not displayed in UI) + const chatTitle = `Chat ${Date.now()}`; onCreateChat(chatTitle, provider); onClose(); @@ -78,13 +98,14 @@ export function CreateChatModal({ } }; - // Filter available providers to only installed ones + // Filter available providers to only installed and not already used const defaultProvider = useMemo(() => { - if (currentProvider && installedProviders.includes(currentProvider)) { + const availableProviders = installedProviders.filter((p) => !usedProviders.has(p)); + if (currentProvider && availableProviders.includes(currentProvider)) { return currentProvider as Provider; } - return (installedProviders[0] || 'claude') as Provider; - }, [currentProvider, installedProviders]); + return (availableProviders[0] || 'claude') as Provider; + }, [currentProvider, installedProviders, usedProviders]); return ( !open && !isCreating && onClose()}> @@ -105,6 +126,7 @@ export function CreateChatModal({ providerRuns={providerRuns} onChange={setProviderRuns} defaultProvider={defaultProvider} + disabledProviders={Array.from(usedProviders)} />
{error &&

{error}

} diff --git a/src/renderer/components/DeleteChatModal.tsx b/src/renderer/components/DeleteChatModal.tsx index d07ee3e7..950a2040 100644 --- a/src/renderer/components/DeleteChatModal.tsx +++ b/src/renderer/components/DeleteChatModal.tsx @@ -65,4 +65,4 @@ export const DeleteChatModal: React.FC = ({ ); }; -export default DeleteChatModal; \ No newline at end of file +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/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 acb00f0a..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 { @@ -439,8 +449,8 @@ export class TerminalSessionManager { const { taskId, cwd, shell, env, initialSize, autoApprove, initialPrompt } = this.options; const id = taskId; - // Don't automatically skip resume for chat terminals - // Let the backend decide based on whether conversation history exists + // 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 @@ -537,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)) { @@ -582,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/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index a1b797e2..f7290506 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -755,6 +755,10 @@ declare global { 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 @@ -768,6 +772,7 @@ declare global { taskId: string; title: string; provider?: string; + isMain?: boolean; }) => Promise<{ success: boolean; conversation?: any; error?: string }>; setActiveConversation: (params: { taskId: string; From 6d491716666057528100328aa9f152b206b3e46c Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:22:47 -0800 Subject: [PATCH 5/9] chore: apply code formatting --- src/renderer/components/ChatInterface.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 3a2c0220..644e99b5 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -434,7 +434,6 @@ const ChatInterface: React.FC = ({ setShowDeleteChatModal(false); }, [chatToDelete, conversations, provider, task.id, activeConversationId]); - // Persist last-selected provider per task (including Droid) useEffect(() => { try { From dfb558e61ce947ae9f859f4bfe298c005dc957b2 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:23:31 -0800 Subject: [PATCH 6/9] chore: apply code formatting --- src/renderer/components/ChatTabs.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/renderer/components/ChatTabs.tsx b/src/renderer/components/ChatTabs.tsx index ee32b05a..48105041 100644 --- a/src/renderer/components/ChatTabs.tsx +++ b/src/renderer/components/ChatTabs.tsx @@ -27,7 +27,6 @@ export function ChatTabs({ onTabClick, onCloseTab, onRenameTab, - onDuplicateTab, }: ChatTabsProps) { const [draggedTab, setDraggedTab] = useState(null); @@ -59,7 +58,6 @@ export function ChatTabs({ return (
- {/* Render existing tabs */} {tabs.map((tab) => { const config = tab.provider ? providerConfig[tab.provider as Provider] : null; return ( @@ -91,7 +89,6 @@ export function ChatTabs({
- {/* Rename button */} - {/* Close button - only show if not the last tab */} {tabs.length > 1 && (