feat: add timeline sidebar to message view
This commit is contained in:
@@ -3,6 +3,10 @@ import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
}
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 320
|
||||
const INITIAL_FORCE_MIN_ITEMS = 12
|
||||
@@ -73,14 +77,16 @@ export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
}
|
||||
return (
|
||||
<VirtualItem
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
forceVisible={forceVisible}
|
||||
onMeasured={handleMeasured}
|
||||
>
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
forceVisible={forceVisible}
|
||||
onMeasured={handleMeasured}
|
||||
>
|
||||
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList from "./message-block-list"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageListHeader from "./message-list-header"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { showCommandPalette } from "../stores/command-palette"
|
||||
@@ -76,14 +77,21 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instanceId)
|
||||
}
|
||||
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
|
||||
const map = new Map<string, number>()
|
||||
const ids = messageIds()
|
||||
ids.forEach((id, index) => map.set(id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
@@ -95,9 +103,25 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
|
||||
const timelineSegments = createMemo<TimelineSegment[]>(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
const segments: TimelineSegment[] = []
|
||||
ids.forEach((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
segments.push(...built)
|
||||
})
|
||||
return segments
|
||||
})
|
||||
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
@@ -362,76 +386,87 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||
/>
|
||||
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div class="message-stream-shell">
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom()}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom()}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasTimelineSegments()}>
|
||||
<div class="message-timeline-sidebar">
|
||||
<MessageTimeline segments={timelineSegments()} onSegmentClick={handleTimelineSegmentClick} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
224
packages/ui/src/components/message-timeline.tsx
Normal file
224
packages/ui/src/components/message-timeline.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { For, 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"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool"
|
||||
|
||||
export interface TimelineSegment {
|
||||
id: string
|
||||
messageId: string
|
||||
type: TimelineSegmentType
|
||||
label: string
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "User",
|
||||
assistant: "Asst",
|
||||
tool: "Tool",
|
||||
}
|
||||
|
||||
const SEGMENT_SHORT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "U",
|
||||
assistant: "A",
|
||||
tool: "T",
|
||||
}
|
||||
|
||||
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
interface PendingSegment {
|
||||
type: TimelineSegmentType
|
||||
texts: string[]
|
||||
toolTitles: string[]
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function collectReasoningText(part: ClientPart): string {
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
return segment
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const parts: string[] = []
|
||||
if (typeof obj.text === "string") {
|
||||
parts.push(obj.text)
|
||||
}
|
||||
if (typeof obj.value === "string") {
|
||||
parts.push(obj.value)
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
parts.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||
}
|
||||
return parts.filter(Boolean).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if (typeof (part as any)?.text === "string") {
|
||||
return (part as any).text
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return (part as any).content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function collectTextFromPart(part: ClientPart): string {
|
||||
if (!part) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return collectReasoningText(part)
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return ((part as any).content as unknown[])
|
||||
.map((entry) => (typeof entry === "string" ? entry : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
if (part.type === "file") {
|
||||
const filename = (part as any)?.filename
|
||||
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function getToolTitle(part: ToolCallPart): string {
|
||||
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
||||
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||
if (title) return title
|
||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||
return part.tool
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
|
||||
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||
const combined = texts
|
||||
.map((text) => text.trim())
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
if (combined.length > 0) {
|
||||
return truncateText(combined)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatToolTooltip(titles: string[]): string {
|
||||
if (titles.length === 0) {
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
||||
if (!record) return []
|
||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||
if (!orderedParts || orderedParts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: TimelineSegment[] = []
|
||||
let segmentIndex = 0
|
||||
let pending: PendingSegment | null = null
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pending) return
|
||||
const label = SEGMENT_LABELS[pending.type]
|
||||
const tooltip = pending.type === "tool"
|
||||
? formatToolTooltip(pending.toolTitles)
|
||||
: formatTextsTooltip(
|
||||
pending.texts,
|
||||
pending.type === "user" ? "User message" : "Assistant response",
|
||||
)
|
||||
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: pending.type,
|
||||
label,
|
||||
tooltip,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
}
|
||||
|
||||
const ensureSegment = (type: TimelineSegmentType) => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
pending = { type, texts: [], toolTitles: [] }
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
const defaultContentType: TimelineSegmentType = record.role === "user" ? "user" : "assistant"
|
||||
|
||||
for (const part of orderedParts) {
|
||||
if (!part || typeof part !== "object") continue
|
||||
|
||||
if (part.type === "tool") {
|
||||
const target = ensureSegment("tool")
|
||||
target.toolTitles.push(getToolTitle(part as ToolCallPart))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const text = collectReasoningText(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
target.texts.push(text)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = collectTextFromPart(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
target.texts.push(text)
|
||||
}
|
||||
|
||||
flushPending()
|
||||
return result
|
||||
}
|
||||
|
||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageTimeline
|
||||
@@ -99,6 +99,7 @@ interface VirtualItemProps {
|
||||
virtualizationEnabled?: Accessor<boolean>
|
||||
forceVisible?: Accessor<boolean>
|
||||
onMeasured?: () => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
@@ -268,7 +269,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
||||
|
||||
return (
|
||||
<div ref={setWrapperRef} class={wrapperClass()} style={{ width: "100%" }}>
|
||||
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
|
||||
<div
|
||||
class={placeholderClass()}
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user