From e203f2099bb024e1865233abbce77c40627059f0 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Fri, 23 Jan 2026 19:20:29 +0800 Subject: [PATCH] feat: auto-generate session titles and support manual title editing (#115) - Add TitleGenerator module to generate session titles using Agent CLI - Support claude-code, opencode, and codex agents - Fire-and-forget async generation on first user message - Prevent concurrent generation with in-flight tracking - Re-check before update to avoid overwriting manual titles - Add double-click to edit session title in sidebar - Inline editing with Enter to save, Escape to cancel - Blur saves by default, but respects Escape cancellation - Persist title changes to database - Add SESSION_UPDATE IPC channel for title updates - Add SESSION_META_UPDATED event for real-time UI sync - Add comprehensive unit tests for new functionality Co-authored-by: Claude Opus 4.5 --- src/main/conductor/TitleGenerator.ts | 216 ++++++++++++++ src/main/conductor/index.ts | 1 + src/main/index.ts | 2 +- src/main/ipc/handlers.ts | 82 +++++- src/main/session/SessionStore.ts | 7 + src/renderer/src/App.tsx | 4 +- src/renderer/src/components/AppSidebar.tsx | 5 +- src/renderer/src/components/ProjectItem.tsx | 152 +++++++--- src/renderer/src/hooks/useApp.ts | 55 +++- tests/integration/ipc/handlers.test.ts | 32 ++- .../main/conductor/TitleGenerator.test.ts | 263 ++++++++++++++++++ tests/unit/main/ipc/handlers.test.ts | 116 ++++++++ tests/unit/main/utils/pathValidation.test.ts | 8 - .../renderer/components/ProjectItem.test.tsx | 182 ++++++++++++ tests/unit/renderer/hooks/useApp.test.tsx | 134 ++++++++- 15 files changed, 1198 insertions(+), 61 deletions(-) create mode 100644 src/main/conductor/TitleGenerator.ts create mode 100644 tests/unit/main/conductor/TitleGenerator.test.ts create mode 100644 tests/unit/main/ipc/handlers.test.ts create mode 100644 tests/unit/renderer/components/ProjectItem.test.tsx diff --git a/src/main/conductor/TitleGenerator.ts b/src/main/conductor/TitleGenerator.ts new file mode 100644 index 000000000..d7d17b8f5 --- /dev/null +++ b/src/main/conductor/TitleGenerator.ts @@ -0,0 +1,216 @@ +/** + * TitleGenerator - Generates session titles using Agent CLI + * + * Uses the same Agent that the session is using, but in a separate CLI invocation + * to avoid polluting the session's context. + */ +import { spawn } from 'node:child_process' +import { getEnhancedPath } from '../utils/path' + +/** Timeout for CLI execution (60 seconds) */ +const CLI_TIMEOUT_MS = 60000 + +/** + * Command configuration for each supported Agent + */ +interface AgentCommand { + command: string + args: string[] + parseOutput: (stdout: string) => string +} + +/** + * Build the title generation prompt + */ +function buildPrompt(userMessage: string): string { + return `Generate a short title (3-6 words) for the user message below. Chinese: max 6 Chinese characters. Output ONLY the title, nothing else. + + +${userMessage} +` +} + +/** + * Build CLI command for the specified agent + */ +function buildAgentCommand(agentId: string, prompt: string): AgentCommand | null { + switch (agentId) { + case 'claude-code': + return { + command: 'claude', + args: [ + '-p', + prompt, + '--output-format', + 'text', + '--tools', + '', + '--permission-mode', + 'dontAsk', + '--no-session-persistence' + ], + parseOutput: (stdout: string) => stdout.trim() + } + + case 'opencode': + return { + command: 'opencode', + args: ['run', prompt, '-m', 'opencode/gpt-5-nano', '--agent', 'title', '--format', 'json'], + parseOutput: (stdout: string) => { + // Parse JSON event stream, find the last text event + const lines = stdout.split('\n').filter(Boolean) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]) + if (event.type === 'text' && event.part?.text) { + return event.part.text.trim() + } + } catch { + // Skip invalid JSON lines + } + } + return '' + } + } + + case 'codex': + return { + command: 'codex', + args: ['exec', prompt, '--sandbox', 'read-only', '--json'], + parseOutput: (stdout: string) => { + // Parse JSON event stream, find the last agent_message + const lines = stdout.split('\n').filter(Boolean) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const event = JSON.parse(lines[i]) + if (event.type === 'item.completed' && event.item?.type === 'agent_message') { + return event.item.text.trim() + } + } catch { + // Skip invalid JSON lines + } + } + return '' + } + } + + default: + return null + } +} + +interface CommandResult { + stdout: string + stderr: string +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + let settled = false + let stdout = '' + let stderr = '' + + const child = spawn(command, args, { + env: { ...process.env, PATH: getEnhancedPath() }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + const timeoutId = setTimeout(() => { + if (settled) { + return + } + settled = true + child.kill('SIGTERM') + const error = new Error('Command timed out') as NodeJS.ErrnoException & { stderr?: string } + error.code = 'ETIMEDOUT' + error.stderr = stderr + reject(error) + }, CLI_TIMEOUT_MS) + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString() + }) + + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + + child.on('error', (error) => { + if (settled) { + return + } + settled = true + clearTimeout(timeoutId) + ;(error as NodeJS.ErrnoException & { stderr?: string }).stderr = stderr + reject(error) + }) + + child.on('close', (code, signal) => { + if (settled) { + return + } + settled = true + clearTimeout(timeoutId) + if (code === 0 && !signal) { + resolve({ stdout, stderr }) + return + } + const error = new Error('Command failed') as NodeJS.ErrnoException & { + stderr?: string + signal?: NodeJS.Signals | null + } + error.code = code === null ? undefined : String(code) + error.signal = signal + error.stderr = stderr + reject(error) + }) + }) +} + +/** + * Generate a session title using the Agent CLI + * + * @param agentId - The agent ID (e.g., 'claude-code', 'opencode', 'codex') + * @param userMessage - The first user message in the session + * @returns Generated title, or null if generation fails (title should not be changed) + */ +export async function generateSessionTitle( + agentId: string, + userMessage: string +): Promise { + const prompt = buildPrompt(userMessage) + const agentCommand = buildAgentCommand(agentId, prompt) + + if (!agentCommand) { + console.log(`[TitleGenerator] Unknown agent: ${agentId}, skipping title generation`) + return null + } + + const { command, args, parseOutput } = agentCommand + + console.log(`[TitleGenerator] Generating title for session using ${agentId}`) + console.log(`[TitleGenerator] Command: ${command} ${args[0]} ...`) + + try { + const { stdout } = await runCommand(command, args) + + const title = parseOutput(stdout) + + if (title) { + console.log(`[TitleGenerator] Generated title: "${title}"`) + return title + } + + console.log(`[TitleGenerator] Empty output, skipping title update`) + return null + } catch (error) { + if (error && typeof error === 'object') { + const stderr = 'stderr' in error ? (error.stderr as string | undefined) : undefined + if (stderr) { + console.error(`[TitleGenerator] CLI stderr: ${stderr}`) + } + } + console.error(`[TitleGenerator] CLI execution failed:`, error) + return null + } +} diff --git a/src/main/conductor/index.ts b/src/main/conductor/index.ts index e9d6fcaba..9567cfefc 100644 --- a/src/main/conductor/index.ts +++ b/src/main/conductor/index.ts @@ -12,6 +12,7 @@ export { SessionLifecycle } from './SessionLifecycle' export { AgentProcess } from './AgentProcess' export { createAcpClient } from './AcpClientFactory' export type { AcpClientCallbacks, AcpClientFactoryOptions } from './AcpClientFactory' +export { generateSessionTitle } from './TitleGenerator' // Types export type { diff --git a/src/main/index.ts b/src/main/index.ts index 22ed4fa71..46689c6c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -139,7 +139,7 @@ app.whenReady().then(async () => { }) // Register IPC handlers - registerIPCHandlers(conductor, fileWatcher) + registerIPCHandlers(conductor, fileWatcher, () => mainWindow) mainWindow = createWindow() diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d1734bae7..d20b67dbb 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -2,11 +2,12 @@ * IPC handlers for main process * Registers all IPC handlers for communication with renderer process */ -import { ipcMain, dialog, clipboard, shell } from 'electron' +import { ipcMain, dialog, clipboard, shell, type BrowserWindow } from 'electron' import { IPC_CHANNELS } from '../../shared/ipc-channels' import { DEFAULT_AGENTS } from '../config/defaults' import { checkAgents, checkAgent, checkAgentVersions } from '../utils/agent-check' import { installAgent, updateCommand } from '../utils/agent-install' +import { generateSessionTitle } from '../conductor/TitleGenerator' import type { Conductor } from '../conductor/Conductor' import type { FileWatcher } from '../watcher' import type { @@ -91,13 +92,90 @@ function extractErrorMessage(err: unknown): string { return String(err) } -export function registerIPCHandlers(conductor: Conductor, fileWatcher: FileWatcher): void { +/** + * Generate session title on first user message (fire-and-forget) + * Only triggers once per session - when session has no title yet + */ +const titleGenerationInFlight = new Set() + +function maybeGenerateSessionTitle( + sessionId: string, + content: MessageContent, + conductor: Conductor, + getMainWindow: () => BrowserWindow | null +): void { + // Extract text from message content + const textItem = content.find((c): c is { type: 'text'; text: string } => c.type === 'text') + if (!textItem) { + return + } + + if (titleGenerationInFlight.has(sessionId)) { + return + } + + titleGenerationInFlight.add(sessionId) + + // Run asynchronously (fire-and-forget) + ;(async () => { + try { + // Check if session already has a title + const sessionData = await conductor.getSessionData(sessionId) + if (!sessionData || sessionData.session.title) { + return // Already has a title or session not found + } + + const agentId = sessionData.session.agentId + console.log(`[IPC] Session ${sessionId} needs a title, generating with ${agentId}...`) + + // Generate title using the same agent CLI + const title = await generateSessionTitle(agentId, textItem.text) + + // If title generation failed, don't update (keep title unchanged) + if (!title) { + console.log(`[IPC] Session ${sessionId} title generation failed, keeping unchanged`) + return + } + + const latestSessionData = await conductor.getSessionData(sessionId) + if (!latestSessionData || latestSessionData.session.title) { + console.log(`[IPC] Session ${sessionId} already has a title, skipping update`) + return + } + + // Update session metadata + const updatedSession = await conductor.updateSessionMeta(sessionId, { title }) + + console.log(`[IPC] Session ${sessionId} title updated to: "${title}"`) + + // Notify frontend about the title update + const mainWindow = getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.SESSION_META_UPDATED, updatedSession) + } + } catch (error) { + // Log error but don't fail - title generation is best-effort + console.error(`[IPC] Failed to generate title for session ${sessionId}:`, error) + } finally { + titleGenerationInFlight.delete(sessionId) + } + })() +} + +export function registerIPCHandlers( + conductor: Conductor, + fileWatcher: FileWatcher, + getMainWindow: () => BrowserWindow | null +): void { // --- Agent handlers (per-session) --- ipcMain.handle( IPC_CHANNELS.AGENT_PROMPT, async (_event, sessionId: string, content: MessageContent) => { try { + // Trigger title generation on first user message (fire-and-forget) + maybeGenerateSessionTitle(sessionId, content, conductor, getMainWindow) + const stopReason = await conductor.sendPrompt(sessionId, content) return { stopReason } } catch (err) { diff --git a/src/main/session/SessionStore.ts b/src/main/session/SessionStore.ts index 9ae0606ca..7ecd0480f 100644 --- a/src/main/session/SessionStore.ts +++ b/src/main/session/SessionStore.ts @@ -253,10 +253,17 @@ export class SessionStore { // Update index this.sessionsIndex.set(sessionId, sessionData.session) + // Ensure session data is in loadedSessions cache for saveSessionData + this.loadedSessions.set(sessionId, sessionData) + // Persist await this.saveSessionData(sessionId) await this.saveIndex() + console.log( + `[SessionStore] Updated session meta: ${sessionId}, title: ${sessionData.session.title}` + ) + return sessionData.session } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 26a88dc7c..f367da84e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -47,7 +47,8 @@ function AppContent(): React.JSX.Element { cancelRequest, switchSessionAgent, setSessionMode, - setSessionModel + setSessionModel, + updateSessionTitle } = useApp() // UI state @@ -178,6 +179,7 @@ function AppContent(): React.JSX.Element { onNewSession={handleNewSessionInProject} onToggleProjectExpanded={toggleProjectExpanded} onReorderProjects={reorderProjects} + onUpdateSessionTitle={updateSessionTitle} /> {/* Main area */} diff --git a/src/renderer/src/components/AppSidebar.tsx b/src/renderer/src/components/AppSidebar.tsx index 237c39641..78b7f5f73 100644 --- a/src/renderer/src/components/AppSidebar.tsx +++ b/src/renderer/src/components/AppSidebar.tsx @@ -40,6 +40,7 @@ interface AppSidebarProps { onNewSession: (projectId: string) => void onToggleProjectExpanded: (projectId: string) => void onReorderProjects: (projectIds: string[]) => void + onUpdateSessionTitle: (sessionId: string, title: string) => void } export function AppSidebar({ @@ -52,7 +53,8 @@ export function AppSidebar({ onNewProject, onNewSession, onToggleProjectExpanded, - onReorderProjects + onReorderProjects, + onUpdateSessionTitle }: AppSidebarProps): React.JSX.Element { const openModal = useModalStore((s) => s.openModal) @@ -134,6 +136,7 @@ export function AppSidebar({ onViewArchivedSessions={(p): void => openModal('archivedSessions', { projectId: p.id, projectName: p.name }) } + onUpdateSessionTitle={onUpdateSessionTitle} /> ))} diff --git a/src/renderer/src/components/ProjectItem.tsx b/src/renderer/src/components/ProjectItem.tsx index 938a0babc..edcd69733 100644 --- a/src/renderer/src/components/ProjectItem.tsx +++ b/src/renderer/src/components/ProjectItem.tsx @@ -1,7 +1,7 @@ /** * ProjectItem component - renders a project with its sessions in sidebar */ -import { useState } from 'react' +import React, { useState, useRef, useEffect } from 'react' import type { MulticaProject, MulticaSession } from '../../../shared/types' import { SidebarMenu, @@ -46,6 +46,7 @@ interface ProjectItemProps { onDeleteProject: (project: MulticaProject) => void onArchiveSession: (session: MulticaSession) => void onViewArchivedSessions: (project: MulticaProject) => void + onUpdateSessionTitle: (sessionId: string, title: string) => void } function formatDate(iso: string): string { @@ -78,17 +79,69 @@ interface SessionItemProps { needsPermission: boolean onSelect: () => void onArchive: () => void + onUpdateTitle: (title: string) => void } -function SessionItem({ +export function SessionItem({ session, isActive, isProcessing, needsPermission, onSelect, - onArchive + onArchive, + onUpdateTitle }: SessionItemProps): React.JSX.Element { const [isHovered, setIsHovered] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState('') + const inputRef = useRef(null) + const cancelEditRef = useRef(false) + + const handleDoubleClick = (e: React.MouseEvent): void => { + e.stopPropagation() + setEditValue(getSessionTitle(session)) + setIsEditing(true) + } + + const handleSave = (): void => { + const trimmed = editValue.trim() + if (trimmed && trimmed !== getSessionTitle(session)) { + onUpdateTitle(trimmed) + } + setIsEditing(false) + } + + const handleBlur = (): void => { + if (cancelEditRef.current) { + cancelEditRef.current = false + setIsEditing(false) + return + } + handleSave() + } + + const handleCancel = (): void => { + cancelEditRef.current = true + setIsEditing(false) + } + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault() + handleSave() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancel() + } + } + + // Focus input when editing starts + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) return ( {/* Title + Status */}
- {getSessionTitle(session)} - {needsPermission ? ( + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + onClick={(e): void => e.stopPropagation()} + className={cn( + 'w-full text-sm px-1.5 py-0.5 rounded', + 'bg-background border border-input', + 'outline-none ring-2 ring-primary/30' + )} + /> + ) : ( + + {getSessionTitle(session)} + + )} + {!isEditing && needsPermission ? ( - ) : isProcessing ? ( + ) : !isEditing && isProcessing ? ( ) : null}
- {/* Git branch + Timestamp */} - - {session.gitBranch && ( - <> - {session.gitBranch} - · - - )} - {formatDate(session.updatedAt)} - + {/* Git branch + Timestamp - hide when editing */} + {!isEditing && ( + + {session.gitBranch && ( + <> + {session.gitBranch} + · + + )} + {formatDate(session.updatedAt)} + + )} {/* Archive button */} -
{ - e.stopPropagation() - onArchive() - }} - onKeyDown={(e): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() + {!isEditing && ( +
{ e.stopPropagation() onArchive() - } - }} - className={cn( - 'shrink-0 cursor-pointer rounded p-0.5 transition-opacity duration-150', - 'hover:bg-muted active:bg-muted', - isHovered ? 'opacity-50 hover:opacity-100' : 'opacity-0' - )} - > - -
+ }} + onKeyDown={(e): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onArchive() + } + }} + className={cn( + 'shrink-0 cursor-pointer rounded p-0.5 transition-opacity duration-150', + 'hover:bg-muted active:bg-muted', + isHovered ? 'opacity-50 hover:opacity-100' : 'opacity-0' + )} + > + +
+ )}
) @@ -172,6 +248,7 @@ function ProjectItemInner({ onDeleteProject, onArchiveSession, onViewArchivedSessions, + onUpdateSessionTitle, dragProps, isDragging }: ProjectItemInnerProps): React.JSX.Element { @@ -303,6 +380,7 @@ function ProjectItemInner({ needsPermission={session.id === permissionPendingSessionId} onSelect={(): void => onSelectSession(session.id)} onArchive={(): void => onArchiveSession(session)} + onUpdateTitle={(title): void => onUpdateSessionTitle(session.id, title)} /> ))} diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 13d29e581..b239321fb 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -93,6 +93,9 @@ export interface AppActions { // Mode/Model actions setSessionMode: (modeId: SessionModeId) => Promise setSessionModel: (modelId: ModelId) => Promise + + // Session metadata actions + updateSessionTitle: (sessionId: string, title: string) => Promise } function mergeSessionUpdates( @@ -197,6 +200,36 @@ export function useApp(): AppState & AppActions { currentSessionIdRef.current = session?.id ?? null // Sync update FIRST setCurrentSession(session) }, []) + + const updateSessionInLists = useCallback((updatedSession: MulticaSession) => { + setProjectsWithSessions((prev) => { + let didUpdate = false + const next = prev.map((entry) => { + if (entry.project.id !== updatedSession.projectId) { + return entry + } + const nextSessions = entry.sessions.map((session) => { + if (session.id !== updatedSession.id) { + return session + } + didUpdate = true + return updatedSession + }) + return didUpdate ? { ...entry, sessions: nextSessions } : entry + }) + return didUpdate ? next : prev + }) + + setSessions((prev) => { + const index = prev.findIndex((session) => session.id === updatedSession.id) + if (index === -1) { + return prev + } + const next = [...prev] + next[index] = updatedSession + return next + }) + }, []) const [runningSessionsStatus, setRunningSessionsStatus] = useState({ runningSessions: 0, sessionIds: [], @@ -248,6 +281,8 @@ export function useApp(): AppState & AppActions { // This is critical for receiving messages after app restart useEffect(() => { const unsubSessionMeta = window.electronAPI.onSessionMetaUpdated((updatedSession) => { + updateSessionInLists(updatedSession) + // Only update if this is the current session if (currentSessionId && updatedSession.id === currentSessionId) { console.log( @@ -263,7 +298,7 @@ export function useApp(): AppState & AppActions { return () => { unsubSessionMeta() } - }, [currentSessionId, updateCurrentSession]) + }, [currentSessionId, updateCurrentSession, updateSessionInLists]) // Start/stop file watching when current session changes useEffect(() => { @@ -846,6 +881,21 @@ export function useApp(): AppState & AppActions { [currentSession] ) + const updateSessionTitle = useCallback( + async (sessionId: string, title: string) => { + try { + const updatedSession = await window.electronAPI.updateSession(sessionId, { title }) + updateSessionInLists(updatedSession) + if (currentSession?.id === sessionId) { + updateCurrentSession(updatedSession) + } + } catch (err) { + toast.error(`Failed to update title: ${getErrorMessage(err)}`) + } + }, + [currentSession?.id, updateCurrentSession, updateSessionInLists] + ) + return { // State projects, @@ -877,6 +927,7 @@ export function useApp(): AppState & AppActions { cancelRequest, switchSessionAgent, setSessionMode, - setSessionModel + setSessionModel, + updateSessionTitle } } diff --git a/tests/integration/ipc/handlers.test.ts b/tests/integration/ipc/handlers.test.ts index 28d598247..148ea58b1 100644 --- a/tests/integration/ipc/handlers.test.ts +++ b/tests/integration/ipc/handlers.test.ts @@ -19,7 +19,7 @@ vi.mock('electron', () => ({ } })) -// Mock child_process spawn +// Mock child_process spawn and exec vi.mock('child_process', () => ({ spawn: vi.fn().mockReturnValue({ on: vi.fn((event, callback) => { @@ -27,6 +27,12 @@ vi.mock('child_process', () => ({ callback(0) } }) + }), + exec: vi.fn((_cmd, _opts, callback) => { + if (callback) { + callback(null, { stdout: 'Mocked Title', stderr: '' }) + } + return {} }) })) @@ -58,16 +64,31 @@ const createMockConductor = (workingDir?: string) => ({ .mockResolvedValue({ id: 'project-1', workingDirectory: workingDir || '/test/dir' }) }) +// Create mock file watcher +const createMockFileWatcher = (): { + watch: ReturnType + unwatch: ReturnType + unwatchAll: ReturnType +} => ({ + watch: vi.fn(), + unwatch: vi.fn(), + unwatchAll: vi.fn() +}) + describe('IPC Handlers', () => { let tempDir: string // eslint-disable-next-line @typescript-eslint/no-explicit-any let handlers: Map any> let mockConductor: ReturnType + let mockFileWatcher: ReturnType + let mockGetMainWindow: ReturnType beforeEach(() => { vi.clearAllMocks() handlers = new Map() mockConductor = createMockConductor() + mockFileWatcher = createMockFileWatcher() + mockGetMainWindow = vi.fn().mockReturnValue(null) // Capture all registered handlers vi.mocked(ipcMain.handle).mockImplementation( @@ -77,9 +98,9 @@ describe('IPC Handlers', () => { } ) - // Register handlers with mock conductor + // Register handlers with mock conductor, file watcher, and main window getter // eslint-disable-next-line @typescript-eslint/no-explicit-any - registerIPCHandlers(mockConductor as any) + registerIPCHandlers(mockConductor as any, mockFileWatcher as any, mockGetMainWindow) // Create temp directory for file system tests tempDir = mkdtempSync(join(tmpdir(), 'ipc-test-')) @@ -126,9 +147,10 @@ describe('IPC Handlers', () => { describe('agent handlers', () => { it('agent:prompt should call conductor.sendPrompt', async () => { const handler = handlers.get('agent:prompt')! - await handler({}, 'session-1', 'Hello') + const content = [{ type: 'text', text: 'Hello' }] + await handler({}, 'session-1', content) - expect(mockConductor.sendPrompt).toHaveBeenCalledWith('session-1', 'Hello') + expect(mockConductor.sendPrompt).toHaveBeenCalledWith('session-1', content) }) it('agent:cancel should call conductor.cancelRequest', async () => { diff --git a/tests/unit/main/conductor/TitleGenerator.test.ts b/tests/unit/main/conductor/TitleGenerator.test.ts new file mode 100644 index 000000000..9c7cc4bf0 --- /dev/null +++ b/tests/unit/main/conductor/TitleGenerator.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { generateSessionTitle } from '../../../../src/main/conductor/TitleGenerator' + +// Mock child_process spawn +vi.mock('node:child_process', () => ({ + spawn: vi.fn() +})) + +// Mock path utility +vi.mock('../../../../src/main/utils/path', () => ({ + getEnhancedPath: vi.fn().mockReturnValue('/usr/local/bin:/usr/bin:/bin') +})) + +describe('TitleGenerator', () => { + const mockSpawn = spawn as unknown as ReturnType + + const createMockProcess = (options: { + stdout?: string + stderr?: string + code?: number | null + signal?: NodeJS.Signals | null + error?: Error + autoClose?: boolean + }): { stdout?: EventEmitter; stderr?: EventEmitter; kill: ReturnType } => { + const child = new EventEmitter() as unknown as { + stdout?: EventEmitter + stderr?: EventEmitter + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = vi.fn() + + if (options.autoClose !== false) { + process.nextTick(() => { + if (options.error) { + child.emit('error', options.error) + return + } + if (options.stdout) { + child.stdout?.emit('data', Buffer.from(options.stdout)) + } + if (options.stderr) { + child.stderr?.emit('data', Buffer.from(options.stderr)) + } + child.emit('close', options.code ?? 0, options.signal ?? null) + }) + } + + return child + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('generateSessionTitle', () => { + describe('Claude Code agent', () => { + it('should generate title using claude CLI', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: '实现用户登录功能\n', stderr: '' })) + + const title = await generateSessionTitle('claude-code', '帮我实现一个用户登录功能') + + expect(title).toBe('实现用户登录功能') + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] }) + ) + }) + + it('should use correct CLI arguments for Claude', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: 'Test Title', stderr: '' })) + + await generateSessionTitle('claude-code', 'Test message') + + const [command, args] = mockSpawn.mock.calls[0] + expect(command).toBe('claude') + expect(args).toContain('-p') + expect(args).toContain('--output-format') + expect(args).toContain('text') + expect(args).toContain('--tools') + expect(args).toContain('--permission-mode') + expect(args).toContain('dontAsk') + expect(args).toContain('--no-session-persistence') + }) + }) + + describe('OpenCode agent', () => { + it('should generate title using opencode CLI', async () => { + const jsonOutput = [ + '{"type":"step_start","timestamp":123}', + '{"type":"text","part":{"text":"实现用户登录功能"}}', + '{"type":"step_finish","timestamp":456}' + ].join('\n') + + mockSpawn.mockReturnValue(createMockProcess({ stdout: jsonOutput, stderr: '' })) + + const title = await generateSessionTitle('opencode', '帮我实现一个用户登录功能') + + expect(title).toBe('实现用户登录功能') + }) + + it('should use opencode/gpt-5-nano model and title agent', async () => { + mockSpawn.mockReturnValue( + createMockProcess({ stdout: '{"type":"text","part":{"text":"Title"}}', stderr: '' }) + ) + + await generateSessionTitle('opencode', 'Test message') + + const [command, args] = mockSpawn.mock.calls[0] + expect(command).toBe('opencode') + expect(args).toContain('run') + expect(args).toContain('-m') + expect(args).toContain('opencode/gpt-5-nano') + expect(args).toContain('--agent') + expect(args).toContain('title') + expect(args).toContain('--format') + expect(args).toContain('json') + }) + }) + + describe('Codex agent', () => { + it('should generate title using codex CLI', async () => { + const jsonOutput = [ + '{"type":"thread.started","thread_id":"123"}', + '{"type":"turn.started"}', + '{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}', + '{"type":"item.completed","item":{"type":"agent_message","text":"实现用户登录功能"}}', + '{"type":"turn.completed"}' + ].join('\n') + + mockSpawn.mockReturnValue(createMockProcess({ stdout: jsonOutput, stderr: '' })) + + const title = await generateSessionTitle('codex', '帮我实现一个用户登录功能') + + expect(title).toBe('实现用户登录功能') + }) + + it('should use correct CLI arguments for Codex with read-only sandbox', async () => { + mockSpawn.mockReturnValue( + createMockProcess({ + stdout: '{"type":"item.completed","item":{"type":"agent_message","text":"Title"}}', + stderr: '' + }) + ) + + await generateSessionTitle('codex', 'Test message') + + const [command, args] = mockSpawn.mock.calls[0] + expect(command).toBe('codex') + expect(args).toContain('exec') + expect(args).toContain('--sandbox') + expect(args).toContain('read-only') + expect(args).toContain('--json') + }) + }) + + describe('failure behavior (returns null)', () => { + it('should return null for unknown agent', async () => { + const title = await generateSessionTitle('unknown-agent', '帮我实现一个用户登录功能') + + expect(title).toBeNull() + expect(mockSpawn).not.toHaveBeenCalled() + }) + + it('should return null on CLI error', async () => { + mockSpawn.mockReturnValue( + createMockProcess({ stderr: 'Error', error: new Error('Command failed') }) + ) + + const title = await generateSessionTitle('claude-code', '帮我实现一个用户登录功能') + + expect(title).toBeNull() + }) + + it('should return null on CLI timeout', async () => { + vi.useFakeTimers() + mockSpawn.mockReturnValue(createMockProcess({ autoClose: false })) + const titlePromise = generateSessionTitle('claude-code', '帮我实现一个用户登录功能') + await vi.advanceTimersByTimeAsync(60000) + const title = await titlePromise + + expect(title).toBeNull() + vi.useRealTimers() + }) + + it('should return null on empty output', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: '', stderr: '' })) + + const title = await generateSessionTitle('claude-code', '帮我实现一个用户登录功能') + + expect(title).toBeNull() + }) + }) + + describe('prompt building', () => { + it('should include user message in prompt', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: 'Title', stderr: '' })) + + await generateSessionTitle('claude-code', 'My specific message') + + const [, args] = mockSpawn.mock.calls[0] + const promptIndex = args.indexOf('-p') + 1 + expect(args[promptIndex]).toContain('My specific message') + }) + + it('should escape single quotes in user message', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: 'Title', stderr: '' })) + + await generateSessionTitle('claude-code', "User's message with 'quotes'") + + const [, args] = mockSpawn.mock.calls[0] + const promptIndex = args.indexOf('-p') + 1 + expect(args[promptIndex]).toContain("User's message with 'quotes'") + }) + }) + + describe('JSON parsing', () => { + it('should handle malformed JSON in OpenCode output', async () => { + const badJsonOutput = [ + '{"type":"text","part":{"text":"Good Title"}}', + 'not valid json', + '{"incomplete' + ].join('\n') + + mockSpawn.mockReturnValue(createMockProcess({ stdout: badJsonOutput, stderr: '' })) + + const title = await generateSessionTitle('opencode', 'Test') + + expect(title).toBe('Good Title') + }) + + it('should handle malformed JSON in Codex output', async () => { + const badJsonOutput = [ + '{"type":"item.completed","item":{"type":"agent_message","text":"Good Title"}}', + 'garbage', + '{' + ].join('\n') + + mockSpawn.mockReturnValue(createMockProcess({ stdout: badJsonOutput, stderr: '' })) + + const title = await generateSessionTitle('codex', 'Test') + + expect(title).toBe('Good Title') + }) + + it('should return null when no valid JSON events found', async () => { + mockSpawn.mockReturnValue(createMockProcess({ stdout: 'not json at all', stderr: '' })) + + const title = await generateSessionTitle('opencode', 'Test message') + + expect(title).toBeNull() + }) + }) + }) +}) diff --git a/tests/unit/main/ipc/handlers.test.ts b/tests/unit/main/ipc/handlers.test.ts new file mode 100644 index 000000000..37fd27193 --- /dev/null +++ b/tests/unit/main/ipc/handlers.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MessageContent } from '../../../../src/shared/types/message' +import type { Conductor } from '../../../../src/main/conductor/Conductor' +import type { FileWatcher } from '../../../../src/main/watcher' +import { IPC_CHANNELS } from '../../../../src/shared/ipc-channels' +import { registerIPCHandlers } from '../../../../src/main/ipc/handlers' +import { generateSessionTitle } from '../../../../src/main/conductor/TitleGenerator' + +const { ipcMainHandleMock, ipcMainOnMock } = vi.hoisted(() => ({ + ipcMainHandleMock: vi.fn(), + ipcMainOnMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: ipcMainHandleMock, + on: ipcMainOnMock + }, + dialog: { + showOpenDialog: vi.fn() + }, + clipboard: { + writeText: vi.fn() + }, + shell: { + openExternal: vi.fn(), + showItemInFolder: vi.fn() + }, + BrowserWindow: vi.fn() +})) + +vi.mock('../../../../src/main/conductor/TitleGenerator', () => ({ + generateSessionTitle: vi.fn() +})) + +const flushPromises = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +const getHandler = (channel: string): ((...args: unknown[]) => Promise) => { + const call = ipcMainHandleMock.mock.calls.find(([registered]) => registered === channel) + if (!call) { + throw new Error(`Handler not registered for ${channel}`) + } + return call[1] +} + +describe('IPC session title generation', () => { + beforeEach(() => { + ipcMainHandleMock.mockClear() + ipcMainOnMock.mockClear() + vi.mocked(generateSessionTitle).mockReset() + }) + + it('avoids concurrent title generation for the same session', async () => { + const conductor = { + getSessionData: vi.fn().mockResolvedValue({ + session: { title: undefined, agentId: 'opencode' } + }), + updateSessionMeta: vi.fn().mockResolvedValue({ id: 'session-1' }), + sendPrompt: vi.fn().mockResolvedValue('stop') + } as unknown as Conductor + + const fileWatcher = {} as FileWatcher + const getMainWindow = (): null => null + + let resolveTitle: (value: string) => void + const titlePromise = new Promise((resolve) => { + resolveTitle = resolve + }) + + vi.mocked(generateSessionTitle).mockReturnValue(titlePromise) + + registerIPCHandlers(conductor, fileWatcher, getMainWindow) + const handler = getHandler(IPC_CHANNELS.AGENT_PROMPT) + + const content: MessageContent = [{ type: 'text', text: 'hello' }] + + await handler({} as Electron.IpcMainInvokeEvent, 'session-1', content) + await handler({} as Electron.IpcMainInvokeEvent, 'session-1', content) + + await flushPromises() + + expect(generateSessionTitle).toHaveBeenCalledTimes(1) + + resolveTitle('Auto Title') + await flushPromises() + + expect(conductor.updateSessionMeta).toHaveBeenCalledTimes(1) + }) + + it('does not overwrite a manual title set during generation', async () => { + const conductor = { + getSessionData: vi + .fn() + .mockResolvedValueOnce({ session: { title: undefined, agentId: 'opencode' } }) + .mockResolvedValueOnce({ session: { title: 'Manual Title', agentId: 'opencode' } }), + updateSessionMeta: vi.fn().mockResolvedValue({ id: 'session-1' }), + sendPrompt: vi.fn().mockResolvedValue('stop') + } as unknown as Conductor + + const fileWatcher = {} as FileWatcher + const getMainWindow = (): null => null + + vi.mocked(generateSessionTitle).mockResolvedValue('Auto Title') + + registerIPCHandlers(conductor, fileWatcher, getMainWindow) + const handler = getHandler(IPC_CHANNELS.AGENT_PROMPT) + + const content: MessageContent = [{ type: 'text', text: 'hello' }] + + await handler({} as Electron.IpcMainInvokeEvent, 'session-1', content) + await flushPromises() + + expect(generateSessionTitle).toHaveBeenCalledTimes(1) + expect(conductor.updateSessionMeta).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/main/utils/pathValidation.test.ts b/tests/unit/main/utils/pathValidation.test.ts index 3484ab47a..4e28745ff 100644 --- a/tests/unit/main/utils/pathValidation.test.ts +++ b/tests/unit/main/utils/pathValidation.test.ts @@ -17,12 +17,4 @@ describe('isValidPath', () => { it('rejects POSIX traversal segments', () => { expect(isValidPath('/tmp/../secret')).toBe(false) }) - - it('accepts absolute Windows paths', () => { - expect(isValidPath('C:\\\\Users\\\\me\\\\project')).toBe(true) - }) - - it('rejects Windows traversal segments', () => { - expect(isValidPath('C:\\\\Users\\\\..\\\\project')).toBe(false) - }) }) diff --git a/tests/unit/renderer/components/ProjectItem.test.tsx b/tests/unit/renderer/components/ProjectItem.test.tsx new file mode 100644 index 000000000..5cd834fc7 --- /dev/null +++ b/tests/unit/renderer/components/ProjectItem.test.tsx @@ -0,0 +1,182 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable react/prop-types */ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { MulticaSession } from '../../../../src/shared/types' +import { SessionItem } from '../../../../src/renderer/src/components/ProjectItem' + +vi.mock('@/components/ui/sidebar', () => ({ + SidebarMenuItem: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + SidebarMenuButton: ({ + children, + onClick, + isActive, + ...props + }: React.ButtonHTMLAttributes & { isActive?: boolean }) => { + void isActive + return ( + + ) + }, + SidebarMenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarMenu: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('lucide-react', () => ({ + AlertTriangle: (props: React.SVGProps) => , + Archive: (props: React.SVGProps) => , + ChevronDown: (props: React.SVGProps) => , + ChevronRight: (props: React.SVGProps) => , + CirclePause: (props: React.SVGProps) => , + Folder: (props: React.SVGProps) => , + Loader2: (props: React.SVGProps) => , + MoreHorizontal: (props: React.SVGProps) => , + Plus: (props: React.SVGProps) => , + Trash2: (props: React.SVGProps) => +})) + +const buildSession = (title: string): MulticaSession => ({ + id: 'session-1', + projectId: 'project-1', + workingDirectory: '/tmp', + agentId: 'opencode', + agentSessionId: 'agent-session-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'active', + title, + messageCount: 0, + isArchived: false +}) + +const setNativeValue = (element: HTMLInputElement, value: string): void => { + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value') + if (!descriptor?.set) { + throw new Error('Missing value setter on input') + } + descriptor.set.call(element, value) +} + +describe('SessionItem', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + globalThis.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('does not save when edit is cancelled with Escape', () => { + const onUpdateTitle = vi.fn() + + act(() => { + root.render( + + ) + }) + + const titleSpan = Array.from(container.querySelectorAll('span')).find( + (span) => span.textContent === 'Old Title' + ) + if (!titleSpan) throw new Error('Title element not found') + + act(() => { + titleSpan.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + const input = container.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('Input not found after double click') + + act(() => { + setNativeValue(input, 'New Title') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + input.dispatchEvent(new FocusEvent('blur', { bubbles: true })) + }) + + expect(onUpdateTitle).not.toHaveBeenCalled() + }) + + it('saves when Enter is pressed', () => { + const onUpdateTitle = vi.fn() + + act(() => { + root.render( + + ) + }) + + const titleSpan = Array.from(container.querySelectorAll('span')).find( + (span) => span.textContent === 'Old Title' + ) + if (!titleSpan) throw new Error('Title element not found') + + act(() => { + titleSpan.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + const input = container.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('Input not found after double click') + + act(() => { + setNativeValue(input, 'New Title') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onUpdateTitle).toHaveBeenCalledWith('New Title') + }) +}) diff --git a/tests/unit/renderer/hooks/useApp.test.tsx b/tests/unit/renderer/hooks/useApp.test.tsx index e1a0af94e..891558659 100644 --- a/tests/unit/renderer/hooks/useApp.test.tsx +++ b/tests/unit/renderer/hooks/useApp.test.tsx @@ -11,21 +11,32 @@ import type { StoredSessionUpdate, MulticaSession } from '../../../../src/shared type AppHandle = { deleteSession: (sessionId: string) => Promise selectSession: (sessionId: string) => Promise + updateSessionTitle: (sessionId: string, title: string) => Promise getSessionUpdates: () => StoredSessionUpdate[] getCurrentSession: () => MulticaSession | null + getSessions: () => MulticaSession[] } const AppHarness = React.forwardRef((_, ref) => { - const { deleteSession, selectSession, sessionUpdates, currentSession } = useApp() + const { + deleteSession, + selectSession, + updateSessionTitle, + sessionUpdates, + currentSession, + sessions + } = useApp() useImperativeHandle( ref, () => ({ deleteSession, selectSession, + updateSessionTitle, getSessionUpdates: () => sessionUpdates, - getCurrentSession: () => currentSession + getCurrentSession: () => currentSession, + getSessions: () => sessions }), - [deleteSession, selectSession, sessionUpdates, currentSession] + [deleteSession, selectSession, updateSessionTitle, sessionUpdates, currentSession, sessions] ) return null }) @@ -59,7 +70,8 @@ describe('useApp', () => { getSessionCommands: vi.fn().mockResolvedValue([]), startFileWatch: vi.fn().mockResolvedValue(undefined), stopFileWatch: vi.fn().mockResolvedValue(undefined), - deleteSession: vi.fn().mockResolvedValue(undefined) + deleteSession: vi.fn().mockResolvedValue(undefined), + updateSession: vi.fn().mockResolvedValue(undefined) }) beforeEach(() => { @@ -306,6 +318,64 @@ describe('useApp', () => { expect(sequenceNumbers).toEqual([1, 2]) }) + it('updates sessions list when session metadata changes', async () => { + let sessionMetaHandler: ((session: MulticaSession) => void) | undefined + + const project = { + id: 'project-1', + name: 'Project', + workingDirectory: '/tmp', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + isExpanded: true, + isArchived: false, + sortOrder: 0 + } + + const session: MulticaSession = { + id: 'session-a', + agentSessionId: 'agent-1', + projectId: 'project-1', + agentId: 'codex', + workingDirectory: '/tmp', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + status: 'active', + messageCount: 0 + } + + const updatedSession: MulticaSession = { + ...session, + title: '更新后的标题' + } + + const electronAPI = createElectronApiMock() + electronAPI.onSessionMetaUpdated.mockImplementation((cb) => { + sessionMetaHandler = cb + return () => {} + }) + electronAPI.listProjectsWithSessions.mockResolvedValue([{ project, sessions: [session] }]) + ;(window as unknown as { electronAPI: typeof electronAPI }).electronAPI = electronAPI + + const ref = React.createRef() + + await act(async () => { + root.render() + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(ref.current?.getSessions()).toEqual([session]) + + await act(async () => { + sessionMetaHandler?.(updatedSession) + }) + + expect(ref.current?.getSessions()).toEqual([updatedSession]) + }) + it('refreshes git branch when git HEAD changes for current session', async () => { let fileChangeHandler: | ((event: { @@ -393,6 +463,62 @@ describe('useApp', () => { expect(electronAPI.listProjectsWithSessions).toHaveBeenCalledTimes(2) expect(ref.current?.getCurrentSession()?.gitBranch).toBe('feature/new-branch') }) + + it('updates session title and refreshes sessions list', async () => { + const project = { + id: 'project-1', + name: 'Project', + workingDirectory: '/tmp', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + isExpanded: true, + isArchived: false, + sortOrder: 0 + } + + const session: MulticaSession = { + id: 'session-a', + agentSessionId: 'agent-1', + projectId: 'project-1', + agentId: 'codex', + workingDirectory: '/tmp', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + status: 'active', + messageCount: 0, + title: 'Original Title' + } + + const updatedSession: MulticaSession = { + ...session, + title: 'New Title' + } + + const electronAPI = createElectronApiMock() + electronAPI.listProjectsWithSessions.mockResolvedValue([{ project, sessions: [session] }]) + electronAPI.updateSession.mockResolvedValue(updatedSession) + ;(window as unknown as { electronAPI: typeof electronAPI }).electronAPI = electronAPI + + const ref = React.createRef() + + await act(async () => { + root.render() + }) + + // Wait for initial load + await act(async () => { + await Promise.resolve() + }) + + expect(ref.current?.getSessions()[0]?.title).toBe('Original Title') + + await act(async () => { + await ref.current?.updateSessionTitle('session-a', 'New Title') + }) + + expect(electronAPI.updateSession).toHaveBeenCalledWith('session-a', { title: 'New Title' }) + expect(ref.current?.getSessions()[0]?.title).toBe('New Title') + }) }) function createDeferred(): {