Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8d374f3
fix(session-ui): cohesive segments — label heuristic + blank-card guard
jeonghun-jj-lee Aug 28, 2026
023c2b6
fix(app): persistent dot overlay — turn-spanning, step-boundary trave…
jeonghun-jj-lee Aug 28, 2026
667c27c
fix(app): smooth scroll — custom 180ms RAF ease-out replacing instant…
jeonghun-jj-lee Aug 28, 2026
2b15f52
fix(app): rail continuity + inset + crossfade
jeonghun-jj-lee Aug 28, 2026
19072d4
fix(app): fix overlay dot rendering — use reactive virtualItemByKey, …
jeonghun-jj-lee Aug 28, 2026
3e8eb03
fix(app): align overlay with row centering — mirror md:max-w/mx-auto/…
jeonghun-jj-lee Aug 28, 2026
5f290dd
fix(app): use DOM measurement for overlay dot position
jeonghun-jj-lee Aug 28, 2026
0f6cd29
fix(app): use rowEl.style.top + turnEl.offsetTop for overlay positioning
jeonghun-jj-lee Aug 28, 2026
f5bffdf
fix(app): restore text-part breathing room + revert overlay to per-ro…
jeonghun-jj-lee Aug 29, 2026
d733d2a
fix(app): move ThinkingMeta above interruption divider
jeonghun-jj-lee Aug 29, 2026
39734f0
fix(app): unify prose-fragment entrance to 300ms + blur
jeonghun-jj-lee Aug 29, 2026
6f1962f
fix(session-ui): heading-anchored prose segmentation
jeonghun-jj-lee Aug 29, 2026
f9ff70e
fix(app): bottom-anchor the harmonic dot — content grows above it
jeonghun-jj-lee Aug 29, 2026
5a4df26
fix(app): dot centres on Thinking/status rows, bottom-anchors on content
jeonghun-jj-lee Aug 29, 2026
ba20ecd
fix(app): dot centres on tool/shell rows, bottom-anchors only on prose
jeonghun-jj-lee Aug 29, 2026
1bf3b07
fix(app): align prose dot with last text line centre (21px inset)
jeonghun-jj-lee Aug 29, 2026
3d2b588
fix(app): detect tool rows by group.type, not railLabel
jeonghun-jj-lee Aug 29, 2026
63ccc6e
fix(app): initialize dotCentre from group type, not hardcoded 11px
jeonghun-jj-lee Aug 29, 2026
6b344f4
fix(app): skip dotCentre measurement for running rows
jeonghun-jj-lee Aug 29, 2026
7c82362
fix(app): narrow measurement skip to tool rows only
jeonghun-jj-lee Aug 29, 2026
e22488c
fix(ui): display:block on harmonic dot SVG — kills baseline gap
jeonghun-jj-lee Aug 29, 2026
5aaedcc
fix(app): display:block on harmonic dot via CSS class
jeonghun-jj-lee Aug 29, 2026
908c4a7
fix(app): explicit width/height on harmonic dot wrapper span
jeonghun-jj-lee Aug 29, 2026
8af303a
fix(app): fix SolidJS style reactivity — no spread in style object
jeonghun-jj-lee Aug 29, 2026
309a8b3
fix(app): single tool parts are not prose — check part.type
jeonghun-jj-lee Aug 29, 2026
141db85
fix(app): use TooltipV2 on harmonic dot, render below
jeonghun-jj-lee Aug 29, 2026
d150595
feat(ui): deterministic 12-pulse harmonic sequence, ascending (l,m)
jeonghun-jj-lee Aug 29, 2026
b663d9b
fix(ui): drop fake trefoil and redundant (4,1) pill from harmonic seq…
jeonghun-jj-lee Aug 30, 2026
19e47c5
refactor(ui): 10 visually distinct genuine spherical harmonics
jeonghun-jj-lee Aug 30, 2026
4f7f0f5
fix(ui): drop peanut and hedgehog — 8 distinct shapes, 9.6s cycle
jeonghun-jj-lee Aug 30, 2026
7df4a72
fix(ui): reorder — lobes first, then pinches, then stars
jeonghun-jj-lee Aug 30, 2026
2c86d08
fix(ui): reorder — pill, pinched, double-pinch, then stars
jeonghun-jj-lee Aug 30, 2026
9fd9dc6
fix(session-ui): paragraph-gap fallback for long headingless sections
jeonghun-jj-lee Aug 30, 2026
a0b1b03
fix(app): hide empty prose fragments and text-part wrappers
jeonghun-jj-lee Aug 30, 2026
bbe6946
fix(app): hide empty assistant-content wrapper via :not(:has(*))
jeonghun-jj-lee Aug 30, 2026
91f3dcd
fix(session-ui): skill tool — no children when body is empty
jeonghun-jj-lee Aug 30, 2026
39dfd9b
fix(session-ui): use character count (1500) for paragraph-gap fallback
jeonghun-jj-lee Aug 31, 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