Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions app-prefixable/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMemo } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
import type { JSX } from "solid-js"
import { marked } from "marked"
import DOMPurify from "dompurify"
Expand All @@ -24,6 +24,7 @@ function sanitize(html: string) {
}

const resetDelay = 2000
const renderInterval = 120

function escapeHtml(value: string) {
return value
Expand Down Expand Up @@ -99,10 +100,51 @@ interface MarkdownProps {
}

export function Markdown(props: MarkdownProps) {
const html = createMemo(() => {
if (!props.content) return ""
const raw = marked.parse(props.content, { async: false, renderer }) as string
return sanitize(raw)
const [html, setHtml] = createSignal("")
const state: {
timer: number | undefined
last: number
queued: string
rendered: string
} = {
timer: undefined,
last: 0,
queued: "",
rendered: "",
}

function render(value: string) {
state.last = Date.now()
state.rendered = value
if (!value) {
setHtml("")
return
}
const raw = marked.parse(value, { async: false, renderer }) as string
setHtml(sanitize(raw))
}

function schedule(value: string) {
state.queued = value
if (value === state.rendered) return
if (state.timer !== undefined) return
const elapsed = Date.now() - state.last
const delay = state.last === 0 || elapsed >= renderInterval ? 0 : renderInterval - elapsed
if (delay === 0) {
render(value)
return
}
state.timer = window.setTimeout(() => {
state.timer = undefined
render(state.queued)
if (state.queued !== state.rendered) schedule(state.queued)
}, delay)
}
Comment thread
hsteude marked this conversation as resolved.

createEffect(() => schedule(props.content))

onCleanup(() => {
if (state.timer !== undefined) clearTimeout(state.timer)
})

const onClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
Expand Down
105 changes: 70 additions & 35 deletions app-prefixable/src/components/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ const TURNS_PER_BATCH = 10
const INITIAL_TURNS = 5
const NEAR_BOTTOM_PX = 10

type TurnRef = {
id: string
userId: string
assistantIds: string[]
}

// Compute turn-level timing from user and assistant message timestamps
function computeTurnTime(user: DisplayMessage, assistants: DisplayMessage[]): Turn["time"] {
const started = user.time?.created
Expand All @@ -29,38 +35,26 @@ function computeTurnTime(user: DisplayMessage, assistants: DisplayMessage[]): Tu
return { started, completed, duration }
}

// Convert flat message list to turns (user + assistant groupings)
function messagesToTurns(messages: DisplayMessage[]): Turn[] {
const turns: Turn[] = []
let current: Turn | null = null
function messagesToTurnRefs(messages: DisplayMessage[]): TurnRef[] {
const turns: TurnRef[] = []
let current: TurnRef | null = null

for (const msg of messages) {
if (msg.role === "user") {
// Start a new turn
if (current) {
current.time = computeTurnTime(current.userMessage, current.assistantMessages)
turns.push(current)
}
if (current) turns.push(current)
current = {
id: msg.id,
userMessage: msg,
assistantMessages: [],
userId: msg.id,
assistantIds: [],
}
} else if (msg.role === "assistant" && current) {
// Add to current turn
current.assistantMessages.push(msg)
current.assistantIds.push(msg.id)
} else if (msg.role === "assistant" && !current) {
// Handle assistant messages before first user message
console.warn("MessageTimeline: Dropping assistant message before first user message", msg.id)
}
}

// Don't forget the last turn
if (current) {
current.time = computeTurnTime(current.userMessage, current.assistantMessages)
turns.push(current)
}

if (current) turns.push(current)
return turns
}

Expand All @@ -71,6 +65,18 @@ function hasVisibleContent(message: DisplayMessage): boolean {
return extractTextContent(message.parts).trim().length > 0
}

function hasStructuredContent(message: DisplayMessage): boolean {
if (message.error) return true
if (message.role === "user") return true
return message.parts.some((p) => p.type === "tool" || p.type === "text" || p.type === "reasoning")
}

function timelineStructure(messages: DisplayMessage[]) {
return messages
.map((msg) => `${msg.id}:${msg.role}:${msg.error ? 1 : 0}:${msg.parts.map((p) => p.type).join(",")}`)
.join("|")
}

export function MessageTimeline(props: {
messages: DisplayMessage[]
processing: boolean
Expand Down Expand Up @@ -98,39 +104,68 @@ export function MessageTimeline(props: {
const [userScrolledUp, setUserScrolledUp] = createSignal(false)
// Track previous turn IDs for session switch detection
const [prevTurnIds, setPrevTurnIds] = createSignal<Set<string>>(new Set())
const structure = createMemo(() => timelineStructure(props.messages))

// Convert messages to turns
const turns = createMemo(() => {
const filtered = props.messages.filter(hasVisibleContent)
return messagesToTurns(filtered)
const messageById = createMemo(() => {
const map = new Map<string, DisplayMessage>()
for (const msg of props.messages) map.set(msg.id, msg)
return map
})

// Convert messages to turn references only when structure changes.
const turnRefs = createMemo(() => {
structure()
const filtered = untrack(() => props.messages).filter(hasStructuredContent)
return messagesToTurnRefs(filtered)
})
Comment thread
hsteude marked this conversation as resolved.

function resolveTurn(ref: TurnRef): Turn | undefined {
const map = messageById()
const user = map.get(ref.userId)
if (!user) return undefined
const assistants = ref.assistantIds.flatMap((id) => {
const msg = map.get(id)
if (!msg) return []
return hasVisibleContent(msg) ? [msg] : []
})
return {
id: ref.id,
userMessage: user,
assistantMessages: assistants,
time: computeTurnTime(user, assistants),
}
Comment thread
hsteude marked this conversation as resolved.
}

// Calculate which turns to render (from the end, most recent first in render order)
const renderedTurns = createMemo(() => {
const all = turns()
const renderedTurnRefs = createMemo(() => {
const all = turnRefs()
const count = Math.min(renderCount(), all.length)
// Take from the end (most recent), but return in chronological order
return all.slice(Math.max(0, all.length - count))
})

const renderedTurns = createMemo(() => {
return renderedTurnRefs().flatMap((ref) => resolveTurn(ref) ?? [])
})

// Check if there are more turns to load
const hasMore = createMemo(() => renderCount() < turns().length)
const hasMore = createMemo(() => renderCount() < turnRefs().length)

// Get the last turn (for showing streaming content)
const lastTurn = createMemo(() => {
const all = turns()
return all.length > 0 ? all[all.length - 1] : null
const all = turnRefs()
const ref = all[all.length - 1]
return ref ? resolveTurn(ref) ?? null : null
})

// Load more earlier turns with scroll anchoring
function loadMore() {
if (!containerRef) {
setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turns().length))
setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turnRefs().length))
return
}
// Save scroll position relative to bottom before loading
const scrollBottom = containerRef.scrollHeight - containerRef.scrollTop
setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turns().length))
setRenderCount((prev) => Math.min(prev + TURNS_PER_BATCH, turnRefs().length))
// Restore scroll position after DOM update
requestAnimationFrame(() => {
if (containerRef) {
Expand Down Expand Up @@ -194,7 +229,7 @@ export function MessageTimeline(props: {

// Reset render count when session changes (detect by comparing turn IDs)
createEffect(() => {
const currentTurns = turns()
const currentTurns = turnRefs()
const currentIds = new Set(currentTurns.map((t) => t.id))
const prevIds = untrack(() => prevTurnIds())

Expand Down Expand Up @@ -260,7 +295,7 @@ export function MessageTimeline(props: {
}}
>
<ChevronUp class="w-4 h-4" />
<span>Load {Math.min(TURNS_PER_BATCH, turns().length - renderCount())} earlier turns</span>
<span>Load {Math.min(TURNS_PER_BATCH, turnRefs().length - renderCount())} earlier turns</span>
</button>
</div>
</Show>
Expand Down Expand Up @@ -299,7 +334,7 @@ export function MessageTimeline(props: {
</Show>

{/* Empty state */}
<Show when={turns().length === 0 && !props.processing}>
<Show when={turnRefs().length === 0 && !props.processing}>
<div class="flex flex-col items-center justify-center h-full text-center py-12">
<div
class="w-16 h-16 rounded-full flex items-center justify-center mb-4"
Expand Down
Loading