feat: add timeline hover message preview

This commit is contained in:
Shantur Rathore
2025-12-08 09:45:11 +00:00
parent a2e5034c20
commit 9b0e02f66f
4 changed files with 113 additions and 5 deletions

View File

@@ -0,0 +1,27 @@
import type { Component } from "solid-js"
import MessageItem from "./message-item"
import type { MessageRecord } from "../stores/message-v2/types"
import type { MessageInfo } from "../types/message"
interface MessagePreviewProps {
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
}
const MessagePreview: Component<MessagePreviewProps> = (props) => {
return (
<div class="message-preview">
<MessageItem
record={props.record}
messageInfo={props.messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
parts={props.record.partIds.map((id) => props.record.parts[id]?.data).filter((part): part is NonNullable<typeof part> => Boolean(part))}
/>
</div>
)
}
export default MessagePreview

View File

@@ -502,6 +502,8 @@ export default function MessageSection(props: MessageSectionProps) {
segments={timelineSegments()}
onSegmentClick={handleTimelineSegmentClick}
activeMessageId={activeMessageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
</Show>

View File

@@ -1,4 +1,6 @@
import { For, createEffect, onCleanup, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
@@ -17,10 +19,12 @@ interface MessageTimelineProps {
segments: TimelineSegment[]
onSegmentClick?: (segment: TimelineSegment) => void
activeMessageId?: string | null
instanceId: string
sessionId: string
}
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
user: "User",
user: "You",
assistant: "Asst",
tool: "Tool",
}
@@ -204,6 +208,10 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const buttonRefs = new Map<string, HTMLButtonElement>()
const store = () => messageStoreBus.getOrCreate(props.instanceId)
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
const [tooltipCoords, setTooltipCoords] = createSignal<{ top: number; left: number }>({ top: 0, left: 0 })
let hoverTimer: number | null = null
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) {
@@ -213,6 +221,36 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
}
const clearHoverTimer = () => {
if (hoverTimer !== null && typeof window !== "undefined") {
window.clearTimeout(hoverTimer)
hoverTimer = null
}
}
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
if (typeof window === "undefined") return
clearHoverTimer()
const target = event.currentTarget as HTMLButtonElement
hoverTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect()
const preferredTop = rect.top + rect.height / 2
const clampedTop = Math.min(window.innerHeight - 220, Math.max(16, preferredTop - 110))
const tooltipWidth = 360
const preferredLeft = rect.right + 12
const clampedLeft = Math.min(window.innerWidth - tooltipWidth - 16, preferredLeft)
setTooltipCoords({ top: clampedTop, left: clampedLeft })
setHoveredSegment(segment)
}, 200)
}
const handleMouseLeave = () => {
clearHoverTimer()
setHoveredSegment(null)
}
onCleanup(() => clearHoverTimer())
createEffect(() => {
const activeId = props.activeMessageId
if (!activeId) return
@@ -230,6 +268,15 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
})
})
const previewData = createMemo(() => {
const segment = hoveredSegment()
if (!segment) return null
const record = store().getMessage(segment.messageId)
if (!record) return null
const info = store().getMessageInfo(segment.messageId)
return { record, info }
})
return (
<div class="message-timeline" role="navigation" aria-label="Message timeline">
<For each={props.segments}>
@@ -241,9 +288,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
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)}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<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>
@@ -251,9 +299,21 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
)
}}
</For>
<Show when={previewData()}>
{(data) => (
<div class="message-timeline-tooltip" style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}>
<MessagePreview
record={data().record}
messageInfo={data().info}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)}
</Show>
</div>
)
}
export default MessageTimeline

View File

@@ -149,4 +149,23 @@
}
}
.message-timeline-tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
}
.message-preview {
width: 360px;
max-height: 420px;
overflow: auto;
border-radius: 8px;
border: 1px solid var(--border-base);
background-color: var(--surface-base);
box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25));
padding: 0.75rem;
}
.message-preview .message-item-base {
font-size: 0.85rem;
}