mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
feat: Improve History panel graph and session pill UX
- History activity graph now uses sliding time window that adjusts as you scroll, showing activity relative to the visible entries rather than always anchored to "now" - Session ID pill now opens on hover instead of click for faster access - Added hover timeout and invisible bridge for smooth tooltip behavior Claude ID: 24a6cdd6-27a7-41e0-af30-679cc2ffe66b Maestro ID: 5a166b38-b7e9-47f0-a8ff-0113c65f2682
This commit is contained in:
@@ -3,28 +3,31 @@ import { Bot, User, ExternalLink, Check, X } from 'lucide-react';
|
||||
import type { Session, Theme, HistoryEntry, HistoryEntryType } from '../types';
|
||||
import { HistoryDetailModal } from './HistoryDetailModal';
|
||||
|
||||
// 24-hour activity bar graph component
|
||||
// 24-hour activity bar graph component with sliding time window
|
||||
interface ActivityGraphProps {
|
||||
entries: HistoryEntry[];
|
||||
theme: Theme;
|
||||
referenceTime?: number; // The "end" of the 24-hour window (defaults to now)
|
||||
}
|
||||
|
||||
const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme, referenceTime }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
// Group entries by hour for past 24 hours
|
||||
const hourlyData = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const msPerHour = 60 * 60 * 1000;
|
||||
const hours24Ago = now - (24 * msPerHour);
|
||||
// Use referenceTime as the end of our window, or current time if not provided
|
||||
const endTime = referenceTime || Date.now();
|
||||
|
||||
// Initialize 24 buckets (index 0 = 24 hours ago, index 23 = current hour)
|
||||
// Group entries by hour for the 24-hour window ending at referenceTime
|
||||
const hourlyData = useMemo(() => {
|
||||
const msPerHour = 60 * 60 * 1000;
|
||||
const hours24Ago = endTime - (24 * msPerHour);
|
||||
|
||||
// Initialize 24 buckets (index 0 = 24 hours before endTime, index 23 = endTime hour)
|
||||
const buckets: { auto: number; user: number }[] = Array.from({ length: 24 }, () => ({ auto: 0, user: 0 }));
|
||||
|
||||
// Filter to last 24 hours and bucket by hour
|
||||
// Filter to the 24-hour window and bucket by hour
|
||||
entries.forEach(entry => {
|
||||
if (entry.timestamp >= hours24Ago && entry.timestamp <= now) {
|
||||
const hoursAgo = Math.floor((now - entry.timestamp) / msPerHour);
|
||||
if (entry.timestamp >= hours24Ago && entry.timestamp <= endTime) {
|
||||
const hoursAgo = Math.floor((endTime - entry.timestamp) / msPerHour);
|
||||
const bucketIndex = 23 - hoursAgo; // Convert to 0-indexed from oldest to newest
|
||||
if (bucketIndex >= 0 && bucketIndex < 24) {
|
||||
if (entry.type === 'AUTO') {
|
||||
@@ -37,7 +40,7 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}, [entries]);
|
||||
}, [entries, endTime]);
|
||||
|
||||
// Find max value for scaling
|
||||
const maxValue = useMemo(() => {
|
||||
@@ -48,7 +51,7 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
const totalAuto = useMemo(() => hourlyData.reduce((sum, h) => sum + h.auto, 0), [hourlyData]);
|
||||
const totalUser = useMemo(() => hourlyData.reduce((sum, h) => sum + h.user, 0), [hourlyData]);
|
||||
|
||||
// Hour labels positioned at: 24 (start), 16, 8, 0 (end/now)
|
||||
// Hour labels positioned at: 24 (start), 16, 8, 0 (end/reference time)
|
||||
const hourLabels = [
|
||||
{ hour: 24, index: 0 },
|
||||
{ hour: 16, index: 8 },
|
||||
@@ -58,12 +61,12 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
|
||||
// Get time range label for tooltip (e.g., "2PM - 3PM")
|
||||
const getTimeRangeLabel = (index: number) => {
|
||||
const now = new Date();
|
||||
const refDate = new Date(endTime);
|
||||
const hoursAgo = 23 - index;
|
||||
|
||||
// Calculate the start hour of this bucket
|
||||
const endHour = new Date(now.getTime() - (hoursAgo * 60 * 60 * 1000));
|
||||
const startHour = new Date(endHour.getTime() - (60 * 60 * 1000));
|
||||
// Calculate the start hour of this bucket relative to endTime
|
||||
const bucketEnd = new Date(refDate.getTime() - (hoursAgo * 60 * 60 * 1000));
|
||||
const bucketStart = new Date(bucketEnd.getTime() - (60 * 60 * 1000));
|
||||
|
||||
const formatHour = (date: Date) => {
|
||||
const hour = date.getHours();
|
||||
@@ -72,13 +75,29 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
return `${hour12}${ampm}`;
|
||||
};
|
||||
|
||||
return `${formatHour(startHour)} - ${formatHour(endHour)}`;
|
||||
return `${formatHour(bucketStart)} - ${formatHour(bucketEnd)}`;
|
||||
};
|
||||
|
||||
// Format the reference time for display (shows what time point we're viewing)
|
||||
const formatReferenceTime = () => {
|
||||
const now = Date.now();
|
||||
const diffMs = now - endTime;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 1) return 'Now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return new Date(endTime).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
// Check if we're viewing historical data (not "now")
|
||||
const isHistorical = referenceTime && (Date.now() - referenceTime) > 60000; // More than 1 minute ago
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 min-w-0 flex flex-col relative mt-0.5"
|
||||
title={hoveredIndex === null ? `Last 24h: ${totalAuto} auto, ${totalUser} user` : undefined}
|
||||
title={hoveredIndex === null ? `${isHistorical ? `Viewing: ${formatReferenceTime()} • ` : ''}24h window: ${totalAuto} auto, ${totalUser} user` : undefined}
|
||||
>
|
||||
{/* Hover tooltip - positioned below the graph */}
|
||||
{hoveredIndex !== null && (
|
||||
@@ -175,7 +194,7 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Hour labels below */}
|
||||
{/* Hour labels below + reference time indicator */}
|
||||
<div className="relative h-3 mt-0.5">
|
||||
{hourLabels.map(({ hour, index }) => (
|
||||
<span
|
||||
@@ -191,6 +210,15 @@ const ActivityGraph: React.FC<ActivityGraphProps> = ({ entries, theme }) => {
|
||||
{hour}h
|
||||
</span>
|
||||
))}
|
||||
{/* Show reference time indicator when viewing historical data */}
|
||||
{isHistorical && (
|
||||
<span
|
||||
className="absolute right-0 text-[8px] font-mono font-bold"
|
||||
style={{ color: theme.colors.accent, top: '-10px' }}
|
||||
>
|
||||
{formatReferenceTime()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -221,6 +249,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [searchFilterOpen, setSearchFilterOpen] = useState(false);
|
||||
const [displayCount, setDisplayCount] = useState(INITIAL_DISPLAY_COUNT);
|
||||
const [graphReferenceTime, setGraphReferenceTime] = useState<number | undefined>(undefined);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
@@ -303,7 +332,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
// Check if there are more entries to load
|
||||
const hasMore = allFilteredEntries.length > displayCount;
|
||||
|
||||
// Handle scroll to load more entries
|
||||
// Handle scroll to load more entries AND update graph reference time
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
@@ -312,12 +341,38 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
if (scrollBottom < 100 && hasMore) {
|
||||
setDisplayCount(prev => Math.min(prev + LOAD_MORE_COUNT, allFilteredEntries.length));
|
||||
}
|
||||
}, [hasMore, allFilteredEntries.length]);
|
||||
|
||||
// Reset selected index and display count when filters change
|
||||
// Find the topmost visible entry to update the graph's reference time
|
||||
// This creates the "sliding window" effect as you scroll through history
|
||||
const containerRect = target.getBoundingClientRect();
|
||||
let topmostVisibleEntry: HistoryEntry | null = null;
|
||||
|
||||
for (let i = 0; i < filteredEntries.length; i++) {
|
||||
const itemEl = itemRefs.current[i];
|
||||
if (itemEl) {
|
||||
const itemRect = itemEl.getBoundingClientRect();
|
||||
// Check if this item is at or below the top of the container
|
||||
if (itemRect.top >= containerRect.top - 20) {
|
||||
topmostVisibleEntry = filteredEntries[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the graph reference time to the topmost visible entry's timestamp
|
||||
// If at the very top (no scrolling), use undefined to show "now"
|
||||
if (target.scrollTop < 10) {
|
||||
setGraphReferenceTime(undefined);
|
||||
} else if (topmostVisibleEntry) {
|
||||
setGraphReferenceTime(topmostVisibleEntry.timestamp);
|
||||
}
|
||||
}, [hasMore, allFilteredEntries.length, filteredEntries]);
|
||||
|
||||
// Reset selected index, display count, and graph reference time when filters change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1);
|
||||
setDisplayCount(INITIAL_DISPLAY_COUNT);
|
||||
setGraphReferenceTime(undefined); // Reset to "now" when filters change
|
||||
}, [activeFilters, searchFilter]);
|
||||
|
||||
// Scroll selected item into view
|
||||
@@ -459,7 +514,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
</div>
|
||||
|
||||
{/* 24-hour activity bar graph */}
|
||||
<ActivityGraph entries={historyEntries} theme={theme} />
|
||||
<ActivityGraph entries={historyEntries} theme={theme} referenceTime={graphReferenceTime} />
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
|
||||
@@ -141,10 +141,11 @@ export function MainPanel(props: MainPanelProps) {
|
||||
const [gitTooltipOpen, setGitTooltipOpen] = useState(false);
|
||||
// Agent sessions tooltip hover state
|
||||
const [sessionsTooltipOpen, setSessionsTooltipOpen] = useState(false);
|
||||
// Session ID pill overlay state
|
||||
// Session ID pill overlay state (hover-triggered with delay for smooth UX)
|
||||
const [sessionPillOverlayOpen, setSessionPillOverlayOpen] = useState(false);
|
||||
const [sessionPillRenaming, setSessionPillRenaming] = useState(false);
|
||||
const [sessionPillRenameValue, setSessionPillRenameValue] = useState('');
|
||||
const sessionPillHoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Bookmarked and named Claude sessions (stored globally)
|
||||
const [bookmarkedSessions, setBookmarkedSessions] = useState<Set<string>>(new Set());
|
||||
const [namedSessions, setNamedSessions] = useState<Record<string, string>>({});
|
||||
@@ -269,7 +270,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
}
|
||||
}, [activeSession?.claudeSessionId, sessionPillRenameValue, namedSessions]);
|
||||
|
||||
// Close session pill overlay when clicking outside
|
||||
// Close session pill overlay when clicking outside (mainly for rename mode)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (sessionPillOverlayOpen && sessionPillRef.current && !sessionPillRef.current.contains(event.target as Node)) {
|
||||
@@ -281,6 +282,15 @@ export function MainPanel(props: MainPanelProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [sessionPillOverlayOpen]);
|
||||
|
||||
// Cleanup hover timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sessionPillHoverTimeout.current) {
|
||||
clearTimeout(sessionPillHoverTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handler for input focus - select session in sidebar
|
||||
const handleInputFocus = () => {
|
||||
if (activeSession) {
|
||||
@@ -561,20 +571,43 @@ export function MainPanel(props: MainPanelProps) {
|
||||
onViewDiff={handleViewGitDiff}
|
||||
/>
|
||||
|
||||
{/* Session ID Pill with Overlay */}
|
||||
{/* Session ID Pill with Overlay (hover-triggered) */}
|
||||
{activeSession.inputMode === 'ai' && activeSession.claudeSessionId && (
|
||||
<div className="relative" ref={sessionPillRef}>
|
||||
<button
|
||||
onClick={() => setSessionPillOverlayOpen(!sessionPillOverlayOpen)}
|
||||
className="flex items-center gap-1 text-[10px] font-mono font-bold px-2 py-0.5 rounded-full border cursor-pointer hover:opacity-80 transition-opacity"
|
||||
<div
|
||||
className="relative"
|
||||
ref={sessionPillRef}
|
||||
onMouseEnter={() => {
|
||||
// Clear any pending close timeout
|
||||
if (sessionPillHoverTimeout.current) {
|
||||
clearTimeout(sessionPillHoverTimeout.current);
|
||||
sessionPillHoverTimeout.current = null;
|
||||
}
|
||||
setSessionPillOverlayOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Delay closing to allow mouse to reach the dropdown
|
||||
sessionPillHoverTimeout.current = setTimeout(() => {
|
||||
// Don't close if we're in rename mode
|
||||
if (!sessionPillRenaming) {
|
||||
setSessionPillOverlayOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 text-[10px] font-mono font-bold px-2 py-0.5 rounded-full border cursor-default hover:opacity-80 transition-opacity"
|
||||
style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent, borderColor: theme.colors.accent + '30' }}
|
||||
title={namedSessions[activeSession.claudeSessionId] || `Session: ${activeSession.claudeSessionId}`}
|
||||
>
|
||||
{bookmarkedSessions.has(activeSession.claudeSessionId) && (
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
)}
|
||||
{namedSessions[activeSession.claudeSessionId] || activeSession.claudeSessionId.split('-')[0].toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Invisible bridge to prevent hover gap issues */}
|
||||
{sessionPillOverlayOpen && (
|
||||
<div className="absolute left-0 right-0 h-2" style={{ top: '100%' }} />
|
||||
)}
|
||||
|
||||
{/* Overlay dropdown */}
|
||||
{sessionPillOverlayOpen && (
|
||||
@@ -583,7 +616,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSidebar,
|
||||
borderColor: theme.colors.border,
|
||||
minWidth: '200px'
|
||||
minWidth: '280px'
|
||||
}}
|
||||
>
|
||||
{/* Session ID display */}
|
||||
|
||||
Reference in New Issue
Block a user