feat: add timeline hover message preview
This commit is contained in:
27
packages/ui/src/components/message-preview.tsx
Normal file
27
packages/ui/src/components/message-preview.tsx
Normal 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
|
||||
@@ -502,6 +502,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
activeMessageId={activeMessageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user