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:
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user