Files
session-viewer/src/client/components/SessionViewer.tsx
teernisse 4ec186d45b Skip entrance animation for messages beyond the first 20
Messages at index 20+ no longer receive the animate-fade-in class or
animationDelay inline style. This avoids scheduling hundreds of CSS
animations on large sessions where the stagger would be invisible
anyway (the earlier cap of 300ms max delay was already clamping them).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:36:02 -05:00

241 lines
9.1 KiB
TypeScript

import React, { useRef, useEffect, useMemo } from "react";
import type { ParsedMessage } from "../lib/types";
import { MessageBubble } from "./MessageBubble";
import { RedactedDivider } from "./RedactedDivider";
interface Props {
messages: ParsedMessage[];
allMessages: ParsedMessage[];
redactedUuids: Set<string>;
loading: boolean;
searchQuery: string;
selectedForRedaction: Set<string>;
onToggleRedactionSelection: (uuid: string) => void;
autoRedactEnabled: boolean;
focusedIndex?: number;
}
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
return (
<div
className="rounded-xl border border-border-muted bg-surface-raised p-4 space-y-3"
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-center gap-2">
<div className="skeleton w-2 h-2 rounded-full" />
<div className="skeleton h-3 w-20" />
</div>
<div className="space-y-2">
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-5/6" />
<div className="skeleton h-4 w-3/4" />
</div>
</div>
);
}
export function SessionViewer({
messages,
allMessages,
redactedUuids,
loading,
searchQuery,
selectedForRedaction,
onToggleRedactionSelection,
autoRedactEnabled,
focusedIndex = -1,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusedIndex >= 0 && containerRef.current) {
const el = containerRef.current.querySelector(
`[data-msg-index="${focusedIndex}"]`
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
}, [focusedIndex]);
// 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 [];
const visibleUuids = new Set(messages.map((m) => m.uuid));
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)) {
prevWasRedactedGap = true;
continue;
}
if (!visibleUuids.has(msg.uuid)) {
continue;
}
if (prevWasRedactedGap) {
items.push({
type: "redacted_divider",
key: `divider-${msg.uuid}`,
});
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++;
}
return items;
}, [messages, allMessages, redactedUuids]);
if (loading) {
return (
<div className="max-w-6xl mx-auto p-6 space-y-4">
<div className="skeleton h-4 w-24 mb-2" />
{[...Array(4)].map((_, i) => (
<MessageSkeleton key={i} delay={i * 100} />
))}
</div>
);
}
if (messages.length === 0 && allMessages.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
>
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">Select a session</p>
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
</div>
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
>
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">No matching messages</p>
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-6 py-5">
<div className="flex items-center justify-between mb-4">
<span className="text-caption text-foreground-muted tabular-nums">
{messages.length} message{messages.length !== 1 ? "s" : ""}
</span>
</div>
<div ref={containerRef} className="space-y-3">
{displayItems.map((item, idx) => {
if (item.type === "redacted_divider") {
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 lq = searchQuery ? searchQuery.toLowerCase() : "";
const isMatch =
searchQuery &&
(msg.content.toLowerCase().includes(lq) ||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
const isDimmed = searchQuery && !isMatch;
const isFocused = item.messageIndex === focusedIndex;
return (
<div
key={msg.uuid}
id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex}
className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={idx < 20 ? { animationDelay: `${idx * 20}ms`, animationFillMode: "backwards" } : undefined}
>
<MessageBubble
message={msg}
searchQuery={searchQuery}
dimmed={!!isDimmed}
selectedForRedaction={selectedForRedaction.has(msg.uuid)}
onToggleRedactionSelection={() =>
onToggleRedactionSelection(msg.uuid)
}
autoRedactEnabled={autoRedactEnabled}
/>
</div>
);
})}
</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`;
}