diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 00000000..eb488911 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto Assign + +on: + issues: + types: [opened] + pull_request: + types: [opened] + +jobs: + assign: + runs-on: ubuntu-latest + steps: + - name: Assign to pedramamini + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue?.number || context.payload.pull_request?.number; + if (issueNumber) { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + assignees: ['pedramamini'] + }); + } diff --git a/CLAUDE.md b/CLAUDE.md index 7e00a869..72323330 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,8 @@ interface Session { toolType: ToolType; // 'claude-code' | 'aider' | 'terminal' | etc. state: SessionState; // 'idle' | 'busy' | 'error' | 'connecting' inputMode: 'ai' | 'terminal'; // Which process receives input - cwd: string; // Working directory + cwd: string; // Current working directory (can change via cd) + projectRoot: string; // Initial working directory (never changes, used for Claude session storage) aiPid: number; // AI process ID terminalPid: number; // Terminal process ID aiLogs: LogEntry[]; // AI output history diff --git a/src/main/index.ts b/src/main/index.ts index 0f0f08d5..e6477c42 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3187,23 +3187,40 @@ function setupIpcHandlers() { // Get all named sessions across all projects (for Tab Switcher "All Named" view) ipcMain.handle('claude:getAllNamedSessions', async () => { + const os = await import('os'); + const homeDir = os.default.homedir(); + const claudeProjectsDir = path.join(homeDir, '.claude', 'projects'); + const allOrigins = claudeSessionOriginsStore.get('origins', {}); const namedSessions: Array<{ claudeSessionId: string; projectPath: string; sessionName: string; starred?: boolean; + lastActivityAt?: number; }> = []; for (const [projectPath, sessions] of Object.entries(allOrigins)) { for (const [claudeSessionId, info] of Object.entries(sessions)) { // Handle both old string format and new object format if (typeof info === 'object' && info.sessionName) { + // Try to get last activity time from the session file + let lastActivityAt: number | undefined; + try { + const encodedPath = encodeClaudeProjectPath(projectPath); + const sessionFile = path.join(claudeProjectsDir, encodedPath, `${claudeSessionId}.jsonl`); + const stats = await fs.stat(sessionFile); + lastActivityAt = stats.mtime.getTime(); + } catch { + // Session file may not exist or be inaccessible + } + namedSessions.push({ claudeSessionId, projectPath, sessionName: info.sessionName, starred: info.starred, + lastActivityAt, }); } } diff --git a/src/main/preload.ts b/src/main/preload.ts index 5c8f33dc..5e266443 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -418,6 +418,7 @@ contextBridge.exposeInMainWorld('maestro', { projectPath: string; sessionName: string; starred?: boolean; + lastActivityAt?: number; }>>, deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => ipcRenderer.invoke('claude:deleteMessagePair', projectPath, sessionId, userMessageUuid, fallbackContent), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 947b167b..66d35b96 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -337,6 +337,11 @@ export default function MaestroConsole() { // Restore a persisted session by respawning its process const restoreSession = async (session: Session): Promise => { try { + // Migration: ensure projectRoot is set (for sessions created before this field was added) + if (!session.projectRoot) { + session = { ...session, projectRoot: session.cwd }; + } + // Sessions must have aiTabs - if missing, this is a data corruption issue if (!session.aiTabs || session.aiTabs.length === 0) { console.error('[restoreSession] Session has no aiTabs - data corruption, skipping:', session.id); @@ -3621,6 +3626,7 @@ export default function MaestroConsole() { state: 'idle', cwd: workingDir, fullPath: workingDir, + projectRoot: workingDir, // Store the initial directory (never changes) isGitRepo, gitBranches, gitTags, @@ -3989,13 +3995,8 @@ export default function MaestroConsole() { return; } - // Block slash commands when agent is busy (in AI mode) - if (effectiveInputValue.trim().startsWith('/') && activeSession.state === 'busy' && activeSession.inputMode === 'ai') { - showFlashNotification('Commands disabled while agent is working'); - return; - } - // Handle slash commands (custom AI commands only - built-in commands have been removed) + // Note: slash commands are queued like regular messages when agent is busy if (effectiveInputValue.trim().startsWith('/')) { const commandText = effectiveInputValue.trim(); const isTerminalMode = activeSession.inputMode === 'terminal'; @@ -5073,152 +5074,13 @@ export default function MaestroConsole() { } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSlashCommandIndex(prev => Math.max(prev - 1, 0)); - } else if (e.key === 'Tab') { - // Tab just fills in the command text + } else if (e.key === 'Tab' || e.key === 'Enter') { + // Tab or Enter fills in the command text (user can then press Enter again to execute) e.preventDefault(); - setInputValue(filteredCommands[selectedSlashCommandIndex]?.command || inputValue); - setSlashCommandOpen(false); - } else if (e.key === 'Enter' && filteredCommands.length > 0) { - // Enter executes the command directly - e.preventDefault(); - const selectedCommand = filteredCommands[selectedSlashCommandIndex]; - if (selectedCommand) { + if (filteredCommands[selectedSlashCommandIndex]) { + setInputValue(filteredCommands[selectedSlashCommandIndex].command); setSlashCommandOpen(false); - setInputValue(''); - if (inputRef.current) inputRef.current.style.height = 'auto'; - - // Execute the custom AI command (substitute template variables and send to agent) - if ('prompt' in selectedCommand && selectedCommand.prompt) { - // Use the same spawn logic as processInput for proper tab-based session ID tracking - (async () => { - let gitBranch: string | undefined; - if (activeSession.isGitRepo) { - try { - const status = await gitService.getStatus(activeSession.cwd); - gitBranch = status.branch; - } catch { - // Ignore git errors - } - } - const substitutedPrompt = substituteTemplateVariables( - selectedCommand.prompt, - { session: activeSession, gitBranch } - ); - - // Get the active tab for proper targeting - const activeTab = getActiveTab(activeSession); - if (!activeTab) { - console.error('[handleInputKeyDown] No active tab for slash command'); - return; - } - - // Build target session ID using tab ID (same pattern as processInput) - const targetSessionId = `${activeSessionId}-ai-${activeTab.id}`; - const isNewSession = !activeTab.claudeSessionId; - - // Add user log showing the command with its interpolated prompt to active tab - const newEntry: LogEntry = { - id: generateId(), - timestamp: Date.now(), - source: 'user', - text: substitutedPrompt, - aiCommand: { - command: selectedCommand.command, - description: selectedCommand.description - } - }; - - // Update session state: add log, set busy, set awaitingSessionId for new sessions - setSessions(prev => prev.map(s => { - if (s.id !== activeSessionId) return s; - - // Update the active tab's logs and state - const updatedAiTabs = s.aiTabs.map(tab => - tab.id === activeTab.id - ? { - ...tab, - logs: [...tab.logs, newEntry], - state: 'busy' as const, - thinkingStartTime: Date.now(), - awaitingSessionId: isNewSession ? true : tab.awaitingSessionId - } - : tab - ); - - return { - ...s, - state: 'busy' as SessionState, - busySource: 'ai', - thinkingStartTime: Date.now(), - currentCycleTokens: 0, - currentCycleBytes: 0, - aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), selectedCommand.command])).slice(-50), - pendingAICommandForSynopsis: selectedCommand.command, - aiTabs: updatedAiTabs - }; - })); - - // Spawn the agent with proper session ID format (same as processInput) - try { - const agent = await window.maestro.agents.get('claude-code'); - if (!agent) throw new Error('Claude Code agent not found'); - - // Get fresh session state to avoid stale closure - const freshSession = sessionsRef.current.find(s => s.id === activeSessionId); - if (!freshSession) throw new Error('Session not found'); - - const freshActiveTab = getActiveTab(freshSession); - const tabClaudeSessionId = freshActiveTab?.claudeSessionId; - - // Build spawn args with resume if we have a session ID - const spawnArgs = [...(agent.args || [])]; - if (tabClaudeSessionId) { - spawnArgs.push('--resume', tabClaudeSessionId); - } - - // Add read-only mode if tab has it enabled - if (freshActiveTab?.readOnlyMode) { - spawnArgs.push('--permission-mode', 'plan'); - } - - const commandToUse = agent.path || agent.command; - await window.maestro.process.spawn({ - sessionId: targetSessionId, - toolType: 'claude-code', - cwd: freshSession.cwd, - command: commandToUse, - args: spawnArgs, - prompt: substitutedPrompt - }); - } catch (error: any) { - console.error('[handleInputKeyDown] Failed to spawn Claude for slash command:', error); - setSessions(prev => prev.map(s => { - if (s.id !== activeSessionId) return s; - const errorEntry: LogEntry = { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `Error: Failed to run ${selectedCommand.command} - ${error.message}` - }; - const updatedAiTabs = s.aiTabs.map(tab => - tab.id === activeTab.id - ? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorEntry] } - : tab - ); - return { - ...s, - state: 'idle' as SessionState, - busySource: undefined, - aiTabs: updatedAiTabs - }; - })); - } - })(); - } else { - // Claude Code slash command (no prompt property) - send raw command text - // Claude Code will expand the command itself from .claude/commands/*.md - processInput(selectedCommand.command); - } + inputRef.current?.focus(); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -6579,7 +6441,7 @@ export default function MaestroConsole() { theme={theme} tabs={activeSession.aiTabs} activeTabId={activeSession.activeTabId} - cwd={activeSession.cwd} + projectRoot={activeSession.projectRoot} shortcut={TAB_SHORTCUTS.tabSwitcher} onTabSelect={(tabId) => { setSessions(prev => prev.map(s => diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index ab18a3aa..14fef220 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -154,7 +154,11 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { ); // Refs for slash command items to enable scroll-into-view + // Reset refs array length when filtered commands change to avoid stale refs const slashCommandItemRefs = useRef<(HTMLDivElement | null)[]>([]); + if (slashCommandItemRefs.current.length !== filteredSlashCommands.length) { + slashCommandItemRefs.current = slashCommandItemRefs.current.slice(0, filteredSlashCommands.length); + } // Refs for tab completion items to enable scroll-into-view const tabCompletionItemRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -264,10 +268,10 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { {/* Slash Command Autocomplete */} {slashCommandOpen && filteredSlashCommands.length > 0 && (
-
+
{filteredSlashCommands.map((cmd, idx) => (
{ + // Single click just selects the item + setSelectedSlashCommandIndex(idx); + }} + onDoubleClick={() => { + // Double click fills in the command text setInputValue(cmd.command); setSlashCommandOpen(false); inputRef.current?.focus(); - // Execute the command after a brief delay to let state update - setTimeout(() => processInput(), 10); }} onMouseEnter={() => setSelectedSlashCommandIndex(idx)} > @@ -555,9 +562,12 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { // Show slash command autocomplete when typing / if (value.startsWith('/') && !value.includes(' ')) { + // Only reset selection when modal first opens, not on every keystroke + if (!slashCommandOpen) { + setSelectedSlashCommandIndex(0); + } setSlashCommandOpen(true); - // Always reset selection to first item when filter changes - setSelectedSlashCommandIndex(0); + // Clamp selection if filtered list shrinks (handled by safeSelectedIndex in render) } else { setSlashCommandOpen(false); } diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 77232ef8..2529785c 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -611,7 +611,7 @@ export function SessionList(props: SessionListProps) { return (
void; onNamedSessionSelect: (claudeSessionId: string, projectPath: string, sessionName: string, starred?: boolean) => void; @@ -49,6 +50,32 @@ function formatCost(cost: number): string { return '$' + cost.toFixed(2); } +/** + * Format a timestamp as relative time (e.g., "5m ago", "2h ago", "Dec 3") + */ +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +/** + * Get the last activity timestamp from a tab's logs + */ +function getTabLastActivity(tab: AITab): number | undefined { + if (!tab.logs || tab.logs.length === 0) return undefined; + // Get the most recent log entry timestamp + return Math.max(...tab.logs.map(log => log.timestamp)); +} + /** * Get context usage percentage from usage stats * Uses inputTokens + outputTokens (not cache tokens) to match MainPanel calculation @@ -141,7 +168,7 @@ export function TabSwitcherModal({ theme, tabs, activeTabId, - cwd, + projectRoot, shortcut, onTabSelect, onNamedSessionSelect, @@ -208,7 +235,7 @@ export function TabSwitcherModal({ const namedTabs = tabs.filter(t => t.name && t.claudeSessionId); await Promise.all( namedTabs.map(tab => - window.maestro.claude.updateSessionName(cwd, tab.claudeSessionId!, tab.name!) + window.maestro.claude.updateSessionName(projectRoot, tab.claudeSessionId!, tab.name!) .catch(err => console.warn('[TabSwitcher] Failed to sync tab name:', err)) ) ); @@ -221,7 +248,7 @@ export function TabSwitcherModal({ if (!namedSessionsLoaded) { syncAndLoad(); } - }, [namedSessionsLoaded, tabs, cwd]); + }, [namedSessionsLoaded, tabs, projectRoot]); // Scroll selected item into view useEffect(() => { @@ -254,7 +281,7 @@ export function TabSwitcherModal({ }); return sorted.map(tab => ({ type: 'open' as const, tab })); } else { - // All Named mode - show ALL named sessions (including open ones) + // All Named mode - show named sessions for the CURRENT PROJECT only (including open ones) // For open tabs, use the 'open' type so we get usage stats; for closed ones use 'named' const items: ListItem[] = []; @@ -265,9 +292,9 @@ export function TabSwitcherModal({ } } - // Add closed named sessions (not currently open) + // Add closed named sessions from the SAME PROJECT (not currently open) for (const session of namedSessions) { - if (!openTabSessionIds.has(session.claudeSessionId)) { + if (session.projectPath === projectRoot && !openTabSessionIds.has(session.claudeSessionId)) { items.push({ type: 'named' as const, session }); } } @@ -281,7 +308,7 @@ export function TabSwitcherModal({ return items; } - }, [viewMode, tabs, namedSessions, openTabSessionIds]); + }, [viewMode, tabs, namedSessions, openTabSessionIds, projectRoot]); // Filter items based on search query const filteredItems = useMemo(() => { @@ -518,6 +545,10 @@ export function TabSwitcherModal({ {formatCost(cost)} )} + {(() => { + const lastActivity = getTabLastActivity(tab); + return lastActivity ? {formatRelativeTime(lastActivity)} : null; + })()}
@@ -578,7 +609,9 @@ export function TabSwitcherModal({ )}
- {session.projectPath.split('/').slice(-2).join('/')} + {session.lastActivityAt && ( + {formatRelativeTime(session.lastActivityAt)} + )}
diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index 6fa6506d..561e01ed 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -578,7 +578,7 @@ const LogItemComponent = memo(({ ) : isAIMode && !markdownRawMode ? ( // Collapsed markdown preview with rendered markdown // Note: prose styles are injected once at TerminalOutput container level for performance -
+
+
+
((p .prose p { color: ${theme.colors.textMain}; margin: 0 !important; line-height: 1.4; } .prose p + p { margin-top: 0.5em !important; } .prose p:empty { display: none; } - .prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.25em 0 !important; padding-left: 0.5em; list-style-position: inside; } - .prose li ul, .prose li ol { margin: 0 !important; padding-left: 1em; list-style-position: inside; } + .prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.25em 0 !important; padding-left: 2em; list-style-position: outside; } + .prose li ul, .prose li ol { margin: 0 !important; padding-left: 1.5em; list-style-position: outside; } .prose li { margin: 0 !important; padding: 0; line-height: 1.4; display: list-item; } .prose li > p { margin: 0 !important; display: contents !important; } .prose li > p + ul, .prose li > p + ol { margin-top: 0 !important; } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index bf46508c..e662eff9 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -308,6 +308,7 @@ interface MaestroAPI { projectPath: string; sessionName: string; starred?: boolean; + lastActivityAt?: number; }>>; deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>; }; diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index ed5fc143..c1791c0a 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -261,6 +261,7 @@ export interface Session { state: SessionState; cwd: string; fullPath: string; + projectRoot: string; // The initial working directory (never changes, used for Claude session storage) aiLogs: LogEntry[]; shellLogs: LogEntry[]; workLog: WorkLogItem[];