mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
216
src/main/conductor/TitleGenerator.ts
Normal file
216
src/main/conductor/TitleGenerator.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -139,7 +139,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
// Register IPC handlers
|
||||
registerIPCHandlers(conductor, fileWatcher)
|
||||
registerIPCHandlers(conductor, fileWatcher, () => mainWindow)
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
263
tests/unit/main/conductor/TitleGenerator.test.ts
Normal file
263
tests/unit/main/conductor/TitleGenerator.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
116
tests/unit/main/ipc/handlers.test.ts
Normal file
116
tests/unit/main/ipc/handlers.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
182
tests/unit/renderer/components/ProjectItem.test.tsx
Normal file
182
tests/unit/renderer/components/ProjectItem.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>(): {
|
||||
|
||||
Reference in New Issue
Block a user