From c0e4158b777e247f5cdaea64ca0d4e4f6269792a Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 09:26:27 -0500 Subject: [PATCH] Add time gap indicators and hash anchor navigation to SessionViewer Time gaps: - Insert a horizontal divider with duration label ("12m later", "1h 30m later") between consecutive visible messages separated by more than 5 minutes - Computed during the display list build pass alongside redacted dividers, so no additional traversal is needed Hash anchor navigation: - Each message div now has id="msg-{uuid}" for deep linking - On load, if the URL contains a #msg-* hash, scroll that message into view with smooth centering and a 3-second highlight ring - Works with the copy-link feature added to MessageBubble headers Co-Authored-By: Claude Opus 4.5 --- src/client/components/SessionViewer.tsx | 54 ++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/client/components/SessionViewer.tsx b/src/client/components/SessionViewer.tsx index 4cf02f0..c47fde4 100644 --- a/src/client/components/SessionViewer.tsx +++ b/src/client/components/SessionViewer.tsx @@ -58,7 +58,22 @@ export function SessionViewer({ } }, [focusedIndex]); - // Build display list with redacted dividers. + // Auto-scroll to hash anchor on load + useEffect(() => { + const hash = window.location.hash; + if (hash && hash.startsWith("#msg-") && messages.length > 0) { + requestAnimationFrame(() => { + const el = document.getElementById(hash.slice(1)); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("search-match-focused", "rounded-xl"); + setTimeout(() => el.classList.remove("search-match-focused"), 3000); + } + }); + } + }, [messages.length]); + + // Build display list with redacted dividers and time gaps. // Must be called before any early returns to satisfy Rules of Hooks. const displayItems = useMemo(() => { if (messages.length === 0) return []; @@ -67,9 +82,11 @@ export function SessionViewer({ const items: Array< | { type: "message"; message: ParsedMessage; messageIndex: number } | { type: "redacted_divider"; key: string } + | { type: "time_gap"; key: string; duration: string } > = []; let prevWasRedactedGap = false; + let prevTimestamp: string | undefined; let messageIndex = 0; for (const msg of allMessages) { if (redactedUuids.has(msg.uuid)) { @@ -86,6 +103,20 @@ export function SessionViewer({ }); prevWasRedactedGap = false; } + // Insert time gap indicator if > 5 minutes between visible messages + if (prevTimestamp && msg.timestamp) { + const currTime = new Date(msg.timestamp).getTime(); + const prevTime = new Date(prevTimestamp).getTime(); + const gap = currTime - prevTime; + if (!isNaN(gap) && gap > 5 * 60 * 1000) { + items.push({ + type: "time_gap", + key: `gap-${msg.uuid}`, + duration: formatDuration(gap), + }); + } + } + if (msg.timestamp) prevTimestamp = msg.timestamp; items.push({ type: "message", message: msg, messageIndex }); messageIndex++; } @@ -154,6 +185,17 @@ export function SessionViewer({ if (item.type === "redacted_divider") { return ; } + if (item.type === "time_gap") { + return ( +
+
+ + {item.duration} later + +
+
+ ); + } const msg = item.message; const isMatch = searchQuery && @@ -163,6 +205,7 @@ export function SessionViewer({ return (
); } + +function formatDuration(ms: number): string { + const minutes = Math.floor(ms / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainMinutes = minutes % 60; + if (remainMinutes === 0) return `${hours}h`; + return `${hours}h ${remainMinutes}m`; +}