Land the pre-talk WebUI campaign: first-frame stability (pre-paint theme boot via CSP-safe public/theme-boot.js, self-hosted fonts + OFL license), streaming performance (rAF token batching, throttled flush, streaming markdown with deferred highlight, 160-row windowing, swarm state in its own store slice with per-run coalescing), correctness (FIFO tool matching, retry preserves request semantics, display/request separation, cache-hit session refresh, multi-tab message.received), guided-minimalism welcome (time-aware greeting + brand lockup + 4 quick actions + collapsible library, fill-first affordance), SwarmDashboard rebuild with honest indeterminate progress, five-locale copy de-jargoning with lazy locale chunks, accessibility Level-A fixes (color-blind metric glyphs, aria-live streaming, skip link, focus-visible affordances), robustness (markdown link/table/KaTeX overflow, safe localStorage, portal modal), and '/' now opens the product with marketing moved to /about.
76 lines
2.5 KiB
TypeScript
76 lines
2.5 KiB
TypeScript
import { memo, useEffect, useState, useCallback, useMemo } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import type { AgentMessage } from "@/types/agent";
|
|
|
|
interface Props {
|
|
messages: AgentMessage[];
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
}
|
|
|
|
export const ConversationTimeline = memo(function ConversationTimeline({ messages, containerRef }: Props) {
|
|
const [activeIdx, setActiveIdx] = useState(-1);
|
|
|
|
const userIndices = useMemo(() => messages
|
|
.map((m, i) => m.type === "user" ? i : -1)
|
|
.filter(i => i >= 0)
|
|
.slice(-40), [messages]);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || userIndices.length === 0) return;
|
|
|
|
let rafId = 0;
|
|
const onScroll = () => {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(() => {
|
|
const rect = container.getBoundingClientRect();
|
|
const mid = rect.top + rect.height / 2;
|
|
let closest = userIndices[0];
|
|
let minDist = Infinity;
|
|
for (const idx of userIndices) {
|
|
const el = container.querySelector(`[data-msg-idx="${idx}"]`);
|
|
if (!el) continue;
|
|
const elRect = el.getBoundingClientRect();
|
|
const dist = Math.abs(elRect.top - mid);
|
|
if (dist < minDist) { minDist = dist; closest = idx; }
|
|
}
|
|
setActiveIdx(closest);
|
|
});
|
|
};
|
|
|
|
container.addEventListener("scroll", onScroll, { passive: true });
|
|
onScroll();
|
|
return () => {
|
|
cancelAnimationFrame(rafId);
|
|
container.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [containerRef, userIndices]);
|
|
|
|
const scrollTo = useCallback((idx: number) => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const el = container.querySelector(`[data-msg-idx="${idx}"]`);
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}, [containerRef]);
|
|
|
|
if (userIndices.length < 2) return null;
|
|
|
|
return (
|
|
<div className="fixed right-3 top-1/2 -translate-y-1/2 h-2/3 flex flex-col justify-between items-center z-20 py-4">
|
|
{userIndices.map((idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => scrollTo(idx)}
|
|
className={cn(
|
|
"rounded-full transition-all shrink-0",
|
|
idx === activeIdx
|
|
? "w-3 h-3 bg-primary shadow-sm shadow-primary/30"
|
|
: "w-2 h-2 bg-muted-foreground/25 hover:bg-muted-foreground/50"
|
|
)}
|
|
title={messages[idx].content.slice(0, 40)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|