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:
Pedram Amini
2025-11-27 02:08:02 -06:00
parent 0835672ce0
commit da21f4a1f8
2 changed files with 122 additions and 34 deletions

View File

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

View File

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