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()}
|
segments={timelineSegments()}
|
||||||
onSegmentClick={handleTimelineSegmentClick}
|
onSegmentClick={handleTimelineSegmentClick}
|
||||||
activeMessageId={activeMessageId()}
|
activeMessageId={activeMessageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 { ClientPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
@@ -17,10 +19,12 @@ interface MessageTimelineProps {
|
|||||||
segments: TimelineSegment[]
|
segments: TimelineSegment[]
|
||||||
onSegmentClick?: (segment: TimelineSegment) => void
|
onSegmentClick?: (segment: TimelineSegment) => void
|
||||||
activeMessageId?: string | null
|
activeMessageId?: string | null
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||||
user: "User",
|
user: "You",
|
||||||
assistant: "Asst",
|
assistant: "Asst",
|
||||||
tool: "Tool",
|
tool: "Tool",
|
||||||
}
|
}
|
||||||
@@ -204,6 +208,10 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
|
|
||||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
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) => {
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
if (element) {
|
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(() => {
|
createEffect(() => {
|
||||||
const activeId = props.activeMessageId
|
const activeId = props.activeMessageId
|
||||||
if (!activeId) return
|
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 (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||||
<For each={props.segments}>
|
<For each={props.segments}>
|
||||||
@@ -241,9 +288,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
type="button"
|
type="button"
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""}`}
|
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""}`}
|
||||||
title={segment.tooltip}
|
|
||||||
aria-current={isActive() ? "true" : undefined}
|
aria-current={isActive() ? "true" : undefined}
|
||||||
onClick={() => props.onSegmentClick?.(segment)}
|
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-full">{segment.label}</span>
|
||||||
<span class="message-timeline-label message-timeline-label-short">{SEGMENT_SHORT_LABELS[segment.type]}</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default MessageTimeline
|
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