diff --git a/ui/desktop/src/components/sessions/SessionTimelineView.tsx b/ui/desktop/src/components/sessions/SessionTimelineView.tsx index f9acdc3a8f..44f2b728ee 100644 --- a/ui/desktop/src/components/sessions/SessionTimelineView.tsx +++ b/ui/desktop/src/components/sessions/SessionTimelineView.tsx @@ -5,15 +5,35 @@ import { MessageSquareText, Target, Calendar, Users, Hash, MessageCircle } from import { ScrollArea } from '../ui/scroll-area'; import { unifiedSessionService } from '../../services/UnifiedSessionService'; -interface TimelineNode { +interface TimelineDay { + date: Date; + dateString: string; + dayLabel: string; + yPosition: number; + sessions: SessionBranch[]; +} + +interface SessionBranch { id: string; session: Session; displayInfo: ReturnType; startTime: Date; endTime: Date; duration: number; - yPosition: number; + startDay: string; + endDay: string; + branchPosition: number; // horizontal offset from main timeline nodeSize: number; + spansMultipleDays: boolean; + continuationLines: ContinuationLine[]; +} + +interface ContinuationLine { + fromDay: string; + toDay: string; + fromY: number; + toY: number; + branchPosition: number; } interface SessionTimelineViewProps { @@ -29,63 +49,150 @@ const SessionTimelineView: React.FC = ({ selectedSessionId, className = '', }) => { - const [hoveredNode, setHoveredNode] = useState(null); + const [hoveredSession, setHoveredSession] = useState(null); - const timelineNodes = useMemo(() => { - if (sessions.length === 0) return []; + const timelineData = useMemo(() => { + if (sessions.length === 0) return { days: [], branches: [], continuationLines: [] }; - // Create nodes from sessions - const nodes: TimelineNode[] = sessions.map((session) => { + // Group sessions by start day + const sessionsByDay = new Map(); + const allDays = new Set(); + + sessions.forEach(session => { + const startDay = new Date(session.created_at).toDateString(); + const endDay = new Date(session.updated_at).toDateString(); + + allDays.add(startDay); + allDays.add(endDay); + + if (!sessionsByDay.has(startDay)) { + sessionsByDay.set(startDay, []); + } + sessionsByDay.get(startDay)!.push(session); + }); + + // Create timeline days + const sortedDays = Array.from(allDays).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + const dayHeight = 200; + const daySpacing = 50; + + const timelineDays: TimelineDay[] = sortedDays.map((dateString, index) => { + const date = new Date(dateString); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + let dayLabel = ''; + if (date.toDateString() === today.toDateString()) { + dayLabel = 'Today'; + } else if (date.toDateString() === yesterday.toDateString()) { + dayLabel = 'Yesterday'; + } else { + dayLabel = date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + } + + return { + date, + dateString, + dayLabel, + yPosition: 80 + (index * (dayHeight + daySpacing)), + sessions: [], // Will be populated below + }; + }); + + // Create session branches + const allBranches: SessionBranch[] = []; + const continuationLines: ContinuationLine[] = []; + const branchPositions = new Map(); // Track horizontal positions per day + + sessions.forEach(session => { const displayInfo = unifiedSessionService.getSessionDisplayInfo(session); const startTime = new Date(session.created_at); const endTime = new Date(session.updated_at); + const startDay = startTime.toDateString(); + const endDay = endTime.toDateString(); const duration = endTime.getTime() - startTime.getTime(); - - // Node size based on message count (min 24px, max 60px) - const nodeSize = Math.max(24, Math.min(60, 24 + (session.message_count * 1.5))); - - return { + const spansMultipleDays = startDay !== endDay; + + // Calculate node size based on message count + const nodeSize = Math.max(20, Math.min(40, 20 + (session.message_count * 1))); + + // Assign horizontal branch position + const currentBranchCount = branchPositions.get(startDay) || 0; + const branchPosition = 120 + (currentBranchCount * 80); // 80px spacing between branches + branchPositions.set(startDay, currentBranchCount + 1); + + const branch: SessionBranch = { id: session.id, session, displayInfo, startTime, endTime, duration, - yPosition: 0, // Will be calculated below + startDay, + endDay, + branchPosition, nodeSize, + spansMultipleDays, + continuationLines: [], }; + + allBranches.push(branch); + + // Create continuation lines for multi-day sessions + if (spansMultipleDays) { + const startDayIndex = sortedDays.indexOf(startDay); + const endDayIndex = sortedDays.indexOf(endDay); + + for (let i = startDayIndex; i < endDayIndex; i++) { + const fromDay = sortedDays[i]; + const toDay = sortedDays[i + 1]; + const fromDayData = timelineDays.find(d => d.dateString === fromDay); + const toDayData = timelineDays.find(d => d.dateString === toDay); + + if (fromDayData && toDayData) { + continuationLines.push({ + fromDay, + toDay, + fromY: fromDayData.yPosition + dayHeight - 20, + toY: toDayData.yPosition + 20, + branchPosition, + }); + } + } + } }); - // Sort by start time (oldest first for top-to-bottom timeline) - nodes.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); - - // Calculate Y positions with spacing - const nodeSpacing = 100; // pixels between nodes - nodes.forEach((node, index) => { - node.yPosition = 80 + (index * nodeSpacing); + // Assign branches to their starting days + timelineDays.forEach(day => { + day.sessions = allBranches.filter(branch => branch.startDay === day.dateString); }); - return nodes; + return { days: timelineDays, branches: allBranches, continuationLines }; }, [sessions]); - const getNodeIcon = (node: TimelineNode) => { - const { displayInfo, session } = node; + const getSessionIcon = (branch: SessionBranch) => { + const { displayInfo, session } = branch; if (displayInfo.type === 'collaborative') { - return ; + return ; } else if (session.extension_data?.matrix?.isDirectMessage) { - return ; + return ; } else if (displayInfo.type === 'matrix') { - return ; + return ; } - return ; + return ; }; - const getNodeColor = (node: TimelineNode) => { - const { displayInfo, session } = node; - const isSelected = selectedSessionId === node.id; - const isHovered = hoveredNode === node.id; + const getSessionColor = (branch: SessionBranch) => { + const { displayInfo, session } = branch; + const isSelected = selectedSessionId === branch.id; + const isHovered = hoveredSession === branch.id; let baseColor = ''; if (displayInfo.type === 'collaborative') { @@ -109,11 +216,18 @@ const SessionTimelineView: React.FC = ({ return baseColor; }; - const getDurationBarColor = (duration: number) => { - if (duration < 5 * 60 * 1000) return 'bg-gray-300'; // < 5 minutes - if (duration < 30 * 60 * 1000) return 'bg-blue-400'; // < 30 minutes - if (duration < 2 * 60 * 60 * 1000) return 'bg-yellow-400'; // < 2 hours - return 'bg-red-400'; // > 2 hours + const getBranchLineColor = (branch: SessionBranch) => { + const { displayInfo, session } = branch; + + if (displayInfo.type === 'collaborative') { + return 'border-purple-400'; + } else if (session.extension_data?.matrix?.isDirectMessage) { + return 'border-green-400'; + } else if (displayInfo.type === 'matrix') { + return 'border-blue-400'; + } + + return 'border-gray-400'; }; const formatDuration = (duration: number) => { @@ -129,11 +243,11 @@ const SessionTimelineView: React.FC = ({ return '< 1m'; }; - const handleNodeClick = (nodeId: string) => { - onSelectSession(nodeId); + const handleSessionClick = (sessionId: string) => { + onSelectSession(sessionId); }; - if (timelineNodes.length === 0) { + if (timelineData.days.length === 0) { return (
@@ -143,7 +257,9 @@ const SessionTimelineView: React.FC = ({ ); } - const timelineHeight = Math.max(600, timelineNodes[timelineNodes.length - 1]?.yPosition + 100); + const totalHeight = timelineData.days.length > 0 + ? timelineData.days[timelineData.days.length - 1].yPosition + 250 + : 600; return (
@@ -151,173 +267,171 @@ const SessionTimelineView: React.FC = ({

Session Timeline

- Chronological view of {timelineNodes.length} sessions. Node size reflects message count, colors indicate session type. + Daily timeline showing {sessions.length} sessions. Each day has its own section with conversation branches.

-
- {/* Main vertical timeline line */} +
+ {/* Main vertical timeline spine */}
- {/* Timeline nodes and connections */} - {timelineNodes.map((node, index) => { - const isSelected = selectedSessionId === node.id; - const isHovered = hoveredNode === node.id; - const nextNode = timelineNodes[index + 1]; - - return ( -
- {/* Connection line to next node */} - {nextNode && ( -
- )} + {/* Day sections and session branches */} + {timelineData.days.map((day, dayIndex) => ( +
+ {/* Day marker on main timeline */} +
+ +
+ + {/* Day label */} +
+ {day.dayLabel} +
+ + {/* Date label */} +
+ {day.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+ + {/* Session branches for this day */} + {day.sessions.map((branch, branchIndex) => { + const isSelected = selectedSessionId === branch.id; + const isHovered = hoveredSession === branch.id; + const branchY = day.yPosition + 40 + (branchIndex * 60); - {/* Horizontal connector line */} -
- - {/* Timeline node */} -
handleNodeClick(node.id)} - onMouseEnter={() => setHoveredNode(node.id)} - onMouseLeave={() => setHoveredNode(null)} - > - {getNodeIcon(node)} - - {/* Message count badge */} - {node.session.message_count > 0 && ( -
- {node.session.message_count} -
- )} -
- - {/* Session details panel */} -
handleNodeClick(node.id)} - > - {/* Session header */} -
-
-

- {node.session.description || node.session.id} -

+ return ( +
+ {/* Branch line from main timeline to session */} +
+ + {/* Session node */} +
handleSessionClick(branch.id)} + onMouseEnter={() => setHoveredSession(branch.id)} + onMouseLeave={() => setHoveredSession(null)} + > + {getSessionIcon(branch)} - {/* Session type badge */} - - {node.displayInfo.type === 'collaborative' ? 'Collaborative' : - node.displayInfo.type === 'matrix' ? 'Matrix' : 'Regular'} - -
-
- - {/* Session metadata */} -
-
- Started: {formatMessageTimestamp(node.startTime.getTime() / 1000)} - {node.duration > 60000 && ( - - Duration: {formatDuration(node.duration)} - + {/* Message count badge */} + {branch.session.message_count > 0 && ( +
+ {branch.session.message_count} +
)}
-
-
- - {node.session.message_count} messages + {/* Multi-day indicator */} + {branch.spansMultipleDays && ( +
+ Multi-day
- - {node.session.total_tokens && ( -
- - {node.session.total_tokens.toLocaleString()} tokens -
- )} - - {node.displayInfo.participants && ( -
- - {node.displayInfo.participants.length} participants -
- )} -
+ )} - {/* Duration bar */} - {node.duration > 60000 && ( -
-
-
-
+ {/* Session details tooltip on hover */} + {isHovered && ( +
+
+ {branch.session.description || branch.session.id} +
+ +
+
Started: {formatMessageTimestamp(branch.startTime.getTime() / 1000)}
+ {branch.duration > 60000 && ( +
Duration: {formatDuration(branch.duration)}
+ )} + +
+
+ + {branch.session.message_count} +
+ + {branch.session.total_tokens && ( +
+ + {branch.session.total_tokens.toLocaleString()} +
+ )}
)}
-
-
- ); - })} + ); + })} +
+ ))} - {/* Timeline start/end markers */} -
- Earliest -
-
- Latest -
+ {/* Continuation lines for multi-day sessions */} + {timelineData.continuationLines.map((line, index) => ( +
+ ))}
{/* Legend */}
-

Legend

+

Timeline Legend

{/* Session types */}
@@ -348,41 +462,37 @@ const SessionTimelineView: React.FC = ({
- {/* Duration colors */} -
-
Duration
-
-
- < 5 minutes -
-
-
- < 30 minutes -
-
-
- < 2 hours -
-
-
- 2+ hours -
-
- {/* Visual elements */}
Visual Elements
+
+
+ Session branch +
+
+
+ Multi-day continuation +
+
+ + Day marker +
+
+ + {/* Interaction */} +
+
Interaction
Node size: Message count
- Position: - Chronological order + Hover: + Session details
- Badge: - Message count + Click: + Open session