fix(timeline): space every marker evenly (#1062)

The rail doubled the gap wherever a tool marker started a different message, a collapsed tool group ended or a prompt followed an assistant with tools: a V1 notion of tools belonging to their assistant message. OpenCode V2 records each model call as its own message, so almost every tool started a new group while tools called together stayed tight, giving irregular, pronounced gaps that meant nothing to the reader. Every visible marker now follows the previous one by one uniform gap, and the message-tool bookkeeping is removed.

Validation: render-cost checks the exact 10,000-marker extent with uniform gaps; timeline-tool-filters, history-navigation and system-messages browser tests and the UI typecheck pass.
This commit is contained in:
Pascal André 2026-10-21 03:28:29 +02:00 • committed by GitHub
parent da0dc279a7
commit 00f39ada16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 7 additions and 55 deletions

View file

@ -101,7 +101,7 @@ The timeline uses the same standard native scrollbar as the transcript, with one
ordinary gutter. Marker rectangles give up width to it; fixed-size icons and
vertical geometry never scale with available width. `timeline-virtual-list.tsx`
measures the marker height and gap primitives, computes exact offsets (including
group spacing), and mounts only the visible range. Hidden tools are excluded from
the uniform gaps between markers), and mounts only the visible range. Hidden tools are excluded from
the layout. Browsing offscreen rows cannot change the scrollbar extent.
Manual rail browsing suppresses active-marker reveals until an explicit transcript

View file

@ -446,15 +446,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
onCleanup(() => { observer.disconnect(); window.removeEventListener("resize", clearHoverPreview) })
})
// Messages with at least one tool segment, for the group spacing below.
const messagesWithTools = createMemo(() => {
const set = new Set<string>()
for (const s of props.segments) {
if (s.type === "tool") set.add(s.messageId)
}
return set
})
const segmentStates = createMemo(() => {
const resolvedStore = store()
const result = new Map<string, TimelineSegmentState>()
@ -507,46 +498,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const messageId = messageBySegmentId().get(id)
return messageId === undefined ? null : firstVisibleByMessage().get(messageId) ?? null
})
const segmentSpacerHeights = createMemo(() => {
const states = segmentStates()
const result = new Map<string, number>()
let previousVisible: TimelineSegment | null = null
for (let index = 0; index < props.segments.length; index += 1) {
const segment = props.segments[index]
const state = states.get(segment.id)
if (state?.hidden) {
result.set(segment.id, 0)
continue
}
if (!previousVisible) {
result.set(segment.id, 0)
previousVisible = segment
continue
}
const previousRaw = index > 0 ? props.segments[index - 1] : null
const startsVisibleToolGroup = segment.type === "tool"
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
const startsCollapsedToolGroup = segment.type === "assistant"
&& previousVisible.messageId !== segment.messageId
&& messagesWithTools().has(segment.messageId)
&& previousRaw?.type === "tool"
&& previousRaw.messageId === segment.messageId
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
&& previousVisible.type === "assistant"
&& messagesWithTools().has(previousVisible.messageId)
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
result.set(segment.id, gapUnits)
previousVisible = segment
}
return result
})
// One uniform gap between visible markers, none before the first. V2 records
// each model call as its own message, so message-based group gaps were noise.
const segmentSpacerHeights = createMemo(() => new Map(visibleSegments().map((segment, index) => [segment.id, index ? 1 : 0])))
return (
<div class="message-timeline-container">

View file

@ -8,7 +8,7 @@ export interface TimelineListHandle {
/** Timeline markers have known geometry, unlike transcript messages. Measure
* the two CSS primitives once and give the native scrollbar the exact extent,
* including offscreen group gaps. No estimated or zero-height hidden rows. */
* including offscreen gaps. No estimated or zero-height hidden rows. */
export default function TimelineVirtualList<T>(props: {
items: T[]
gap: (item: T) => number

View file

@ -414,10 +414,8 @@ test("timeline exact extent, bounded DOM and active highlight updates do not mou
const marker = document.querySelector<HTMLElement>(".timeline-measure button")!
const gap = document.querySelector<HTMLElement>(".timeline-measure div")!
const markerHeight = parseFloat(getComputedStyle(marker).height), gapHeight = parseFloat(getComputedStyle(gap).height)
// Each 3-marker group has a double gap before its tool and following user.
let units = 0
for (let index = 1; index < 10000; index++) units += index % 3 === 2 ? 1 : 2
return { extent: parseFloat(content.style.height), expected: markerHeight * 10000 + gapHeight * units, mounted: document.querySelectorAll(".timeline-virtual-row").length }
// One uniform gap between consecutive markers, whatever their message.
return { extent: parseFloat(content.style.height), expected: markerHeight * 10000 + gapHeight * 9999, mounted: document.querySelectorAll(".timeline-virtual-row").length }
})
console.log("timeline-geometry", geometry)
// CSS serialization and native scroll extents round at this magnitude.