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:
Pedram Amini
2025-11-26 23:00:43 -06:00
parent 94c9bc90a4
commit ed443a2a37
8 changed files with 338 additions and 80 deletions

View File

@@ -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');

View File

@@ -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 --- */}

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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}`,

View File

@@ -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" />

View File

@@ -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={{

View File

@@ -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'
}
},