diff --git a/packages/ui/src/components/message-preview.tsx b/packages/ui/src/components/message-preview.tsx new file mode 100644 index 00000000..b8e153fb --- /dev/null +++ b/packages/ui/src/components/message-preview.tsx @@ -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 = (props) => { + return ( +
+ props.record.parts[id]?.data).filter((part): part is NonNullable => Boolean(part))} + /> +
+ ) +} + +export default MessagePreview diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 24c46c61..b6b4aedb 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -502,6 +502,8 @@ export default function MessageSection(props: MessageSectionProps) { segments={timelineSegments()} onSegmentClick={handleTimelineSegmentClick} activeMessageId={activeMessageId()} + instanceId={props.instanceId} + sessionId={props.sessionId} /> diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index e8e7a132..79d4eae7 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -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 = { - user: "User", + user: "You", assistant: "Asst", tool: "Tool", } @@ -204,6 +208,10 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord) const MessageTimeline: Component = (props) => { const buttonRefs = new Map() + const store = () => messageStoreBus.getOrCreate(props.instanceId) + const [hoveredSegment, setHoveredSegment] = createSignal(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 = (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 = (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 ( ) } - - + export default MessageTimeline + diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index 1261fcc6..fddc19f8 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -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; +}