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 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 09:26:27 -05:00
parent 0f3739605c
commit c0e4158b77

View File

@@ -58,7 +58,22 @@ export function SessionViewer({
} }
}, [focusedIndex]); }, [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. // Must be called before any early returns to satisfy Rules of Hooks.
const displayItems = useMemo(() => { const displayItems = useMemo(() => {
if (messages.length === 0) return []; if (messages.length === 0) return [];
@@ -67,9 +82,11 @@ export function SessionViewer({
const items: Array< const items: Array<
| { type: "message"; message: ParsedMessage; messageIndex: number } | { type: "message"; message: ParsedMessage; messageIndex: number }
| { type: "redacted_divider"; key: string } | { type: "redacted_divider"; key: string }
| { type: "time_gap"; key: string; duration: string }
> = []; > = [];
let prevWasRedactedGap = false; let prevWasRedactedGap = false;
let prevTimestamp: string | undefined;
let messageIndex = 0; let messageIndex = 0;
for (const msg of allMessages) { for (const msg of allMessages) {
if (redactedUuids.has(msg.uuid)) { if (redactedUuids.has(msg.uuid)) {
@@ -86,6 +103,20 @@ export function SessionViewer({
}); });
prevWasRedactedGap = false; 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 }); items.push({ type: "message", message: msg, messageIndex });
messageIndex++; messageIndex++;
} }
@@ -154,6 +185,17 @@ export function SessionViewer({
if (item.type === "redacted_divider") { if (item.type === "redacted_divider") {
return <RedactedDivider key={item.key} />; return <RedactedDivider key={item.key} />;
} }
if (item.type === "time_gap") {
return (
<div key={item.key} className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-border-muted" />
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
{item.duration} later
</span>
<div className="flex-1 h-px bg-border-muted" />
</div>
);
}
const msg = item.message; const msg = item.message;
const isMatch = const isMatch =
searchQuery && searchQuery &&
@@ -163,6 +205,7 @@ export function SessionViewer({
return ( return (
<div <div
key={msg.uuid} key={msg.uuid}
id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex} data-msg-index={item.messageIndex}
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`} className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }} style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
@@ -184,3 +227,12 @@ export function SessionViewer({
</div> </div>
); );
} }
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`;
}