mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
it is allllliiiiiiive
This commit is contained in:
@@ -258,6 +258,7 @@ function setupIpcHandlers() {
|
||||
args: string[];
|
||||
prompt?: string;
|
||||
shell?: string;
|
||||
images?: string[]; // Base64 data URLs for images
|
||||
}) => {
|
||||
if (!processManager) throw new Error('Process manager not initialized');
|
||||
if (!agentDetector) throw new Error('Agent detector not initialized');
|
||||
|
||||
@@ -9,6 +9,7 @@ interface ProcessConfig {
|
||||
args: string[];
|
||||
prompt?: string;
|
||||
shell?: string;
|
||||
images?: string[]; // Base64 data URLs for images
|
||||
}
|
||||
|
||||
interface AgentConfig {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ProcessConfig {
|
||||
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
|
||||
prompt?: string; // For batch mode agents like Claude (passed as CLI argument)
|
||||
shell?: string; // Shell to use for terminal sessions (e.g., 'zsh', 'bash', 'fish')
|
||||
images?: string[]; // Base64 data URLs for images (passed via stream-json input)
|
||||
}
|
||||
|
||||
interface ManagedProcess {
|
||||
@@ -23,10 +24,68 @@ interface ManagedProcess {
|
||||
pid: number;
|
||||
isTerminal: boolean;
|
||||
isBatchMode?: boolean; // True for agents that run in batch mode (exit after response)
|
||||
isStreamJsonMode?: boolean; // True when using stream-json input/output (for images)
|
||||
jsonBuffer?: string; // Buffer for accumulating JSON output in batch mode
|
||||
lastCommand?: string; // Last command sent to terminal (for filtering command echoes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a data URL and extract base64 data and media type
|
||||
*/
|
||||
function parseDataUrl(dataUrl: string): { base64: string; mediaType: string } | null {
|
||||
// Format: data:image/png;base64,iVBORw0KGgo...
|
||||
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
mediaType: match[1],
|
||||
base64: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stream-json message for Claude Code with images and text
|
||||
*/
|
||||
function buildStreamJsonMessage(prompt: string, images: string[]): string {
|
||||
// Build content array with images first, then text
|
||||
const content: Array<{
|
||||
type: 'image' | 'text';
|
||||
text?: string;
|
||||
source?: { type: 'base64'; media_type: string; data: string };
|
||||
}> = [];
|
||||
|
||||
// Add images
|
||||
for (const dataUrl of images) {
|
||||
const parsed = parseDataUrl(dataUrl);
|
||||
if (parsed) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: parsed.mediaType,
|
||||
data: parsed.base64,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add text prompt
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: prompt,
|
||||
});
|
||||
|
||||
// Build the stream-json message
|
||||
const message = {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
|
||||
export class ProcessManager extends EventEmitter {
|
||||
private processes: Map<string, ManagedProcess> = new Map();
|
||||
|
||||
@@ -34,16 +93,29 @@ export class ProcessManager extends EventEmitter {
|
||||
* Spawn a new process for a session
|
||||
*/
|
||||
spawn(config: ProcessConfig): { pid: number; success: boolean } {
|
||||
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell } = config;
|
||||
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images } = config;
|
||||
|
||||
// For batch mode with prompt, append prompt to args with -- separator
|
||||
// The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --)
|
||||
const finalArgs = prompt ? [...args, '--', prompt] : args;
|
||||
// For batch mode with images, use stream-json mode and send message via stdin
|
||||
// For batch mode without images, append prompt to args with -- separator
|
||||
const hasImages = images && images.length > 0;
|
||||
let finalArgs: string[];
|
||||
|
||||
if (hasImages && prompt) {
|
||||
// Use stream-json mode for images - prompt will be sent via stdin
|
||||
finalArgs = [...args, '--input-format', 'stream-json', '--output-format', 'stream-json', '-p'];
|
||||
} else if (prompt) {
|
||||
// Regular batch mode - prompt as CLI arg
|
||||
// The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --)
|
||||
finalArgs = [...args, '--', prompt];
|
||||
} else {
|
||||
finalArgs = args;
|
||||
}
|
||||
|
||||
console.log('[ProcessManager] spawn() config:', {
|
||||
sessionId,
|
||||
toolType,
|
||||
hasPrompt: !!prompt,
|
||||
hasImages,
|
||||
promptValue: prompt,
|
||||
baseArgs: args,
|
||||
finalArgs
|
||||
@@ -128,13 +200,47 @@ export class ProcessManager extends EventEmitter {
|
||||
return { pid: ptyProcess.pid, success: true };
|
||||
} else {
|
||||
// Use regular child_process for AI tools (including batch mode)
|
||||
|
||||
// Fix PATH for Electron environment
|
||||
// Electron's main process may have a limited PATH that doesn't include
|
||||
// user-installed binaries like node, which is needed for #!/usr/bin/env node scripts
|
||||
const env = { ...process.env };
|
||||
const standardPaths = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
||||
if (env.PATH) {
|
||||
// Prepend standard paths if not already present
|
||||
if (!env.PATH.includes('/opt/homebrew/bin')) {
|
||||
env.PATH = `${standardPaths}:${env.PATH}`;
|
||||
}
|
||||
} else {
|
||||
env.PATH = standardPaths;
|
||||
}
|
||||
|
||||
console.log('[ProcessManager] About to spawn child process:', {
|
||||
command,
|
||||
finalArgs,
|
||||
cwd,
|
||||
PATH: env.PATH?.substring(0, 150),
|
||||
hasStdio: 'default (pipe)'
|
||||
});
|
||||
|
||||
const childProcess = spawn(command, finalArgs, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
env,
|
||||
shell: false, // Explicitly disable shell to prevent injection
|
||||
stdio: ['pipe', 'pipe', 'pipe'], // Explicitly set stdio to pipe
|
||||
});
|
||||
|
||||
console.log('[ProcessManager] Child process spawned:', {
|
||||
pid: childProcess.pid,
|
||||
hasStdout: !!childProcess.stdout,
|
||||
hasStderr: !!childProcess.stderr,
|
||||
hasStdin: !!childProcess.stdin,
|
||||
killed: childProcess.killed,
|
||||
exitCode: childProcess.exitCode
|
||||
});
|
||||
|
||||
const isBatchMode = !!prompt;
|
||||
const isStreamJsonMode = hasImages && !!prompt;
|
||||
|
||||
const managedProcess: ManagedProcess = {
|
||||
sessionId,
|
||||
@@ -144,41 +250,111 @@ export class ProcessManager extends EventEmitter {
|
||||
pid: childProcess.pid || -1,
|
||||
isTerminal: false,
|
||||
isBatchMode,
|
||||
isStreamJsonMode,
|
||||
jsonBuffer: isBatchMode ? '' : undefined,
|
||||
};
|
||||
|
||||
this.processes.set(sessionId, managedProcess);
|
||||
|
||||
console.log('[ProcessManager] Setting up stdout/stderr/exit handlers for session:', sessionId);
|
||||
console.log('[ProcessManager] childProcess.stdout:', childProcess.stdout ? 'exists' : 'null');
|
||||
console.log('[ProcessManager] childProcess.stderr:', childProcess.stderr ? 'exists' : 'null');
|
||||
|
||||
// Handle stdout
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
if (childProcess.stdout) {
|
||||
console.log('[ProcessManager] Attaching stdout data listener...');
|
||||
childProcess.stdout.setEncoding('utf8'); // Ensure proper encoding
|
||||
childProcess.stdout.on('error', (err) => {
|
||||
console.error('[ProcessManager] stdout error:', err);
|
||||
});
|
||||
childProcess.stdout.on('data', (data: Buffer | string) => {
|
||||
console.log('[ProcessManager] >>> STDOUT EVENT FIRED <<<');
|
||||
console.log('[ProcessManager] stdout event fired for session:', sessionId);
|
||||
const output = data.toString();
|
||||
|
||||
console.log('[ProcessManager] stdout data received:', {
|
||||
sessionId,
|
||||
isBatchMode,
|
||||
isStreamJsonMode,
|
||||
dataLength: output.length,
|
||||
dataPreview: output.substring(0, 200)
|
||||
});
|
||||
|
||||
if (isBatchMode) {
|
||||
// In batch mode, accumulate JSON output
|
||||
if (isStreamJsonMode) {
|
||||
// In stream-json mode, each line is a JSONL message
|
||||
// Accumulate and process complete lines
|
||||
managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output;
|
||||
|
||||
// Process complete lines
|
||||
const lines = managedProcess.jsonBuffer.split('\n');
|
||||
// Keep the last incomplete line in the buffer
|
||||
managedProcess.jsonBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
// Handle different message types from stream-json output
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
// Extract text from content blocks
|
||||
const textContent = msg.message.content
|
||||
.filter((block: any) => block.type === 'text')
|
||||
.map((block: any) => block.text)
|
||||
.join('');
|
||||
if (textContent) {
|
||||
this.emit('data', sessionId, textContent);
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.result) {
|
||||
this.emit('data', sessionId, msg.result);
|
||||
}
|
||||
// Capture session_id from any message type
|
||||
if (msg.session_id) {
|
||||
this.emit('session-id', sessionId, msg.session_id);
|
||||
}
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, emit as raw text
|
||||
console.log('[ProcessManager] Non-JSON line in stream-json mode:', line.substring(0, 100));
|
||||
this.emit('data', sessionId, line);
|
||||
}
|
||||
}
|
||||
} else if (isBatchMode) {
|
||||
// In regular batch mode, accumulate JSON output
|
||||
managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output;
|
||||
console.log('[ProcessManager] Accumulated JSON buffer length:', managedProcess.jsonBuffer.length);
|
||||
} else {
|
||||
// In interactive mode, emit data immediately
|
||||
this.emit('data', sessionId, output);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log('[ProcessManager] WARNING: childProcess.stdout is null!');
|
||||
}
|
||||
|
||||
// Handle stderr
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
this.emit('data', sessionId, `[stderr] ${data.toString()}`);
|
||||
});
|
||||
if (childProcess.stderr) {
|
||||
console.log('[ProcessManager] Attaching stderr data listener...');
|
||||
childProcess.stderr.setEncoding('utf8');
|
||||
childProcess.stderr.on('error', (err) => {
|
||||
console.error('[ProcessManager] stderr error:', err);
|
||||
});
|
||||
childProcess.stderr.on('data', (data: Buffer | string) => {
|
||||
console.log('[ProcessManager] >>> STDERR EVENT FIRED <<<', data.toString().substring(0, 100));
|
||||
this.emit('data', sessionId, `[stderr] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle exit
|
||||
childProcess.on('exit', (code) => {
|
||||
if (isBatchMode && managedProcess.jsonBuffer) {
|
||||
// Parse JSON response from batch mode
|
||||
console.log('[ProcessManager] Child process exit event:', {
|
||||
sessionId,
|
||||
code,
|
||||
isBatchMode,
|
||||
isStreamJsonMode,
|
||||
jsonBufferLength: managedProcess.jsonBuffer?.length || 0,
|
||||
jsonBufferPreview: managedProcess.jsonBuffer?.substring(0, 200)
|
||||
});
|
||||
if (isBatchMode && !isStreamJsonMode && managedProcess.jsonBuffer) {
|
||||
// Parse JSON response from regular batch mode (not stream-json)
|
||||
try {
|
||||
const jsonResponse = JSON.parse(managedProcess.jsonBuffer);
|
||||
|
||||
@@ -215,6 +391,24 @@ export class ProcessManager extends EventEmitter {
|
||||
this.processes.delete(sessionId);
|
||||
});
|
||||
|
||||
// Handle stdin for batch mode
|
||||
if (isStreamJsonMode && prompt && images) {
|
||||
// Stream-json mode with images: send the message via stdin
|
||||
const streamJsonMessage = buildStreamJsonMessage(prompt, images);
|
||||
console.log('[ProcessManager] Sending stream-json message with images:', {
|
||||
sessionId,
|
||||
messageLength: streamJsonMessage.length,
|
||||
imageCount: images.length
|
||||
});
|
||||
childProcess.stdin?.write(streamJsonMessage + '\n');
|
||||
childProcess.stdin?.end(); // Signal end of input
|
||||
} else if (isBatchMode) {
|
||||
// Regular batch mode: close stdin immediately since prompt is passed as CLI arg
|
||||
// Some CLIs wait for stdin to close before processing
|
||||
console.log('[ProcessManager] Closing stdin for batch mode (prompt passed as CLI arg)');
|
||||
childProcess.stdin?.end();
|
||||
}
|
||||
|
||||
return { pid: childProcess.pid || -1, success: true };
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -234,21 +234,23 @@ export default function MaestroConsole() {
|
||||
|
||||
if (!isClaudeBatchMode) {
|
||||
// Only spawn for non-batch-mode agents
|
||||
// Use agent.path (full path) if available for better cross-environment compatibility
|
||||
aiSpawnResult = await window.maestro.process.spawn({
|
||||
sessionId: `${correctedSession.id}-ai`,
|
||||
toolType: aiAgentType,
|
||||
cwd: correctedSession.cwd,
|
||||
command: agent.command,
|
||||
command: agent.path || agent.command,
|
||||
args: agent.args || []
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Spawn terminal process
|
||||
// Use terminalAgent.path (full path) if available
|
||||
const terminalSpawnResult = await window.maestro.process.spawn({
|
||||
sessionId: `${correctedSession.id}-terminal`,
|
||||
toolType: 'terminal',
|
||||
cwd: correctedSession.cwd,
|
||||
command: terminalAgent.command,
|
||||
command: terminalAgent.path || terminalAgent.command,
|
||||
args: terminalAgent.args || []
|
||||
});
|
||||
|
||||
@@ -1145,11 +1147,12 @@ export default function MaestroConsole() {
|
||||
// Spawn BOTH processes - this is the dual-process architecture
|
||||
try {
|
||||
// 1. Spawn AI agent process
|
||||
// Use agent.path (full path) if available for better cross-environment compatibility
|
||||
const aiSpawnResult = await window.maestro.process.spawn({
|
||||
sessionId: `${newId}-ai`,
|
||||
toolType: agentId,
|
||||
cwd: workingDir,
|
||||
command: agent.command,
|
||||
command: agent.path || agent.command,
|
||||
args: agent.args || []
|
||||
});
|
||||
|
||||
@@ -1158,11 +1161,12 @@ export default function MaestroConsole() {
|
||||
}
|
||||
|
||||
// 2. Spawn terminal process
|
||||
// Use terminalAgent.path (full path) if available
|
||||
const terminalSpawnResult = await window.maestro.process.spawn({
|
||||
sessionId: `${newId}-terminal`,
|
||||
toolType: 'terminal',
|
||||
cwd: workingDir,
|
||||
command: terminalAgent.command,
|
||||
command: terminalAgent.path || terminalAgent.command,
|
||||
args: terminalAgent.args || []
|
||||
});
|
||||
|
||||
@@ -1449,8 +1453,9 @@ export default function MaestroConsole() {
|
||||
})();
|
||||
}
|
||||
|
||||
// Capture input value before clearing (needed for async batch mode spawn)
|
||||
// Capture input value and images before clearing (needed for async batch mode spawn)
|
||||
const capturedInputValue = inputValue;
|
||||
const capturedImages = [...stagedImages];
|
||||
|
||||
setInputValue('');
|
||||
setStagedImages([]);
|
||||
@@ -1500,13 +1505,18 @@ export default function MaestroConsole() {
|
||||
}
|
||||
|
||||
// Spawn Claude with prompt as argument (use captured value)
|
||||
// If images are present, they will be passed via stream-json input format
|
||||
// Use agent.path (full path) if available, otherwise fall back to agent.command
|
||||
const commandToUse = agent.path || agent.command;
|
||||
console.log('[processInput] Spawning Claude:', { command: commandToUse, path: agent.path, fallback: agent.command });
|
||||
await window.maestro.process.spawn({
|
||||
sessionId: targetSessionId,
|
||||
toolType: 'claude-code',
|
||||
cwd: activeSession.cwd,
|
||||
command: agent.command,
|
||||
command: commandToUse,
|
||||
args: spawnArgs,
|
||||
prompt: capturedInputValue
|
||||
prompt: capturedInputValue,
|
||||
images: capturedImages.length > 0 ? capturedImages : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to spawn Claude batch process:', error);
|
||||
@@ -1897,6 +1907,46 @@ export default function MaestroConsole() {
|
||||
setFlatFileList(flattenTree(filteredFileTree, expandedSet));
|
||||
}, [activeSession?.fileExplorerExpanded, filteredFileTree]);
|
||||
|
||||
// Handle pending jump path from /jump command
|
||||
useEffect(() => {
|
||||
if (!activeSession || activeSession.pendingJumpPath === undefined || flatFileList.length === 0) return;
|
||||
|
||||
const jumpPath = activeSession.pendingJumpPath;
|
||||
|
||||
// Find the target index
|
||||
let targetIndex = 0;
|
||||
|
||||
if (jumpPath === '') {
|
||||
// Jump to root - select first item
|
||||
targetIndex = 0;
|
||||
} else {
|
||||
// Find the folder in the flat list, then select its first child
|
||||
const folderIndex = flatFileList.findIndex(item => item.fullPath === jumpPath && item.isFolder);
|
||||
|
||||
if (folderIndex !== -1 && folderIndex + 1 < flatFileList.length) {
|
||||
// Check if the next item is a child of this folder
|
||||
const nextItem = flatFileList[folderIndex + 1];
|
||||
if (nextItem.fullPath.startsWith(jumpPath + '/')) {
|
||||
// Select the first child
|
||||
targetIndex = folderIndex + 1;
|
||||
} else {
|
||||
// Folder has no visible children, select the folder itself
|
||||
targetIndex = folderIndex;
|
||||
}
|
||||
} else if (folderIndex !== -1) {
|
||||
// Folder found but no children, select the folder
|
||||
targetIndex = folderIndex;
|
||||
}
|
||||
// If folder not found, stay at 0
|
||||
}
|
||||
|
||||
setSelectedFileIndex(targetIndex);
|
||||
|
||||
// Clear the pending jump path
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, pendingJumpPath: undefined } : s
|
||||
));
|
||||
}, [activeSession?.pendingJumpPath, flatFileList, activeSession?.id]);
|
||||
|
||||
// Scroll to selected file item when selection changes
|
||||
useEffect(() => {
|
||||
@@ -2218,9 +2268,10 @@ export default function MaestroConsole() {
|
||||
onResumeClaudeSession={(claudeSessionId: string, messages: LogEntry[]) => {
|
||||
// Update the active session with the selected Claude session ID and load messages
|
||||
// Also reset state to 'idle' since we're just loading historical messages
|
||||
// Switch to AI mode since we're resuming an AI session
|
||||
if (activeSession) {
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle' } : s
|
||||
s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s
|
||||
));
|
||||
setActiveClaudeSessionId(claudeSessionId);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ interface AgentSessionsBrowserProps {
|
||||
activeSession: Session | undefined;
|
||||
activeClaudeSessionId: string | null;
|
||||
onClose: () => void;
|
||||
onSelectSession: (claudeSessionId: string) => void;
|
||||
onResumeSession: (claudeSessionId: string, messages: LogEntry[]) => void;
|
||||
onNewSession: () => void;
|
||||
}
|
||||
@@ -45,7 +44,6 @@ export function AgentSessionsBrowser({
|
||||
activeSession,
|
||||
activeClaudeSessionId,
|
||||
onClose,
|
||||
onSelectSession,
|
||||
onResumeSession,
|
||||
onNewSession,
|
||||
}: AgentSessionsBrowserProps) {
|
||||
@@ -171,6 +169,13 @@ export function AgentSessionsBrowser({
|
||||
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages);
|
||||
// Scroll to bottom after initial load and focus the container for keyboard nav
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
messagesContainerRef.current.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Prepend older messages
|
||||
setMessages(prev => [...result.messages, ...prev]);
|
||||
@@ -272,6 +277,10 @@ export function AgentSessionsBrowser({
|
||||
e.preventDefault();
|
||||
setViewingSession(null);
|
||||
setMessages([]);
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter in session details view resumes the session
|
||||
e.preventDefault();
|
||||
handleResume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -294,14 +303,7 @@ export function AgentSessionsBrowser({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle selecting/resuming a session
|
||||
const handleSelect = useCallback(() => {
|
||||
if (viewingSession) {
|
||||
onSelectSession(viewingSession.sessionId);
|
||||
onClose();
|
||||
}
|
||||
}, [viewingSession, onSelectSession, onClose]);
|
||||
|
||||
// Handle resuming a session
|
||||
const handleResume = useCallback(() => {
|
||||
if (viewingSession) {
|
||||
// Convert messages to LogEntry format for AI terminal
|
||||
@@ -388,30 +390,17 @@ export function AgentSessionsBrowser({
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{viewingSession ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelect}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textMain,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResume}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: theme.colors.accentText,
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Resume
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
onClick={handleResume}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: theme.colors.accentText,
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Resume
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
@@ -439,8 +428,10 @@ export function AgentSessionsBrowser({
|
||||
{viewingSession ? (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto p-6 space-y-4"
|
||||
className="flex-1 overflow-y-auto p-6 space-y-4 outline-none"
|
||||
onScroll={handleMessagesScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Load more indicator */}
|
||||
{hasMoreMessages && (
|
||||
|
||||
@@ -155,7 +155,6 @@ export function MainPanel(props: MainPanelProps) {
|
||||
activeSession={activeSession || undefined}
|
||||
activeClaudeSessionId={activeClaudeSessionId}
|
||||
onClose={() => setAgentSessionsOpen(false)}
|
||||
onSelectSession={setActiveClaudeSessionId}
|
||||
onResumeSession={onResumeClaudeSession}
|
||||
onNewSession={onNewClaudeSession}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
|
||||
// Layer stack integration
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Define handlers first before they're used in effects
|
||||
const loadAgents = async () => {
|
||||
@@ -100,6 +101,13 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
|
||||
}
|
||||
}, [isOpen, onClose, updateLayerHandler]);
|
||||
|
||||
// Focus modal on open (only once, not on every render)
|
||||
useEffect(() => {
|
||||
if (isOpen && modalRef.current) {
|
||||
modalRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -109,7 +117,7 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
|
||||
aria-modal="true"
|
||||
aria-label="Create New Agent"
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
ref={modalRef}
|
||||
onKeyDown={(e) => {
|
||||
// Handle Cmd+O for folder picker before stopping propagation
|
||||
if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const slashCommands: SlashCommand[] = [
|
||||
command: '/jump',
|
||||
description: 'Jump to CWD in file tree',
|
||||
execute: (context: SlashCommandContext) => {
|
||||
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus, setSelectedFileIndex } = context;
|
||||
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus } = context;
|
||||
|
||||
// Use fallback to first session if activeSessionId is empty
|
||||
const actualActiveId = activeSessionId || (sessions.length > 0 ? sessions[0].id : '');
|
||||
@@ -59,14 +59,16 @@ export const slashCommands: SlashCommand[] = [
|
||||
if (setRightPanelOpen) setRightPanelOpen(true);
|
||||
if (setActiveRightTab) setActiveRightTab('files');
|
||||
if (setActiveFocus) setActiveFocus('right');
|
||||
if (setSelectedFileIndex) setSelectedFileIndex(0);
|
||||
|
||||
// Expand all parent folders in the path (using relative paths to match file tree)
|
||||
// Calculate the relative path from session cwd to target directory
|
||||
const relativePath = targetDir.replace(activeSession.cwd, '').replace(/^\//, '');
|
||||
|
||||
// Expand all parent folders in the path and set pendingJumpPath
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== actualActiveId) return s;
|
||||
|
||||
// Build list of relative parent paths to expand
|
||||
const pathParts = targetDir.replace(s.cwd, '').split('/').filter(Boolean);
|
||||
const pathParts = relativePath.split('/').filter(Boolean);
|
||||
const expandPaths: string[] = [];
|
||||
|
||||
let currentPath = '';
|
||||
@@ -80,7 +82,9 @@ export const slashCommands: SlashCommand[] = [
|
||||
|
||||
return {
|
||||
...s,
|
||||
fileExplorerExpanded: Array.from(newExpanded)
|
||||
fileExplorerExpanded: Array.from(newExpanded),
|
||||
// Set pending jump path - will be processed by App.tsx once flatFileList updates
|
||||
pendingJumpPath: relativePath || ''
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface Session {
|
||||
scratchPadMode?: 'edit' | 'preview';
|
||||
// Claude Code session ID for conversation continuity
|
||||
claudeSessionId?: string;
|
||||
// Pending jump path for /jump command (relative path within file tree)
|
||||
pendingJumpPath?: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
Reference in New Issue
Block a user