Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
a167c44
feat(app): animate the timeline like iOS while a session runs
kitlangton Oct 20, 2026
3e9d6af
feat(app): ease the composer region with the timeline
kitlangton Oct 20, 2026
789dbc0
fix(session-ui): retain streamed paragraphs on completion
kitlangton Oct 20, 2026
b336a7d
fix(app): clip send flights behind the composer
kitlangton Oct 20, 2026
11bb2e1
fix(app): preserve bottom follow through layout changes
kitlangton Oct 20, 2026
9f8cb10
fix(app): scroll submissions to the end from history
kitlangton Oct 20, 2026
c8230b2
fix(app): retain row entrance after deferred measurement
kitlangton Oct 20, 2026
e9133f0
fix(app): keep exiting timeline rows mounted through their fade
kitlangton Oct 20, 2026
13b0fa8
fix(app): animate activity capsule count widths
kitlangton Oct 20, 2026
ceb056b
fix(session-ui): animate subagent card width changes
kitlangton Oct 20, 2026
e76dee8
feat(app): measure timeline rows with pretext
kitlangton Oct 20, 2026
cd9b9ab
test(app): verify pretext parity and long-history jumps
kitlangton Oct 20, 2026
cfd81c5
fix(app): reconcile pretext geometry with timeline motion
kitlangton Oct 20, 2026
da8487f
test(session-ui): type the pretext parity fixtures
kitlangton Oct 20, 2026
437b0ce
fix(app): stop cold timeline measurement microtask loop
kitlangton Oct 20, 2026
9c77de6
fix(app): avoid redundant timeline scroll layouts
kitlangton Oct 20, 2026
036f55a
feat(app): demo session in the debug bar
kitlangton Oct 20, 2026
0614dc7
feat(app): random demo turns for typed prompts
kitlangton Oct 20, 2026
4f2dae1
docs: describe random demo turns
kitlangton Oct 20, 2026
e1c3870
fix(app): vary each step of a random demo turn
kitlangton Oct 20, 2026
0857945
fix(app): give every random demo turn at least three tool steps
kitlangton Oct 20, 2026
a7ace24
fix(app): keep random demo turns within ten seconds
kitlangton Oct 20, 2026
8b36cbb
fix(app): send from a fresh demo session and vary list items
kitlangton Oct 20, 2026
323e703
feat(debug): stream long demo replies across tool boundaries
kitlangton Oct 20, 2026
ccedc29
fix(app): retarget steer flights and size pending actions
kitlangton Oct 20, 2026
ec41ef5
fix(session-ui): preserve markdown identity across preparation paths
kitlangton Oct 20, 2026
146b026
fix(app,session-ui): keep streamed words settled and remove row-growt…
kitlangton Oct 21, 2026
fee08e6
fix(app,session-ui): share row-growth spring and eliminate split-shif…
kitlangton Oct 21, 2026
95c04e6
feat(app,session-ui): keep Working in its own tail row and hand off a…
kitlangton Oct 21, 2026
baa195c
perf(app): defer growth scrollTop writes and flush SSE events on rAF
kitlangton Oct 21, 2026
4b693c9
perf(session-ui): retain highlighted code tokens across streaming deltas
kitlangton Oct 21, 2026
62d9126
fix(app,session-ui,ui): eliminate timeline motion splits and pops and…
kitlangton Oct 21, 2026
e67d25c
feat(app): sub-line pretext streaming height, adaptive feather cushio…
kitlangton Oct 21, 2026
3f16c6c
fix(app): keep Working anchored while steers promote
kitlangton Oct 21, 2026
56e4b16
fix(app): let a run's gap spring without cutting short the Working glide
kitlangton Oct 21, 2026
a65072e
fix(app): ease rows to their new heights when the timeline width changes
kitlangton Oct 21, 2026
0541006
perf(app): stop reading the header height on every timeline resize
kitlangton Oct 21, 2026
2d68f39
perf(app): re-measure timeline rows once a width change settles
kitlangton Oct 21, 2026
39339cc
perf(app): keep the timeline model when unrelated fonts load
kitlangton Oct 21, 2026
86e7f3c
fix(session-ui): group a delivered batch of steers and quiet the Pend…
kitlangton Oct 21, 2026
4e3e900
feat(app): tunable streaming edge with Smooth, Snappier, Instant line…
kitlangton Oct 21, 2026
1bdd6fa
fix(app): measure the composer dock on the 1/64 layout grid
kitlangton Oct 21, 2026
51684d4
feat(session-ui): a quiet Steering label above a waiting run, and one…
kitlangton Oct 21, 2026
4935cc5
feat(app): a delivered queued prompt flies from its queue row onto it…
kitlangton Oct 21, 2026
e4578a9
fix(app): a leaving queue row closes on its flight's spring
kitlangton Oct 21, 2026
0d4d1f4
fix(app): a promoted steer's row shrinks on the promotion glide
kitlangton Oct 21, 2026
c35d600
fix(session-ui): show a stream's held last word once the text pauses
kitlangton Oct 21, 2026
28b18c3
fix(session-ui): model code lines at the code block's own line height
kitlangton Oct 21, 2026
32999b3
test(session-ui): hover a waiting prompt before using its actions
kitlangton Oct 21, 2026
f266f87
fix(session-ui): probe code block chrome with the .highlights class
kitlangton Oct 22, 2026
304f64d
test(session-ui): allow Windows' fallback-font wrap on the CJK and em…
kitlangton Oct 22, 2026
2391f16
test(session-ui): allow the Windows fallback-font wrap in the reasoni…
kitlangton Oct 22, 2026
bfe3296
feat(app): try four streaming edges from Knobs, Experiments
kitlangton Oct 22, 2026
bc096f7
fix(app): land rapid and long sends in their final place
kitlangton Oct 22, 2026
d5bbedb
fix(app): land sent prompts in their resting timeline slot
kitlangton Oct 22, 2026
f8f5d2f
fix(app): preserve row pace when streaming completes
kitlangton Oct 22, 2026
318c065
fix(app): settle timeline content when returning to a session
kitlangton Oct 22, 2026
5f4732c
fix(app): start queued row entrances without inherited speed
kitlangton Oct 22, 2026
eb5e0d3
fix(app): isolate row entrance distance from queued growth
kitlangton Oct 22, 2026
5f9564f
fix(app): let complete timeline arrivals open together
kitlangton Oct 22, 2026
6b7ec3d
fix(app): isolate arrival pacing and settle timeline exits
kitlangton Oct 22, 2026
ab98ff1
fix(app): preserve timeline growth across scroll intent
kitlangton Oct 22, 2026
e826492
fix(app): keep arrivals and exits continuous during navigation
kitlangton Oct 22, 2026
a485a2d
fix(app): preserve active growth when following resumes
kitlangton Oct 22, 2026
8a85ef8
fix(app): bound timeline returns and shared growth travel
kitlangton Oct 22, 2026
d835ec8
fix(app): preserve distinct send-flight launch times
kitlangton Oct 22, 2026
692f03c
fix(app): reconcile timeline motion with beta layout
kitlangton Oct 22, 2026
7b0237c
feat(debug): open the demo with Knobs on Experiments
kitlangton Oct 22, 2026
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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 158 additions & 0 deletions packages/app/component-tests/timeline-pretext.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { SessionDocument } from "@opencode/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL, STORY_TIME } from "@opencode/session-ui/storybook"
import { createReactiveTimelineProjection, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
import type { TimelineRowLayout } from "@opencode/session-ui/timeline/text-layout"
import { DialogProvider } from "@opencode/ui/context/dialog"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"

let report = () => ({
count: 0,
fallbacks: 0,
expected: 0,
extent: 0,
offset: 0,
viewport: 0,
mounted: 0,
mismatches: [] as string[],
})
let reveal = (_id: string) => {}

export function mountLongTimeline(count = 1800) {
const host = document.createElement("main")
host.dataset.testid = "pretext-long-timeline"
host.style.cssText =
"position:fixed;inset:24px;width:744px;height:640px;z-index:1000;background:var(--v2-background-bg-base)"
document.body.append(host)
const fixture: SessionDocument = {
sessionID: "pretext-long-session",
status: { type: "idle" },
diffs: [],
messages: Array.from({ length: count }, (_, index) => index).flatMap((index) => [
{
id: `long-user-${index}`,
type: "user" as const,
text: `Question ${index + 1}: ${"Explain how this part of the timeline works. ".repeat(1 + (index % 4))}`,
metadata: { agent: "build", model: STORY_MODEL },
time: { created: STORY_TIME + index * 2 },
},
{
id: `long-assistant-${index}`,
type: "assistant" as const,
agent: "build",
model: STORY_MODEL,
content: [
{
type: "text" as const,
text:
index % 3 === 0
? `## Result ${index + 1}\n\n${"The prepared text preserves **strong words** and `inline code`. ".repeat(1 + (index % 7))}`
: index % 3 === 1
? `A concrete implementation.\n\n\`\`\`ts\nconst result = ${index};\nconsole.log(result);\n\`\`\`\n\nThe source stays selectable.`
: `These are the steps.\n\n- Prepare the text once.\n- Reuse its measured advances when the viewport changes.\n- Paint the same revision.\n\nReady.`,
},
],
time: { created: STORY_TIME + index * 2, completed: STORY_TIME + index * 2 + 1 },
},
]),
}
function Fixture() {
const [state, setState] = createStore({ pinned: true })
const sizes = new Map<string, TimelineRowLayout>()
let viewport: HTMLDivElement | undefined
const projection = createReactiveTimelineProjection({
sessionMessages: () => fixture.messages,
status: () => fixture.status,
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
})
const timeline = createTimelineVirtualizer({
sessionKey: () => fixture.sessionID,
projection,
showHeader: () => false,
layout: (row, environment) => {
const value = renderer.layout(row, environment)
sizes.set(TimelineRow.key(row), value)
return value
},
pinned: () => state.pinned,
scroll: () => ({ overflow: true, jump: !state.pinned }),
setScrollRef: (element) => {
viewport = element
},
setContentRef: () => {},
onPin: () => setState("pinned", true),
onUnpin: () => setState("pinned", false),
onScheduleScrollState: () => {},
onResumeScroll: () => {},
onSelectionInteraction: () => {},
onUserScroll: () => {},
onHistoryScroll: () => {},
canRenderImmediately: () => true,
setRevealMessage: (callback) => {
reveal = callback
},
})
const renderer = createSessionTimelineRowRenderer({
sessionID: () => fixture.sessionID,
status: () => fixture.status,
projection,
presentation: () => undefined,
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
disclosure: timeline.disclosure,
})
report = () => {
const mounted = [...host.querySelectorAll<HTMLElement>("[data-timeline-key]")]
return {
count: projection.rows().length,
fallbacks: projection.rows().filter((row) => sizes.get(TimelineRow.key(row))?.source === "dom").length,
expected: [...sizes.values()].reduce((sum, value) => sum + (value.source === "dom" ? 0 : value.height), 82),
extent: viewport?.scrollHeight ?? 0,
offset: viewport?.scrollTop ?? 0,
viewport: viewport?.clientHeight ?? 0,
mounted: mounted.length,
mismatches: mounted.flatMap((element) => {
const key = element.dataset.timelineKey!
const size = sizes.get(key)
const natural = element.firstElementChild!.getBoundingClientRect().height
return size && size.source !== "dom" && Math.abs(natural - size.height) > 1
? [`${key}: ${natural} != ${size.height}`]
: []
}),
}
}
return (
<timeline.View
header={null}
deferred={() => false}
renderRow={(row, onSizeChange) => <renderer.Row row={row} onSizeChange={onSizeChange} />}
/>
)
}
render(
() => (
<LanguageProvider locale="en">
<DialogProvider>
<CurrentSessionProviders document={fixture}>
<Fixture />
</CurrentSessionProviders>
</DialogProvider>
</LanguageProvider>
),
host,
)
}

export function longTimelineReport() {
return report()
}
export function jumpLongTimeline(index: number) {
reveal(`long-user-${index}`)
return report()
}
74 changes: 64 additions & 10 deletions packages/app/component-tests/timeline-virtualizer.fixture.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { TimelineRow } from "@opencode/session-ui/timeline/projection"
import { onCleanup } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"

export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
export function mountTimelineVirtualizer(input: {
count: number
rowHeight: number
immediate?: boolean
modeled?: number
growthRows?: number
viewportHeight?: number
insertedHeight?: number
}) {
const host = document.createElement("main")
host.dataset.testid = "timeline-virtualizer-fixture"
host.dataset.scrolls = "0"
Expand All @@ -14,30 +22,46 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
document.body.appendChild(host)

function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const [state, setState] = createStore({ pinned: true, ready: false, growth: 0, inserted: false, removed: false })

const rows = Array.from(
const initial = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)

const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
const inserted = new TimelineRow.UserMessage({ userMessageID: "inserted" })
const rows = createMemo(() =>
(state.inserted ? [...initial.slice(0, -1), inserted, initial.at(-1)!] : initial).filter(
(row) => !state.removed || row.userMessageID !== `message-${input.count - 3}`,
),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row])))
const indexes = new Map([...initial, inserted].map((row, index) => [row.userMessageID, index]))
const height = (id: string) =>
(id === "inserted" ? (input.insertedHeight ?? input.rowHeight) : input.rowHeight) +
(indexes.get(id)! >= input.count - 1 - (input.growthRows ?? 1) && indexes.get(id)! < input.count - 1
? state.growth
: 0)
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement

const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
rows,
rowByKey,
activeMessageID: () => undefined,
messageByID: () => new Map(),
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
showHeader: () => false,
layout: (row) =>
row._tag === "UserMessage" && indexes.get(row.userMessageID)! < (input.modeled ?? 0)
? { source: "fixed", height: height(row.userMessageID) }
: { source: "dom", reason: "deferred fixture content" },
pinned: () => state.pinned,
scroll: () => ({ overflow: false, jump: false }),
setScrollRef: (element) => {
Expand All @@ -50,6 +74,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
},
onPin: () => setState("pinned", true),
setScrollToEnd: (scroll) => (scrollToEnd = scroll),
onUnpin: () => setState("pinned", false),
onScheduleScrollState: (element) => {
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
Expand Down Expand Up @@ -94,6 +119,35 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
<button type="button" onClick={() => setState("ready", true)}>
Complete Markdown
</button>
<button type="button" data-testid="grow-row" onClick={() => setState("growth", 120)}>
Grow penultimate row
</button>
<button type="button" data-testid="insert-row" onClick={() => setState("inserted", true)}>
Insert row
</button>
<button type="button" data-testid="remove-row" onClick={() => setState("removed", true)}>
Remove row
</button>
<button
type="button"
data-testid="follow"
onClick={() => {
setState("pinned", true)
scrollToEnd()
}}
>
Follow latest
</button>
<button
type="button"
data-testid="resume"
onClick={() => {
setState("pinned", true)
scrollToEnd("smooth")
}}
>
Resume following
</button>
<button type="button" onClick={() => (container.style.display = "none")}>
Hide viewport
</button>
Expand All @@ -112,15 +166,15 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
>
Reconnect ready rows
</button>
<div ref={container} style={{ height: "180px", width: "400px" }}>
<div ref={container} style={{ height: `${input.viewportHeight ?? 180}px`, width: "400px" }}>
<timeline.View
header={null}
deferred={() => false}
renderRow={(row) => (
<div
data-component="markdown"
data-markdown-ready={state.ready ? "" : undefined}
style={{ height: `${input.rowHeight}px` }}
style={{ height: `${height(row().userMessageID)}px` }}
>
{row().userMessageID}
</div>
Expand Down
Loading
Loading