From ed443a2a37a87b147e54e2818bcefc6c492b68b7 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 26 Nov 2025 23:00:43 -0600 Subject: [PATCH] feat: Add session favorites, recent sessions quick access, and window state persistence - Add starred/favorite sessions in Agent Sessions modal (persisted per-project) - Add recent Claude sessions hover tooltip on Agent Sessions button for quick access - Remember window size, position, and maximized/fullscreen state across restarts - Fix light theme text colors for user message bubbles in chat views - Fix auto-scroll to pause when user has expanded log entries - Move history graph tooltip below the graph to avoid overlap - Adjust warning colors in vibe mode themes for better contrast --- src/main/index.ts | 61 +++++++- src/renderer/App.tsx | 57 ++++++++ .../components/AgentSessionsBrowser.tsx | 4 +- .../components/AgentSessionsModal.tsx | 138 +++++++++++++----- src/renderer/components/HistoryPanel.tsx | 4 +- src/renderer/components/MainPanel.tsx | 94 +++++++++++- src/renderer/components/TerminalOutput.tsx | 56 +++---- src/renderer/constants/themes.ts | 4 +- 8 files changed, 338 insertions(+), 80 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index fdeef74f..d9af1fed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -81,6 +81,26 @@ const agentConfigsStore = new Store({ }, }); +// Window state store (for remembering window size/position) +interface WindowState { + x?: number; + y?: number; + width: number; + height: number; + isMaximized: boolean; + isFullScreen: boolean; +} + +const windowStateStore = new Store({ + name: 'maestro-window-state', + defaults: { + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, + }, +}); + // History entries store (per-project history for AUTO and USER entries) interface HistoryEntry { id: string; @@ -120,9 +140,14 @@ let sessionWebServerManager: SessionWebServerManager | null = null; let agentDetector: AgentDetector | null = null; function createWindow() { + // Restore saved window state + const savedState = windowStateStore.store; + mainWindow = new BrowserWindow({ - width: 1400, - height: 900, + x: savedState.x, + y: savedState.y, + width: savedState.width, + height: savedState.height, minWidth: 1000, minHeight: 600, backgroundColor: '#0b0b0d', @@ -134,11 +159,41 @@ function createWindow() { }, }); + // Restore maximized/fullscreen state after window is created + if (savedState.isFullScreen) { + mainWindow.setFullScreen(true); + } else if (savedState.isMaximized) { + mainWindow.maximize(); + } + logger.info('Browser window created', 'Window', { - size: '1400x900', + size: `${savedState.width}x${savedState.height}`, + maximized: savedState.isMaximized, + fullScreen: savedState.isFullScreen, mode: process.env.NODE_ENV || 'production' }); + // Save window state before closing + const saveWindowState = () => { + if (!mainWindow) return; + + const isMaximized = mainWindow.isMaximized(); + const isFullScreen = mainWindow.isFullScreen(); + const bounds = mainWindow.getBounds(); + + // Only save bounds if not maximized/fullscreen (to restore proper size later) + if (!isMaximized && !isFullScreen) { + windowStateStore.set('x', bounds.x); + windowStateStore.set('y', bounds.y); + windowStateStore.set('width', bounds.width); + windowStateStore.set('height', bounds.height); + } + windowStateStore.set('isMaximized', isMaximized); + windowStateStore.set('isFullScreen', isFullScreen); + }; + + mainWindow.on('close', saveWindowState); + // Load the app if (process.env.NODE_ENV === 'development') { mainWindow.loadURL('http://localhost:5173'); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0bd9b669..0e20f7fc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -155,6 +155,13 @@ export default function MaestroConsole() { const [agentSessionsOpen, setAgentSessionsOpen] = useState(false); const [activeClaudeSessionId, setActiveClaudeSessionId] = useState(null); + // Recent Claude sessions for quick access (breadcrumbs when session hopping) + const [recentClaudeSessions, setRecentClaudeSessions] = useState>([]); + // Batch Runner Modal State const [batchRunnerModalOpen, setBatchRunnerModalOpen] = useState(false); const [renameGroupId, setRenameGroupId] = useState(null); @@ -3117,6 +3124,18 @@ export default function MaestroConsole() { s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s )); setActiveClaudeSessionId(claudeSessionId); + + // Track this session in recent sessions list + const firstMessage = messages.find(m => m.source === 'user')?.text || ''; + setRecentClaudeSessions(prev => { + // Remove if already exists + const filtered = prev.filter(s => s.sessionId !== claudeSessionId); + // Add to front + return [ + { sessionId: claudeSessionId, firstMessage: firstMessage.slice(0, 100), timestamp: new Date().toISOString() }, + ...filtered + ].slice(0, 10); // Keep only last 10 + }); } }} onNewClaudeSession={() => { @@ -3248,6 +3267,44 @@ export default function MaestroConsole() { })); }} audioFeedbackCommand={audioFeedbackCommand} + recentClaudeSessions={recentClaudeSessions} + onResumeRecentSession={async (sessionId: string) => { + // Resume a session from the recent sessions list + if (!activeSession?.cwd) return; + + try { + // Load the session messages + const result = await window.maestro.claude.readSessionMessages( + activeSession.cwd, + sessionId, + { offset: 0, limit: 100 } + ); + + // Convert to log entries + const messages: LogEntry[] = result.messages.map((msg: { type: string; content: string; timestamp: string; uuid: string }) => ({ + id: msg.uuid || generateId(), + timestamp: new Date(msg.timestamp).getTime(), + source: msg.type === 'user' ? 'user' as const : 'stdout' as const, + text: msg.content || '' + })); + + // Update the session + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, claudeSessionId: sessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s + )); + setActiveClaudeSessionId(sessionId); + + // Move to front of recent list + setRecentClaudeSessions(prev => { + const session = prev.find(s => s.sessionId === sessionId); + if (!session) return prev; + const filtered = prev.filter(s => s.sessionId !== sessionId); + return [{ ...session, timestamp: new Date().toISOString() }, ...filtered]; + }); + } catch (error) { + console.error('Failed to resume session:', error); + } + }} /> {/* --- RIGHT PANEL --- */} diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 6e33666d..a9c13800 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -542,7 +542,7 @@ export function AgentSessionsBrowser({ className="max-w-[75%] rounded-lg px-4 py-3 text-sm" style={{ backgroundColor: msg.type === 'user' ? theme.colors.accent : theme.colors.bgActivity, - color: msg.type === 'user' ? theme.colors.accentText : theme.colors.textMain, + color: msg.type === 'user' ? (theme.mode === 'light' ? '#fff' : '#000') : theme.colors.textMain, }} >
@@ -550,7 +550,7 @@ export function AgentSessionsBrowser({
{formatRelativeTime(msg.timestamp)}
diff --git a/src/renderer/components/AgentSessionsModal.tsx b/src/renderer/components/AgentSessionsModal.tsx index 716984d2..fef039ff 100644 --- a/src/renderer/components/AgentSessionsModal.tsx +++ b/src/renderer/components/AgentSessionsModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2 } from 'lucide-react'; +import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2, Star } from 'lucide-react'; import type { Theme, Session } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -46,6 +46,7 @@ export function AgentSessionsModal({ const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); const [messagesOffset, setMessagesOffset] = useState(0); + const [starredSessions, setStarredSessions] = useState>(new Set()); const inputRef = useRef(null); const selectedItemRef = useRef(null); @@ -96,8 +97,13 @@ export function AgentSessionsModal({ } }, [viewingSession, updateLayerHandler]); - // Load sessions on mount + // Load sessions on mount and reset to list view useEffect(() => { + // Always reset to list view when modal opens + setViewingSession(null); + setMessages([]); + setMessagesOffset(0); + const loadSessions = async () => { if (!activeSession?.cwd) { console.log('AgentSessionsModal: No activeSession.cwd'); @@ -107,6 +113,13 @@ export function AgentSessionsModal({ console.log('AgentSessionsModal: Loading sessions for cwd:', activeSession.cwd); try { + // Load starred sessions for this project + const starredKey = `starredClaudeSessions:${activeSession.cwd}`; + const savedStarred = await window.maestro.settings.get(starredKey); + if (savedStarred && Array.isArray(savedStarred)) { + setStarredSessions(new Set(savedStarred)); + } + const result = await window.maestro.claude.listSessions(activeSession.cwd); console.log('AgentSessionsModal: Got sessions:', result.length); setSessions(result); @@ -120,6 +133,25 @@ export function AgentSessionsModal({ loadSessions(); }, [activeSession?.cwd]); + // Toggle star status for a session + const toggleStar = useCallback(async (sessionId: string, e: React.MouseEvent) => { + e.stopPropagation(); // Don't trigger session view + + const newStarred = new Set(starredSessions); + if (newStarred.has(sessionId)) { + newStarred.delete(sessionId); + } else { + newStarred.add(sessionId); + } + setStarredSessions(newStarred); + + // Persist to settings + if (activeSession?.cwd) { + const starredKey = `starredClaudeSessions:${activeSession.cwd}`; + await window.maestro.settings.set(starredKey, Array.from(newStarred)); + } + }, [starredSessions, activeSession?.cwd]); + // Focus input on mount useEffect(() => { const timer = setTimeout(() => inputRef.current?.focus(), 50); @@ -193,11 +225,20 @@ export function AgentSessionsModal({ } }, [hasMoreMessages, messagesLoading, handleLoadMore]); - // Filter sessions by search - const filteredSessions = sessions.filter(s => - s.firstMessage.toLowerCase().includes(search.toLowerCase()) || - s.sessionId.toLowerCase().includes(search.toLowerCase()) - ); + // Filter sessions by search and sort starred to top + const filteredSessions = sessions + .filter(s => + s.firstMessage.toLowerCase().includes(search.toLowerCase()) || + s.sessionId.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => { + const aStarred = starredSessions.has(a.sessionId); + const bStarred = starredSessions.has(b.sessionId); + if (aStarred && !bStarred) return -1; + if (!aStarred && bStarred) return 1; + // Within same starred status, sort by most recent + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + }); // Reset selected index when search changes useEffect(() => { @@ -357,7 +398,7 @@ export function AgentSessionsModal({ className="max-w-[85%] rounded-lg px-4 py-2 text-sm" style={{ backgroundColor: msg.type === 'user' ? theme.colors.accent : theme.colors.bgMain, - color: msg.type === 'user' ? theme.colors.accentText : theme.colors.textMain, + color: msg.type === 'user' ? (theme.mode === 'light' ? '#fff' : '#000') : theme.colors.textMain, }} >
@@ -365,7 +406,7 @@ export function AgentSessionsModal({
{formatRelativeTime(msg.timestamp)}
@@ -390,38 +431,55 @@ export function AgentSessionsModal({ {sessions.length === 0 ? 'No Claude sessions found for this project' : 'No sessions match your search'} ) : ( - filteredSessions.map((session, i) => ( - +
+
+ {session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`} +
+
+ + + {formatRelativeTime(session.modifiedAt)} + + + + {session.messageCount} msgs + + + + {formatSize(session.sizeBytes)} + +
-
- - - {formatRelativeTime(session.modifiedAt)} - - - - {session.messageCount} msgs - - - - {formatSize(session.sizeBytes)} - -
- - - )) + + ); + }) )} )} diff --git a/src/renderer/components/HistoryPanel.tsx b/src/renderer/components/HistoryPanel.tsx index 852c8dc8..1e2f93aa 100644 --- a/src/renderer/components/HistoryPanel.tsx +++ b/src/renderer/components/HistoryPanel.tsx @@ -80,10 +80,10 @@ const ActivityGraph: React.FC = ({ entries, theme }) => { className="flex-1 min-w-0 flex flex-col relative mt-0.5" title={hoveredIndex === null ? `Last 24h: ${totalAuto} auto, ${totalUser} user` : undefined} > - {/* Hover tooltip */} + {/* Hover tooltip - positioned below the graph */} {hoveredIndex !== null && (
void; } export function MainPanel(props: MainPanelProps) { @@ -128,10 +139,15 @@ export function MainPanel(props: MainPanelProps) { const [showSessionIdCopied, setShowSessionIdCopied] = useState(false); // Git pill tooltip hover state const [gitTooltipOpen, setGitTooltipOpen] = useState(false); + // Agent sessions tooltip hover state + const [sessionsTooltipOpen, setSessionsTooltipOpen] = useState(false); // Panel width for responsive hiding of widgets const [panelWidth, setPanelWidth] = useState(Infinity); // Start with Infinity so widgets show by default const headerRef = useRef(null); + // Extract recent sessions from props + const { recentClaudeSessions, onResumeRecentSession } = props; + // Track panel width for responsive widget hiding useEffect(() => { const header = headerRef.current; @@ -618,9 +634,79 @@ export function MainPanel(props: MainPanelProps) { )}
- + {/* Agent Sessions Button with Recent Sessions Hover */} +
setSessionsTooltipOpen(true)} + onMouseLeave={() => setSessionsTooltipOpen(false)} + > + + + {/* Recent Sessions Hover Overlay */} + {sessionsTooltipOpen && recentClaudeSessions.length > 0 && ( +
setSessionsTooltipOpen(true)} + onMouseLeave={() => setSessionsTooltipOpen(false)} + > +
+ Recent Sessions +
+
+ {recentClaudeSessions.slice(0, 5).map((session) => ( + + ))} +
+
{ + setAgentSessionsOpen(true); + setSessionsTooltipOpen(false); + }} + > + View all sessions → +
+
+ )} +
{!rightPanelOpen && (