Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d7dbc0
fix(session-ui): cohesive segments — label heuristic + blank-card guard
jeonghun-jj-lee Aug 28, 2026
f04b736
fix(app): persistent dot overlay — turn-spanning, step-boundary trave…
jeonghun-jj-lee Aug 28, 2026
f8726a1
fix(app): smooth scroll — custom 180ms RAF ease-out replacing instant…
jeonghun-jj-lee Aug 28, 2026
df85128
fix(app): rail continuity + inset + crossfade
jeonghun-jj-lee Aug 28, 2026
e528223
fix(app): fix overlay dot rendering — use reactive virtualItemByKey, …
jeonghun-jj-lee Aug 28, 2026
7ed91f5
fix(app): align overlay with row centering — mirror md:max-w/mx-auto/…
jeonghun-jj-lee Aug 28, 2026
3dd3dfe
fix(app): use DOM measurement for overlay dot position
jeonghun-jj-lee Aug 28, 2026
72d289f
fix(app): use rowEl.style.top + turnEl.offsetTop for overlay positioning
jeonghun-jj-lee Aug 28, 2026
2c7b65a
fix(app): restore text-part breathing room + revert overlay to per-ro…
jeonghun-jj-lee Aug 29, 2026
2b8acfc
fix(app): move ThinkingMeta above interruption divider
jeonghun-jj-lee Aug 29, 2026
80cf73c
fix(app): unify prose-fragment entrance to 300ms + blur
jeonghun-jj-lee Aug 29, 2026
46aa730
fix(session-ui): heading-anchored prose segmentation
jeonghun-jj-lee Aug 29, 2026
060e154
fix(app): bottom-anchor the harmonic dot — content grows above it
jeonghun-jj-lee Aug 29, 2026
e5ee0e0
fix(app): dot centres on Thinking/status rows, bottom-anchors on content
jeonghun-jj-lee Aug 29, 2026
eb3333c
fix(app): dot centres on tool/shell rows, bottom-anchors only on prose
jeonghun-jj-lee Aug 29, 2026
231be8a
fix(app): align prose dot with last text line centre (21px inset)
jeonghun-jj-lee Aug 29, 2026
c82f371
fix(app): detect tool rows by group.type, not railLabel
jeonghun-jj-lee Aug 29, 2026
d07a03f
fix(app): initialize dotCentre from group type, not hardcoded 11px
jeonghun-jj-lee Aug 29, 2026
91c3b5a
fix(app): skip dotCentre measurement for running rows
jeonghun-jj-lee Aug 29, 2026
4efa761
fix(app): narrow measurement skip to tool rows only
jeonghun-jj-lee Aug 29, 2026
b92f32d
fix(ui): display:block on harmonic dot SVG — kills baseline gap
jeonghun-jj-lee Aug 29, 2026
0be80d5
fix(app): display:block on harmonic dot via CSS class
jeonghun-jj-lee Aug 29, 2026
5645f9d
fix(app): explicit width/height on harmonic dot wrapper span
jeonghun-jj-lee Aug 29, 2026
b55ee97
fix(app): fix SolidJS style reactivity — no spread in style object
jeonghun-jj-lee Aug 29, 2026
3a54cc3
fix(app): single tool parts are not prose — check part.type
jeonghun-jj-lee Aug 29, 2026
9c265f8
fix(app): use TooltipV2 on harmonic dot, render below
jeonghun-jj-lee Aug 29, 2026
de7364f
feat(ui): deterministic 12-pulse harmonic sequence, ascending (l,m)
jeonghun-jj-lee Aug 29, 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
21 changes: 1 addition & 20 deletions packages/app/src/design-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -209,23 +209,7 @@
[data-part-enter] {
animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards;
}
/* Prose-fragment cards get a snappier, blur-free entrance (#265): 150ms
ease-out, 10px rise, no blur. Faster than the general timeline-enter because
these settle during active streaming — the eye is already watching the bottom
edge, so the entrance can be crisp without startling. */
[data-prose-fragment][data-part-enter] {
animation: prose-fragment-enter 150ms ease-out backwards;
}
@keyframes prose-fragment-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

/* The open cascade holds at frame 0 (opacity 0, risen, blurred) until the
timeline has settled at the bottom — released by removing
[data-entrance-pending] (message-timeline.tsx entranceReady). Also keeps
Expand Down Expand Up @@ -279,9 +263,6 @@
--motion-enter-rise: 0px;
--motion-enter-blur: 0px;
}
[data-prose-fragment][data-part-enter] {
animation: none;
}
}

/* ── inline code: readable in both schemes ── */
Expand Down
36 changes: 3 additions & 33 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,44 +378,14 @@

/* The thought rail's live node: a spherical-harmonic morphing dot that pulses
through Y_l^m silhouettes via SMIL path animation (sphere → shape → sphere).
The grow animation fires on mount (7→13px). Honours reduced motion. */
display:block kills the inline SVG baseline gap (~2-3px descender space)
that otherwise pushes the dot above its intended centre. */
.thought-rail-dot--harmonic {
animation: thought-rail-grow 150ms ease-out both;
}
/* Travelling dot (#265): after the first measurement settles, the dot
transitions smoothly to track the latest prose-fragment card. The settled
class gates the transition so the initial mount uses the grow animation
alone — no slide from top:0 to the first measured position. */
.thought-rail-dot--settled {
transition: top 150ms ease-out;
}
/* The rail line extends in sync with the dot — same timing so the spine
grows as the dot descends. */
[data-slot="thought-rail-line"] {
transition: height 150ms ease-out;
}
@keyframes thought-rail-grow {
from {
transform: scale(0.538); /* 7/13 — starts at done-dot size */
opacity: 0.7;
}
to {
transform: scale(1);
opacity: 1;
}
display: block;
}
@media (prefers-reduced-motion: reduce) {
.thought-rail-dot--harmonic {
animation: none;
}
.thought-rail-dot--harmonic .harmonic-dot-shape animate {
/* SMIL respects this; browsers also pause SMIL under reduced-motion */
display: none;
}
.thought-rail-dot--settled {
transition: none;
}
[data-slot="thought-rail-line"] {
transition: none;
}
}
127 changes: 92 additions & 35 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail"
import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail } from "./thought-rail"
import { DEFAULT_DOT_CENTRE, ThoughtRail, ThoughtRailLabel, THOUGHT_RAIL_INSET, shouldRenderRail, dotCentreForGroup } from "./thought-rail"
import { formatElapsed, formatTokens, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import {
AmicodeEntityView,
Expand Down Expand Up @@ -98,6 +98,7 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { smoothScrollInterpolate, SMOOTH_SCROLL_DURATION } from "./smooth-scroll"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
Expand Down Expand Up @@ -702,6 +703,64 @@ export function MessageTimeline(props: {
)
},
})

// ── Smooth scroll (#631) ───────────────────────────────────────────────
// Custom RAF loop for bottom-follow scrolls (180ms ease-out cubic). Instant
// scrollToEnd is used for mount, reveal, and prepend-anchor; smooth only for
// the "new content arrived while anchored" path. Cancels on user gesture.
let smoothFrame: number | undefined
let smoothStartY = 0
let smoothStartTime = 0
const prefersReducedMotion = typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: undefined

const cancelSmoothScroll = () => {
if (smoothFrame !== undefined) {
cancelAnimationFrame(smoothFrame)
smoothFrame = undefined
}
}

const smoothScrollToEnd = () => {
const el = listRoot()
if (!el) { virtualizer.scrollToEnd(); return }
// Reduced motion: instant
if (prefersReducedMotion?.matches) { virtualizer.scrollToEnd(); return }
// Compute target (max scroll position = scrollHeight - clientHeight)
const target = el.scrollHeight - el.clientHeight
const current = el.scrollTop
if (Math.abs(target - current) < 2) return // already there
// If an animation is in flight, restart from current position
cancelSmoothScroll()
smoothStartY = current
smoothStartTime = performance.now()
const tick = (now: number) => {
const elapsed = now - smoothStartTime
const y = smoothScrollInterpolate(smoothStartY, target, elapsed, SMOOTH_SCROLL_DURATION)
el.scrollTop = y
if (elapsed < SMOOTH_SCROLL_DURATION) smoothFrame = requestAnimationFrame(tick)
else smoothFrame = undefined
}
smoothFrame = requestAnimationFrame(tick)
}

// Cancel smooth scroll on user gesture (wheel, touch, pointer)
onMount(() => {
const el = listRoot()
if (!el) return
const cancel = () => cancelSmoothScroll()
el.addEventListener("wheel", cancel, { passive: true })
el.addEventListener("touchstart", cancel, { passive: true })
el.addEventListener("pointerdown", cancel)
onCleanup(() => {
el.removeEventListener("wheel", cancel)
el.removeEventListener("touchstart", cancel)
el.removeEventListener("pointerdown", cancel)
cancelSmoothScroll()
})
})

const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
Expand All @@ -710,7 +769,7 @@ export function MessageTimeline(props: {
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
virtualizer.scrollToEnd()
smoothScrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
Expand Down Expand Up @@ -819,7 +878,7 @@ export function MessageTimeline(props: {
if (index === undefined) return
virtualizer.scrollToIndex(index, { align: "center" })
})
props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
props.setScrollToEnd?.(() => smoothScrollToEnd())
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})

Expand All @@ -843,7 +902,7 @@ export function MessageTimeline(props: {
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
virtualizer.scrollToEnd()
smoothScrollToEnd()
}

let measuredSessionKey = sessionKey()
Expand Down Expand Up @@ -1479,38 +1538,38 @@ export function MessageTimeline(props: {
if (row._tag === "Thinking") {
const hasOutput = hasAssistantParts(row.userMessageID)
// Dot stays on Thinking only while no output exists
return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput }
return { first: true, last: !hasOutput, running: row.turnRunning && !hasOutput, prose: false }
}
if (row._tag !== "AssistantPart") return undefined
if (!shouldRenderRail(row)) return undefined
// Last AssistantPart gets the dot when the turn is still running
return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart }
// Last AssistantPart gets the dot when the turn is still running.
// prose = text content that grows → dot bottom-anchored.
// !prose = tool/status row → dot at dotCentre.
// A "part" group can be either text or a single tool — check the actual part.
let isProse = false
if (row.group.type === "part") {
const part = getMsgPart(row.group.ref.messageID, row.group.ref.partID)
isProse = part?.type === "text"
}
return { first: false, last: row.lastAssistantPart, running: row.turnRunning && row.lastAssistantPart, prose: isProse }
}

// The dot sits on the row's FIRST TEXT LINE, wherever the content puts it
// (Kate 2026-08-24: dots must line up with the text they coincide with).
// Prose and rail-label rows put it at the default 11px; rows that open
// with a card (a tool chip, a group header, a widget preview) start their
// first line lower by that card's own padding — measured, not tabulated,
// because the set of card species is open-ended. The ResizeObserver
// re-measures when async card content mounts (deferToolContent) or
// streaming reflows the row; observers exist only on rendered rows, so
// the count is bounded by the virtualizer's window.
// The dot aligns with the vertical centre of the row's first text line.
// Initialized from dotCentreForGroup (deterministic per row type) so the
// dot starts in the right place without waiting for DOM measurement.
// ResizeObserver refines the value once the content has rendered.
let turnEl: HTMLDivElement | undefined
const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE)
const [dotSettled, setDotSettled] = createSignal(false)
const initialDotCentre = () => {
const row = input.row()
if (row._tag === "Thinking") return dotCentreForGroup("thinking")
if (row._tag === "AssistantPart") return dotCentreForGroup(row.group.type)
return DEFAULT_DOT_CENTRE
}
const [dotCentre, setDotCentre] = createSignal(initialDotCentre())
const measureDotCentre = () => {
if (!turnEl || !rail()) return
const hostTop = turnEl.getBoundingClientRect().top
// Travelling dot (#265): ONLY the running dot tracks the last
// prose-fragment card. The done-dot stays at the first text line
// (top of the row) so the rail reads as a sequence of origin marks.
const r = rail()
const isRunning = r && r.last && r.running
const fragments = isRunning ? turnEl.querySelectorAll("[data-prose-fragment]") : undefined
const lastFragment = fragments && fragments.length > 0 ? (fragments[fragments.length - 1] as HTMLElement) : null
const target = lastFragment ?? turnEl
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT)
const walker = document.createTreeWalker(turnEl, NodeFilter.SHOW_TEXT)
let node: Node | null
while ((node = walker.nextNode())) {
if (!node.textContent?.trim()) continue
Expand All @@ -1519,13 +1578,10 @@ export function MessageTimeline(props: {
const rect = range.getClientRects()[0]
if (!rect || rect.height === 0) continue
const centre = rect.top + rect.height / 2 - hostTop
// When targeting a fragment, the dot can be anywhere down the row
// (no ceiling). For non-fragment rows the 80px ceiling guards against
// mid-virtualisation nonsense measurements.
const maxCentre = lastFragment ? Infinity : 80
if (centre > 0 && centre < maxCentre) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
if (!dotSettled()) setDotSettled(true)
return
if (centre > 0 && centre < 80) {
setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
return
}
}
}
onMount(() => {
Expand All @@ -1546,6 +1602,7 @@ export function MessageTimeline(props: {
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:mx-auto": props.centered,
"pt-3": previousAssistantPart(),
"md:pl-3": assistantPart(),
}}
>
<div
Expand All @@ -1560,8 +1617,8 @@ export function MessageTimeline(props: {
first={r().first}
last={r().last}
running={r().running}
prose={r().prose}
dotCentre={dotCentre()}
settled={dotSettled()}
turnStartedAt={"turnStartedAt" in input.row() ? (input.row() as any).turnStartedAt : undefined}
tokens={r().running && r().last ? assistantTokensForTurn(input.row().userMessageID) || undefined : undefined}
/>
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/pages/session/timeline/model-pure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import type { Accessor } from "solid-js"

export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}

export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
}

export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}

export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
before?: () => void
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return

input.before?.()
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}
2 changes: 1 addition & 1 deletion packages/app/src/pages/session/timeline/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"

const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
Expand Down
35 changes: 2 additions & 33 deletions packages/app/src/pages/session/timeline/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createMemo, createResource, onCleanup, untrack, type Accessor } from "s
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"
export { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model-pure"

const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
Expand Down Expand Up @@ -93,36 +95,3 @@ export function createTimelineModel(input: {
refreshTimer = undefined
}
}

export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}

export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
}

export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}

export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
before?: () => void
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return

input.before?.()
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
input.after?.(true)
}
Loading
Loading