feat: sync timeline highlight with scroll

This commit is contained in:
Shantur Rathore
2025-12-07 21:59:57 +00:00
parent e3489b22e6
commit a2e5034c20
3 changed files with 116 additions and 16 deletions

View File

@@ -118,6 +118,7 @@ export default function MessageSection(props: MessageSectionProps) {
})
const hasTimelineSegments = () => timelineSegments().length > 0
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(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) {
<Show when={hasTimelineSegments()}>
<div class="message-timeline-sidebar">
<MessageTimeline segments={timelineSegments()} onSegmentClick={handleTimelineSegmentClick} />
<MessageTimeline
segments={timelineSegments()}
onSegmentClick={handleTimelineSegmentClick}
activeMessageId={activeMessageId()}
/>
</div>
</Show>
</div>

View File

@@ -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<TimelineSegmentType, string> = {
@@ -202,23 +203,57 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
}
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const buttonRefs = new Map<string, HTMLButtonElement>()
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 (
<div class="message-timeline" role="navigation" aria-label="Message timeline">
<For each={props.segments}>
{(segment) => (
<button
type="button"
class={`message-timeline-segment message-timeline-${segment.type}`}
title={segment.tooltip}
onClick={() => props.onSegmentClick?.(segment)}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</span>
</button>
)}
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId
return (
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""}`}
title={segment.tooltip}
aria-current={isActive() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</span>
</button>
)
}}
</For>
</div>
)
}
export default MessageTimeline

View File

@@ -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;
}