From a2e5034c20d7c9ea2fa649ff957859b8f58a3f5e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 7 Dec 2025 21:59:57 +0000 Subject: [PATCH] feat: sync timeline highlight with scroll --- .../ui/src/components/message-section.tsx | 44 +++++++++++++- .../ui/src/components/message-timeline.tsx | 59 +++++++++++++++---- .../src/styles/messaging/message-timeline.css | 29 ++++++++- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index e8a9826a..24c46c61 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -118,6 +118,7 @@ export default function MessageSection(props: MessageSectionProps) { }) const hasTimelineSegments = () => timelineSegments().length > 0 + const [activeMessageId, setActiveMessageId] = createSignal(null) const changeToken = createMemo(() => String(sessionRevision())) @@ -353,9 +354,44 @@ export default function MessageSection(props: MessageSectionProps) { observer.observe(bottomTarget) onCleanup(() => observer.disconnect()) }) - + + createEffect(() => { + const container = scrollElement() + const ids = messageIds() + if (!container || ids.length === 0) return + if (typeof document === "undefined") return + + const observer = new IntersectionObserver( + (entries) => { + let best: IntersectionObserverEntry | null = null + for (const entry of entries) { + if (!entry.isIntersecting) continue + if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) { + best = entry + } + } + if (best) { + const anchorId = (best.target as HTMLElement).id + const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId + setActiveMessageId((current) => (current === messageId ? current : messageId)) + } + }, + { root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 }, + ) + + ids.forEach((messageId) => { + const anchor = document.getElementById(getMessageAnchorId(messageId)) + if (anchor) { + observer.observe(anchor) + } + }) + + onCleanup(() => observer.disconnect()) + }) + onCleanup(() => { + if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) } @@ -462,7 +498,11 @@ export default function MessageSection(props: MessageSectionProps) {
- +
diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 2e55aa6b..e8e7a132 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, type Component } from "solid-js" +import { For, createEffect, onCleanup, type Component } from "solid-js" import type { ClientPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" @@ -16,6 +16,7 @@ export interface TimelineSegment { interface MessageTimelineProps { segments: TimelineSegment[] onSegmentClick?: (segment: TimelineSegment) => void + activeMessageId?: string | null } const SEGMENT_LABELS: Record = { @@ -202,23 +203,57 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) } const MessageTimeline: Component = (props) => { + const buttonRefs = new Map() + + const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { + if (element) { + buttonRefs.set(segmentId, element) + } else { + buttonRefs.delete(segmentId) + } + } + + createEffect(() => { + const activeId = props.activeMessageId + if (!activeId) return + const targetSegment = props.segments.find((segment) => segment.messageId === activeId) + if (!targetSegment) return + const element = buttonRefs.get(targetSegment.id) + if (!element) return + const timer = typeof window !== "undefined" ? window.setTimeout(() => { + element.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }, 120) : null + onCleanup(() => { + if (timer !== null && typeof window !== "undefined") { + window.clearTimeout(timer) + } + }) + }) + return ( ) } + export default MessageTimeline diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index 4fe7ed00..1261fcc6 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -1,7 +1,7 @@ .message-layout { display: grid; grid-template-columns: minmax(0, 1fr); - gap: 0.5rem; + gap: 0rem; width: 100%; min-height: 0; flex: 1 1 auto; @@ -77,7 +77,15 @@ letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-primary); - transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease; + transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.message-timeline-segment-active { + border-color: transparent; + background-color: #0f5b44; + color: #fff; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); } .message-timeline-segment:hover, @@ -88,6 +96,15 @@ transform: translateY(-1px); } +.message-timeline-segment-active, +.message-timeline-segment-active:hover, +.message-timeline-segment-active:focus-visible { + background-color: #0f5b44; + color: #fff; + transform: none; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + .message-timeline-segment:focus-visible { box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary); } @@ -107,6 +124,14 @@ background-color: var(--surface-secondary); } +.message-timeline-segment-active { + background-color: #0f5b44 !important; + border-color: transparent !important; + color: #fff !important; + font-weight: 700; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + .message-timeline-label { pointer-events: none; }