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 <noreply@anthropic.com>
This commit is contained in:
LinYushen
2026-01-23 19:20:29 +08:00
committed by GitHub
parent edfe8c4c7a
commit e203f2099b
15 changed files with 1198 additions and 61 deletions

View File

@@ -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.
<user-message>
${userMessage}
</user-message>`
}
/**
* 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<CommandResult> {
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<string | null> {
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
}
}

View File

@@ -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 {

View File

@@ -139,7 +139,7 @@ app.whenReady().then(async () => {
})
// Register IPC handlers
registerIPCHandlers(conductor, fileWatcher)
registerIPCHandlers(conductor, fileWatcher, () => mainWindow)
mainWindow = createWindow()

View File

@@ -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<string>()
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) {

View File

@@ -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
}

View File

@@ -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 */}

View File

@@ -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}
/>
))}
</SidebarMenu>

View File

@@ -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<HTMLInputElement>(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 (
<SidebarMenuItem
@@ -97,7 +150,7 @@ function SessionItem({
>
<SidebarMenuButton
isActive={isActive}
onClick={onSelect}
onClick={isEditing ? undefined : onSelect}
className={cn(
'h-auto py-1 pl-10 transition-colors duration-150',
'hover:bg-sidebar-accent/50',
@@ -107,49 +160,72 @@ function SessionItem({
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{/* Title + Status */}
<div className="flex items-center gap-1.5">
<span className="truncate text-sm">{getSessionTitle(session)}</span>
{needsPermission ? (
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e): void => 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'
)}
/>
) : (
<span className="truncate text-sm" onDoubleClick={handleDoubleClick}>
{getSessionTitle(session)}
</span>
)}
{!isEditing && needsPermission ? (
<CirclePause className="h-3 w-3 shrink-0 text-amber-500" />
) : isProcessing ? (
) : !isEditing && isProcessing ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
) : null}
</div>
{/* Git branch + Timestamp */}
<span className="text-xs text-muted-foreground/60">
{session.gitBranch && (
<>
<span className="font-medium">{session.gitBranch}</span>
<span className="mx-1">·</span>
</>
)}
{formatDate(session.updatedAt)}
</span>
{/* Git branch + Timestamp - hide when editing */}
{!isEditing && (
<span className="text-xs text-muted-foreground/60">
{session.gitBranch && (
<>
<span className="font-medium">{session.gitBranch}</span>
<span className="mx-1">·</span>
</>
)}
{formatDate(session.updatedAt)}
</span>
)}
</div>
{/* Archive button */}
<div
role="button"
tabIndex={0}
onClick={(e): void => {
e.stopPropagation()
onArchive()
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
{!isEditing && (
<div
role="button"
tabIndex={0}
onClick={(e): void => {
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'
)}
>
<Archive className="h-3 w-3 text-muted-foreground" />
</div>
}}
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'
)}
>
<Archive className="h-3 w-3 text-muted-foreground" />
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
@@ -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)}
/>
))}
</SidebarMenu>

View File

@@ -93,6 +93,9 @@ export interface AppActions {
// Mode/Model actions
setSessionMode: (modeId: SessionModeId) => Promise<void>
setSessionModel: (modelId: ModelId) => Promise<void>
// Session metadata actions
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
}
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<RunningSessionsStatus>({
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
}
}

View File

@@ -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<typeof vi.fn>
unwatch: ReturnType<typeof vi.fn>
unwatchAll: ReturnType<typeof vi.fn>
} => ({
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<string, (...args: any[]) => any>
let mockConductor: ReturnType<typeof createMockConductor>
let mockFileWatcher: ReturnType<typeof createMockFileWatcher>
let mockGetMainWindow: ReturnType<typeof vi.fn>
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 () => {

View File

@@ -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<typeof vi.fn>
const createMockProcess = (options: {
stdout?: string
stderr?: string
code?: number | null
signal?: NodeJS.Signals | null
error?: Error
autoClose?: boolean
}): { stdout?: EventEmitter; stderr?: EventEmitter; kill: ReturnType<typeof vi.fn> } => {
const child = new EventEmitter() as unknown as {
stdout?: EventEmitter
stderr?: EventEmitter
kill: ReturnType<typeof vi.fn>
}
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()
})
})
})
})

View File

@@ -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<void> => new Promise((resolve) => setImmediate(resolve))
const getHandler = (channel: string): ((...args: unknown[]) => Promise<unknown>) => {
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<string>((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()
})
})

View File

@@ -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)
})
})

View File

@@ -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<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
SidebarMenuButton: ({
children,
onClick,
isActive,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { isActive?: boolean }) => {
void isActive
return (
<button onClick={onClick} {...props}>
{children}
</button>
)
},
SidebarMenuSub: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SidebarMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <div />,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/collapsible', () => ({
Collapsible: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CollapsibleTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('lucide-react', () => ({
AlertTriangle: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
Archive: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
ChevronDown: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
ChevronRight: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
CirclePause: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
Folder: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
Loader2: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
MoreHorizontal: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
Plus: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
Trash2: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
}))
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(
<SessionItem
session={buildSession('Old Title')}
isActive={false}
isProcessing={false}
needsPermission={false}
onSelect={vi.fn()}
onArchive={vi.fn()}
onUpdateTitle={onUpdateTitle}
/>
)
})
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(
<SessionItem
session={buildSession('Old Title')}
isActive={false}
isProcessing={false}
needsPermission={false}
onSelect={vi.fn()}
onArchive={vi.fn()}
onUpdateTitle={onUpdateTitle}
/>
)
})
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')
})
})

View File

@@ -11,21 +11,32 @@ import type { StoredSessionUpdate, MulticaSession } from '../../../../src/shared
type AppHandle = {
deleteSession: (sessionId: string) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
updateSessionTitle: (sessionId: string, title: string) => Promise<void>
getSessionUpdates: () => StoredSessionUpdate[]
getCurrentSession: () => MulticaSession | null
getSessions: () => MulticaSession[]
}
const AppHarness = React.forwardRef<AppHandle>((_, 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<AppHandle>()
await act(async () => {
root.render(<AppHarness ref={ref} />)
})
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<AppHandle>()
await act(async () => {
root.render(<AppHarness ref={ref} />)
})
// 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<T>(): {