mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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
This commit is contained in:
@@ -81,6 +81,26 @@ const agentConfigsStore = new Store<AgentConfigsData>({
|
||||
},
|
||||
});
|
||||
|
||||
// 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<WindowState>({
|
||||
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');
|
||||
|
||||
@@ -155,6 +155,13 @@ export default function MaestroConsole() {
|
||||
const [agentSessionsOpen, setAgentSessionsOpen] = useState(false);
|
||||
const [activeClaudeSessionId, setActiveClaudeSessionId] = useState<string | null>(null);
|
||||
|
||||
// Recent Claude sessions for quick access (breadcrumbs when session hopping)
|
||||
const [recentClaudeSessions, setRecentClaudeSessions] = useState<Array<{
|
||||
sessionId: string;
|
||||
firstMessage: string;
|
||||
timestamp: string;
|
||||
}>>([]);
|
||||
|
||||
// Batch Runner Modal State
|
||||
const [batchRunnerModalOpen, setBatchRunnerModalOpen] = useState(false);
|
||||
const [renameGroupId, setRenameGroupId] = useState<string | null>(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 --- */}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
@@ -550,7 +550,7 @@ export function AgentSessionsBrowser({
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] mt-2 opacity-60"
|
||||
style={{ color: msg.type === 'user' ? theme.colors.accentText : theme.colors.textDim }}
|
||||
style={{ color: msg.type === 'user' ? (theme.mode === 'light' ? '#fff' : '#000') : theme.colors.textDim }}
|
||||
>
|
||||
{formatRelativeTime(msg.timestamp)}
|
||||
</div>
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selectedItemRef = useRef<HTMLButtonElement>(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,
|
||||
}}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
@@ -365,7 +406,7 @@ export function AgentSessionsModal({
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] mt-1 opacity-60"
|
||||
style={{ color: msg.type === 'user' ? theme.colors.accentText : theme.colors.textDim }}
|
||||
style={{ color: msg.type === 'user' ? (theme.mode === 'light' ? '#fff' : '#000') : theme.colors.textDim }}
|
||||
>
|
||||
{formatRelativeTime(msg.timestamp)}
|
||||
</div>
|
||||
@@ -390,38 +431,55 @@ export function AgentSessionsModal({
|
||||
{sessions.length === 0 ? 'No Claude sessions found for this project' : 'No sessions match your search'}
|
||||
</div>
|
||||
) : (
|
||||
filteredSessions.map((session, i) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
ref={i === selectedIndex ? selectedItemRef : null}
|
||||
onClick={() => handleViewSession(session)}
|
||||
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-opacity-10 transition-colors"
|
||||
style={{
|
||||
backgroundColor: i === selectedIndex ? theme.colors.accent : 'transparent',
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate text-sm">
|
||||
{session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`}
|
||||
filteredSessions.map((session, i) => {
|
||||
const isStarred = starredSessions.has(session.sessionId);
|
||||
return (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
ref={i === selectedIndex ? selectedItemRef : null}
|
||||
onClick={() => handleViewSession(session)}
|
||||
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-opacity-10 transition-colors group"
|
||||
style={{
|
||||
backgroundColor: i === selectedIndex ? theme.colors.accent : 'transparent',
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
>
|
||||
{/* Star button */}
|
||||
<button
|
||||
onClick={(e) => toggleStar(session.sessionId, e)}
|
||||
className="p-1 -ml-1 rounded hover:bg-white/10 transition-colors shrink-0"
|
||||
title={isStarred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star
|
||||
className="w-4 h-4"
|
||||
style={{
|
||||
color: isStarred ? theme.colors.warning : theme.colors.textDim,
|
||||
fill: isStarred ? theme.colors.warning : 'transparent',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate text-sm">
|
||||
{session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: theme.colors.textDim }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(session.modifiedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{session.messageCount} msgs
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
{formatSize(session.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: theme.colors.textDim }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(session.modifiedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{session.messageCount} msgs
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
{formatSize(session.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -80,10 +80,10 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ 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 && (
|
||||
<div
|
||||
className="absolute bottom-full mb-1 px-2 py-1.5 rounded text-[10px] font-mono whitespace-nowrap z-20 pointer-events-none"
|
||||
className="absolute top-full mt-1 px-2 py-1.5 rounded text-[10px] font-mono whitespace-nowrap z-20 pointer-events-none"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSidebar,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Wand2, Radio, ExternalLink, Columns, Copy, List, Loader2, Clock, GitBranch, ArrowUp, ArrowDown, FileEdit } from 'lucide-react';
|
||||
import { Wand2, Radio, ExternalLink, Columns, Copy, List, Loader2, Clock, GitBranch, ArrowUp, ArrowDown, FileEdit, Play } from 'lucide-react';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import { InputArea } from './InputArea';
|
||||
@@ -11,6 +11,13 @@ import { gitService } from '../services/git';
|
||||
import { formatActiveTime } from '../utils/theme';
|
||||
import type { Session, Theme, Shortcut, FocusArea, BatchRunState } from '../types';
|
||||
|
||||
// Recent Claude session for quick access
|
||||
interface RecentClaudeSession {
|
||||
sessionId: string;
|
||||
firstMessage: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface SlashCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
@@ -98,6 +105,10 @@ interface MainPanelProps {
|
||||
|
||||
// TTS settings
|
||||
audioFeedbackCommand?: string;
|
||||
|
||||
// Recent Claude sessions for quick access
|
||||
recentClaudeSessions: RecentClaudeSession[];
|
||||
onResumeRecentSession: (sessionId: string) => 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<HTMLDivElement>(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) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={() => setAgentSessionsOpen(true)} className="p-2 rounded hover:bg-white/5" title={`Agent Sessions (${shortcuts.agentSessions.keys.join('+').replace('Meta', 'Cmd').replace('Shift', '\u21E7')})`}>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Agent Sessions Button with Recent Sessions Hover */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setSessionsTooltipOpen(true)}
|
||||
onMouseLeave={() => setSessionsTooltipOpen(false)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setAgentSessionsOpen(true)}
|
||||
className="p-2 rounded hover:bg-white/5"
|
||||
title={`Agent Sessions (${shortcuts.agentSessions.keys.join('+').replace('Meta', 'Cmd').replace('Shift', '\u21E7')})`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Recent Sessions Hover Overlay */}
|
||||
{sessionsTooltipOpen && recentClaudeSessions.length > 0 && (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-72 rounded-lg border shadow-xl z-50"
|
||||
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}
|
||||
onMouseEnter={() => setSessionsTooltipOpen(true)}
|
||||
onMouseLeave={() => setSessionsTooltipOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-bold uppercase border-b"
|
||||
style={{ color: theme.colors.textDim, borderColor: theme.colors.border }}
|
||||
>
|
||||
Recent Sessions
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-thin">
|
||||
{recentClaudeSessions.slice(0, 5).map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
onClick={() => {
|
||||
onResumeRecentSession(session.sessionId);
|
||||
setSessionsTooltipOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-white/5 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Play className="w-3 h-3 shrink-0" style={{ color: theme.colors.accent }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs truncate" style={{ color: theme.colors.textMain }}>
|
||||
{session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: theme.colors.textDim }}>
|
||||
{(() => {
|
||||
const date = new Date(session.timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 text-xs border-t text-center cursor-pointer hover:bg-white/5"
|
||||
style={{ color: theme.colors.accent, borderColor: theme.colors.border }}
|
||||
onClick={() => {
|
||||
setAgentSessionsOpen(true);
|
||||
setSessionsTooltipOpen(false);
|
||||
}}
|
||||
>
|
||||
View all sessions →
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!rightPanelOpen && (
|
||||
<button onClick={() => setRightPanelOpen(true)} className="p-2 rounded hover:bg-white/5" title={`Show right panel (${shortcuts.toggleRightPanel.keys.join('+').replace('Meta', 'Cmd')})`}>
|
||||
<Columns className="w-4 h-4" />
|
||||
|
||||
@@ -41,9 +41,6 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
// Virtuoso ref for programmatic scrolling
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Track if user is viewing expanded content (disable auto-scroll)
|
||||
const [userScrolledAway, setUserScrolledAway] = useState(false);
|
||||
|
||||
// Track which log entries are expanded (by log ID)
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -213,7 +210,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
key={`match-${index}`}
|
||||
style={{
|
||||
backgroundColor: theme.colors.warning,
|
||||
color: theme.mode === 'dark' ? '#000' : '#fff',
|
||||
color: theme.mode === 'light' ? '#fff' : '#000',
|
||||
padding: '1px 2px',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
@@ -253,7 +250,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
result += text.substring(lastIndex, index);
|
||||
|
||||
// Add marked match with special tags
|
||||
result += `<mark style="background-color: ${theme.colors.warning}; color: ${theme.mode === 'dark' ? '#000' : '#fff'}; padding: 1px 2px; border-radius: 2px;">`;
|
||||
result += `<mark style="background-color: ${theme.colors.warning}; color: ${theme.mode === 'light' ? '#fff' : '#000'}; padding: 1px 2px; border-radius: 2px;">`;
|
||||
result += text.substring(index, index + query.length);
|
||||
result += '</mark>';
|
||||
|
||||
@@ -402,6 +399,11 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
// Initialize to 0 so that on first load with existing logs, we scroll to bottom
|
||||
const prevLogCountRef = useRef(0);
|
||||
useEffect(() => {
|
||||
// Don't auto-scroll if user has expanded logs (viewing full content)
|
||||
if (hasExpandedLogs) {
|
||||
prevLogCountRef.current = filteredLogs.length;
|
||||
return;
|
||||
}
|
||||
// Only scroll when new logs are added, not when deleted
|
||||
if (filteredLogs.length > prevLogCountRef.current && filteredLogs.length > 0) {
|
||||
// Use setTimeout to ensure scroll happens after render
|
||||
@@ -414,11 +416,16 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
}, 0);
|
||||
}
|
||||
prevLogCountRef.current = filteredLogs.length;
|
||||
}, [filteredLogs.length]);
|
||||
}, [filteredLogs.length, hasExpandedLogs]);
|
||||
|
||||
// Auto-scroll to bottom when session becomes busy to show thinking indicator
|
||||
const prevBusyStateRef = useRef(session.state === 'busy');
|
||||
useEffect(() => {
|
||||
// Don't auto-scroll if user has expanded logs (viewing full content)
|
||||
if (hasExpandedLogs) {
|
||||
prevBusyStateRef.current = session.state === 'busy';
|
||||
return;
|
||||
}
|
||||
const isBusy = session.state === 'busy';
|
||||
// Scroll when transitioning to busy state
|
||||
if (isBusy && !prevBusyStateRef.current) {
|
||||
@@ -432,11 +439,16 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
}, 50);
|
||||
}
|
||||
prevBusyStateRef.current = isBusy;
|
||||
}, [session.state, filteredLogs.length]);
|
||||
}, [session.state, filteredLogs.length, hasExpandedLogs]);
|
||||
|
||||
// Auto-scroll to bottom when message queue changes
|
||||
const prevQueueLengthRef = useRef(session.messageQueue?.length || 0);
|
||||
useEffect(() => {
|
||||
// Don't auto-scroll if user has expanded logs (viewing full content)
|
||||
if (hasExpandedLogs) {
|
||||
prevQueueLengthRef.current = session.messageQueue?.length || 0;
|
||||
return;
|
||||
}
|
||||
const queueLength = session.messageQueue?.length || 0;
|
||||
// Scroll when new messages are added to the queue
|
||||
if (queueLength > prevQueueLengthRef.current) {
|
||||
@@ -449,7 +461,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
}, 50);
|
||||
}
|
||||
prevQueueLengthRef.current = queueLength;
|
||||
}, [session.messageQueue?.length, filteredLogs.length]);
|
||||
}, [session.messageQueue?.length, filteredLogs.length, hasExpandedLogs]);
|
||||
|
||||
// Render a single log item - used by Virtuoso
|
||||
const LogItem = useCallback(({ index, log }: { index: number; log: LogEntry }) => {
|
||||
@@ -820,12 +832,15 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
</>
|
||||
)}
|
||||
{/* Action buttons - bottom right corner */}
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
||||
<div
|
||||
className="absolute bottom-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100"
|
||||
style={{ transition: 'opacity 0.15s ease-in-out' }}
|
||||
>
|
||||
{/* Speak Button - only show for non-user messages when TTS is configured */}
|
||||
{audioFeedbackCommand && log.source !== 'user' && (
|
||||
<button
|
||||
onClick={() => speakText(log.text)}
|
||||
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
|
||||
className="p-1.5 rounded opacity-50 hover:opacity-100"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Speak text"
|
||||
>
|
||||
@@ -835,7 +850,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
{/* Copy to Clipboard Button */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(log.text)}
|
||||
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity"
|
||||
className="p-1.5 rounded opacity-50 hover:opacity-100"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
@@ -934,24 +949,11 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
ref={virtuosoRef}
|
||||
data={filteredLogs}
|
||||
className="flex-1"
|
||||
atBottomStateChange={(atBottom) => {
|
||||
// Only update state when the value actually changes to reduce re-renders
|
||||
setUserScrolledAway(prev => {
|
||||
const newValue = !atBottom;
|
||||
return prev === newValue ? prev : newValue;
|
||||
});
|
||||
}}
|
||||
followOutput={(isAtBottom) => {
|
||||
followOutput={() => {
|
||||
// Don't auto-scroll if user has expanded logs (viewing full content)
|
||||
if (hasExpandedLogs) return false;
|
||||
// Don't auto-scroll if user has scrolled away (e.g., reading content)
|
||||
if (userScrolledAway && !isAtBottom) return false;
|
||||
// Always scroll when session becomes busy to show the thinking indicator
|
||||
if (session.state === 'busy' && isAtBottom) return 'smooth';
|
||||
// Always scroll when there are queued messages to show them
|
||||
if (session.messageQueue && session.messageQueue.length > 0 && isAtBottom) return 'smooth';
|
||||
// Otherwise, only follow if user is already at bottom
|
||||
return isAtBottom ? 'smooth' : false;
|
||||
// Don't follow output - we handle scrolling manually
|
||||
return false;
|
||||
}}
|
||||
itemContent={(index, log) => <LogItem index={index} log={log} />}
|
||||
components={{
|
||||
|
||||
@@ -247,7 +247,7 @@ export const THEMES: Record<ThemeId, Theme> = {
|
||||
accentDim: 'rgba(212, 175, 55, 0.25)',
|
||||
accentText: '#ffd700',
|
||||
success: '#7cb342',
|
||||
warning: '#ffb300',
|
||||
warning: '#c77dff',
|
||||
error: '#da70d6'
|
||||
}
|
||||
},
|
||||
@@ -285,7 +285,7 @@ export const THEMES: Record<ThemeId, Theme> = {
|
||||
accentDim: 'rgba(255, 42, 109, 0.25)',
|
||||
accentText: '#ff6b9d',
|
||||
success: '#05ffa1',
|
||||
warning: '#ffd319',
|
||||
warning: '#00f5d4',
|
||||
error: '#ff2a6d'
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user