mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
3
src/shared/electron-api.d.ts
vendored
3
src/shared/electron-api.d.ts
vendored
@@ -174,6 +174,9 @@ export interface ElectronAPI {
|
||||
|
||||
// Permission response
|
||||
respondToPermission(response: PermissionResponse): void
|
||||
|
||||
// Terminal
|
||||
runInTerminal(command: string): Promise<void>
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user