feat: decouple session creation from agent startup

- Lazy agent initialization: agent now starts on first prompt instead of session creation
- Show auth errors inline in chat with structured UI (badges, steps, command button)
- Add one-click terminal command execution for authentication
- Users can enter chat immediately after selecting folder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan
2026-01-16 14:17:23 +08:00
parent 5f1a0cff56
commit 699da814ee
7 changed files with 222 additions and 19 deletions

View File

@@ -204,15 +204,15 @@ export class Conductor {
}
/**
* Create a new session with a new agent process
* Create a new session (agent starts lazily on first prompt)
*/
async createSession(cwd: string, agentConfig: AgentConfig): Promise<MulticaSession> {
let session: MulticaSession
if (this.sessionStore) {
// Create session record first to get the ID
// Create session record - agent will be started lazily via ensureAgentForSession
session = await this.sessionStore.create({
agentSessionId: '', // Will be updated after agent starts
agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt
agentId: agentConfig.id,
workingDirectory: cwd
})
@@ -233,18 +233,8 @@ export class Conductor {
this.inMemorySession = session
}
// Start agent process for this session
const { agentSessionId } = await this.startAgentForSession(session.id, agentConfig, cwd)
// Update session with agentSessionId
if (this.sessionStore) {
session = await this.sessionStore.updateMeta(session.id, { agentSessionId })
} else {
session.agentSessionId = agentSessionId
this.inMemorySession = session
}
console.log(`[Conductor] Created session: ${session.id} (agent: ${agentSessionId})`)
// Agent will be started lazily when user sends first prompt (via ensureAgentForSession)
console.log(`[Conductor] Created session: ${session.id} (agent: pending)`)
return session
}

View File

@@ -335,5 +335,30 @@ export function registerIPCHandlers(conductor: Conductor): void {
}
)
// Terminal: Run command in a new terminal window
ipcMain.handle(IPC_CHANNELS.TERMINAL_RUN, async (_event, command: string) => {
if (!command || typeof command !== 'string') {
throw new Error('Invalid command')
}
// Escape the command for AppleScript
const escapedCommand = command.replace(/"/g, '\\"')
// Use AppleScript to open Terminal and run the command
const script = `
tell application "Terminal"
activate
do script "${escapedCommand}"
end tell
`
try {
await spawnAsync('osascript', ['-e', script])
} catch (error) {
console.error('[IPC] Failed to run command in terminal:', error)
throw error
}
})
console.log('[IPC] All handlers registered')
}

View File

@@ -100,7 +100,10 @@ const electronAPI: ElectronAPI = {
callback(session as Parameters<typeof callback>[0])
ipcRenderer.on(IPC_CHANNELS.SESSION_META_UPDATED, listener)
return () => ipcRenderer.removeListener(IPC_CHANNELS.SESSION_META_UPDATED, listener)
}
},
// Terminal
runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command)
}
// Expose API to renderer via contextBridge

View File

@@ -145,7 +145,15 @@ interface PlanBlock {
entries: PlanEntry[]
}
type ContentBlock = TextBlock | ImageBlock | ThoughtBlock | ToolCallBlock | PlanBlock
interface ErrorBlock {
type: 'error'
errorType: 'auth' | 'general'
agentId?: string
authCommand?: string
message: string
}
type ContentBlock = TextBlock | ImageBlock | ThoughtBlock | ToolCallBlock | PlanBlock | ErrorBlock
interface Message {
role: 'user' | 'assistant'
@@ -442,6 +450,31 @@ function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] {
}
}
break
case 'error_message' as string:
// Handle error messages (shown in chat instead of toast)
{
const errorUpdate = update as {
errorType?: string
agentId?: string
authCommand?: string
message?: string
}
flushAssistantMessage()
const errorBlock: ErrorBlock = {
type: 'error',
errorType: (errorUpdate.errorType as 'auth' | 'general') || 'general',
agentId: errorUpdate.agentId,
authCommand: errorUpdate.authCommand,
message: errorUpdate.message || 'An error occurred'
}
// Add error as a standalone "assistant" message
messages.push({
role: 'assistant',
blocks: [errorBlock]
})
}
break
}
}
@@ -508,6 +541,16 @@ function MessageBubble({ message }: MessageBubbleProps) {
)
case 'plan':
return <PlanBlockView key={`plan-${idx}`} entries={block.entries} />
case 'error':
return (
<AuthErrorBlockView
key={`error-${idx}`}
errorType={block.errorType}
agentId={block.agentId}
authCommand={block.authCommand}
message={block.message}
/>
)
default:
return null
}
@@ -790,3 +833,100 @@ function PlanBlockView({ entries }: { entries: PlanEntry[] }) {
</Collapsible>
)
}
// Agent name mapping for display
const AGENT_DISPLAY_NAMES: Record<string, string> = {
'claude-code': 'Claude Code',
opencode: 'OpenCode',
codex: 'Codex'
}
// Auth error block view - displays authentication required message with command
function AuthErrorBlockView({
errorType,
agentId,
authCommand,
message
}: {
errorType: 'auth' | 'general'
agentId?: string
authCommand?: string
message: string
}) {
const agentName = agentId ? AGENT_DISPLAY_NAMES[agentId] || agentId : 'Agent'
const handleRunInTerminal = async () => {
if (authCommand) {
try {
await window.electronAPI.runInTerminal(authCommand)
} catch (err) {
// Fallback to copying if terminal open fails
await navigator.clipboard.writeText(authCommand)
console.error('Failed to open terminal:', err)
}
}
}
if (errorType !== 'auth') {
// General error - simple display
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-mono px-2 py-0.5 rounded border border-destructive/50 text-destructive">
ERROR
</span>
</div>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
)
}
return (
<div className="rounded-lg border border-destructive/30 bg-background p-4 space-y-4">
{/* Error badges */}
<div className="flex flex-wrap gap-2">
<span className="text-xs font-mono px-2 py-1 rounded border border-destructive/50 text-destructive">
SESSION ERROR
</span>
<span className="text-xs font-mono px-2 py-1 rounded border border-destructive/50 text-destructive">
AUTHENTICATION REQUIRED
</span>
</div>
{/* Resolution steps */}
<div className="space-y-3">
<p className="text-sm text-foreground">To resolve, please:</p>
<ol className="space-y-3 text-sm list-none">
{/* Step 1: Run command */}
<li className="flex items-center gap-3">
<span className="text-muted-foreground w-4 shrink-0">1.</span>
<span>Run</span>
<button
onClick={handleRunInTerminal}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-muted hover:bg-muted/80 transition-colors border border-border"
title="Click to run in terminal"
>
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<code className="text-sm font-mono">{authCommand}</code>
</button>
</li>
{/* Step 2: Send message again */}
<li className="flex items-center gap-3">
<span className="text-muted-foreground w-4 shrink-0">2.</span>
<span>Send your last message again</span>
</li>
</ol>
</div>
{/* Additional info */}
<p className="text-xs text-muted-foreground">
This will authenticate {agentName}. Follow the prompts in your terminal to complete the
login process.
</p>
</div>
)
}

View File

@@ -15,6 +15,20 @@ const FILE_MODIFYING_KINDS = new Set(['edit', 'write', 'delete', 'execute'])
// Actual tool names from _meta.claudeCode.toolName (used for Claude Code)
const FILE_MODIFYING_TOOL_NAMES = new Set(['write', 'edit', 'bash', 'notebookedit'])
// Auth commands for each agent
const AGENT_AUTH_COMMANDS: Record<string, string> = {
'claude-code': 'claude login',
opencode: 'opencode auth',
codex: 'codex auth'
}
// Check if error is authentication related
function isAuthError(errorMessage: string): boolean {
const authKeywords = ['authentication required', 'unauthorized', 'not authenticated', 'login required']
const lowerMessage = errorMessage.toLowerCase()
return authKeywords.some((keyword) => lowerMessage.includes(keyword))
}
export interface AppState {
// Sessions
sessions: MulticaSession[]
@@ -285,7 +299,32 @@ export function useApp(): AppState & AppActions {
await window.electronAPI.sendPrompt(currentSession.id, content)
} catch (err) {
toast.error(`Failed to send prompt: ${getErrorMessage(err)}`)
const errorMessage = getErrorMessage(err)
// Check if this is an authentication error
if (isAuthError(errorMessage)) {
// Get the auth command for the current agent
const authCommand = AGENT_AUTH_COMMANDS[currentSession.agentId] || 'Please authenticate'
// Add error message to chat instead of toast
const errorUpdate = {
timestamp: new Date().toISOString(),
update: {
sessionId: currentSession.agentSessionId || currentSession.id,
update: {
sessionUpdate: 'error_message',
errorType: 'auth',
agentId: currentSession.agentId,
authCommand: authCommand,
message: errorMessage
}
}
} as unknown as StoredSessionUpdate
setSessionUpdates((prev) => [...prev, errorUpdate])
} else {
// For other errors, use toast
toast.error(`Failed to send prompt: ${errorMessage}`)
}
}
},
[currentSession]

View File

@@ -174,6 +174,9 @@ export interface ElectronAPI {
// Permission response
respondToPermission(response: PermissionResponse): void
// Terminal
runInTerminal(command: string): Promise<void>
}
declare global {

View File

@@ -46,7 +46,10 @@ export const IPC_CHANNELS = {
// File tree
FS_LIST_DIRECTORY: 'fs:list-directory',
FS_DETECT_APPS: 'fs:detect-apps',
FS_OPEN_WITH: 'fs:open-with'
FS_OPEN_WITH: 'fs:open-with',
// Terminal
TERMINAL_RUN: 'terminal:run'
} as const
export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]