Skip to content
Draft
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
47 changes: 47 additions & 0 deletions packages/app-bundle/overlay/packages/app/src/design-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,50 @@ span[data-component="tag"][data-variant="accent"] {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}

/* ============================================================
THOUGHT RAIL — persistent dot overlay (#628)
============================================================ */

/* The continuous rail line running alongside the active turn */
.thought-rail-line {
background: color-mix(in srgb, var(--v2-icon-icon-accent) 35%, transparent);
border-radius: var(--radius-full);
transition: height 250ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* The sticky dot container — spring animation on position change */
.thought-rail-dot {
transition: top 250ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* The wave inside the dot — accent color, sized for the rail */
.thought-rail-wave {
width: 14px;
height: 14px;
}

/* Crossfade on turn completion */
.thought-rail-overlay {
opacity: 1;
transition: opacity 150ms ease-out;
}
.thought-rail-overlay--settled {
opacity: 0;
}

/* Done-dot: appears per-row after turn completion */
.thought-rail-done-dot {
background: color-mix(in srgb, var(--v2-icon-icon-accent) 55%, transparent);
transition: opacity 150ms ease-out;
}

/* Reduced motion: no spring, no crossfade, instant transitions */
@media (prefers-reduced-motion: reduce) {
.thought-rail-line,
.thought-rail-dot,
.thought-rail-overlay,
.thought-rail-done-dot {
transition: none !important;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { dotCentreForGroup, type TimelineGroupType } from "./dot-offsets"

describe("dotCentreForGroup", () => {
test("prose group returns 21px", () => {
expect(dotCentreForGroup("prose")).toBe(21)
})

test("tool-group returns 11px", () => {
expect(dotCentreForGroup("tool-group")).toBe(11)
})

test("single-tool returns 16px", () => {
expect(dotCentreForGroup("single-tool")).toBe(16)
})

test("thinking returns 11px", () => {
expect(dotCentreForGroup("thinking")).toBe(11)
})

test("unknown type falls back to prose offset", () => {
expect(dotCentreForGroup("unknown" as TimelineGroupType)).toBe(21)
})

test("all offsets are positive numbers", () => {
const types: TimelineGroupType[] = ["prose", "tool-group", "single-tool", "thinking"]
for (const t of types) {
const offset = dotCentreForGroup(t)
expect(offset).toBeGreaterThan(0)
expect(typeof offset).toBe("number")
}
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Deterministic dot-centre offsets by timeline group type.
*
* Replaces the TreeWalker-based measurement that suffered from race conditions
* when virtualizer rows mount/unmount during streaming. Each offset is the
* vertical centre (in px from the row's top edge) where the rail dot should
* sit for that row type.
*
* If CSS padding changes, update the constants here — one file, one source.
*/

export type TimelineGroupType = "prose" | "tool-group" | "single-tool" | "thinking"

const OFFSETS: Record<TimelineGroupType, number> = {
/** Text content: accounts for card padding-top (12px) + half line-height (~18px) */
prose: 21,
/** Collapsed tool group accordion: the header's vertical centre */
"tool-group": 11,
/** A single tool card (not in a group): header centre */
"single-tool": 16,
/** Thinking row: wave indicator's vertical centre */
thinking: 11,
}

const DEFAULT_OFFSET = OFFSETS.prose

/**
* Returns the vertical centre offset (px from row top) for the rail dot
* given the type of timeline row group.
*/
export function dotCentreForGroup(type: TimelineGroupType): number {
return OFFSETS[type] ?? DEFAULT_OFFSET
}
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 { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import { AmicoWave, ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import {
AmicodeEntityView,
entityLabel,
Expand Down Expand Up @@ -95,6 +95,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createSmoothScroller } from "./smooth-scroll"

const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
Expand Down Expand Up @@ -565,14 +566,22 @@ export function MessageTimeline(props: {
},
})
const resizeItem = virtualizer.resizeItem
// Smooth scroller: replaces instant scrollToEnd when content grows while anchored.
// User gestures cancel the animation; reduced-motion falls back to instant.
const reducedMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
const smoothScroller = createSmoothScroller({
getElement: () => listRoot(),
duration: 180,
reducedMotion,
})
let resizeAnchorScheduled = false
const anchorResizedBottom = () => {
if (resizeAnchorScheduled || props.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
virtualizer.scrollToEnd()
smoothScroller.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
Expand Down Expand Up @@ -813,6 +822,8 @@ export function MessageTimeline(props: {
// amicode#271: update scroll offset for the last-prompt bubble
setScrollTop(event.currentTarget.scrollTop)
if (!props.hasScrollGesture()) return
// Cancel smooth scroll on user gesture (#628)
if (smoothScroller.isAnimating()) smoothScroller.cancel()
// User-initiated scroll — clear any click override so bubble tracks position
// (but not if we're mid-programmatic scroll from a bubble click)
if (!bubbleScrolling) setBubbleOverride(undefined)
Expand Down Expand Up @@ -1318,6 +1329,12 @@ export function MessageTimeline(props: {
const row = input.row()
return row._tag === "AssistantPart" && row.previousAssistantPart
}
// Rail visibility: show on Thinking and AssistantPart rows
const showsRail = () => {
const tag = input.row()._tag
return tag === "Thinking" || tag === "AssistantPart"
}
const isWorking = () => workingTurn(input.row().userMessageID)

return (
<div
Expand All @@ -1332,6 +1349,64 @@ export function MessageTimeline(props: {
}}
>
<div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
{/* Rail dot + line — renders inside the content's left padding area */}
<Show when={showsRail()}>
<div
class="thought-rail-segment hidden md:block"
classList={{ "is-working": isWorking() }}
style={{
position: "absolute",
top: "0",
left: "12px",
width: "20px",
height: "100%",
"pointer-events": "none",
"z-index": "5",
}}
>
{/* Vertical rail line */}
<div
class="thought-rail-line"
style={{
position: "absolute",
top: "0",
left: "5px",
width: "2px",
height: "100%",
}}
/>
{/* The dot — AmicoWave when working, static dot when done */}
<div
class="thought-rail-dot-anchor"
style={{
position: "absolute",
top: input.row()._tag === "Thinking" ? "8px" : "18px",
left: "0",
width: "12px",
height: "12px",
display: "flex",
"align-items": "center",
"justify-content": "center",
}}
>
<Show
when={isWorking()}
fallback={
<div
class="thought-rail-done-dot"
style={{
width: "6px",
height: "6px",
"border-radius": "50%",
}}
/>
}
>
<AmicoWave class="thought-rail-wave" />
</Show>
</div>
</div>
</Show>
{input.children}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"
import { createSmoothScroller, type SmoothScrollerOptions } from "./smooth-scroll"

// Mock RAF for deterministic testing
let rafCallbacks: ((time: number) => void)[] = []
let rafId = 0
const mockRaf = (cb: (time: number) => void) => {
rafCallbacks.push(cb)
return ++rafId
}
const mockCancelRaf = (_id: number) => {
// In tests we just clear all — sufficient for our use
}

function flushRaf(time: number) {
const cbs = [...rafCallbacks]
rafCallbacks = []
for (const cb of cbs) cb(time)
}

describe("createSmoothScroller", () => {
let element: { scrollTop: number; scrollHeight: number; clientHeight: number }

beforeEach(() => {
rafCallbacks = []
rafId = 0
element = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 }
})

test("scrolls to bottom in ~180ms using ease-out", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
})

element.scrollTop = 500
scroller.scrollToEnd()

// First frame establishes startTime (no movement yet)
flushRaf(0)
expect(element.scrollTop).toBe(500)

// Mid-animation — should have moved
flushRaf(90)
expect(element.scrollTop).toBeGreaterThan(500)
expect(element.scrollTop).toBeLessThan(800)

// After full duration — should be at the end
flushRaf(180)
expect(element.scrollTop).toBe(800) // scrollHeight - clientHeight
})

test("cancel() stops the animation", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
})

element.scrollTop = 0
scroller.scrollToEnd()
flushRaf(0) // start
const posAfterStart = element.scrollTop

scroller.cancel()
flushRaf(90) // should do nothing — cancelled

expect(element.scrollTop).toBe(posAfterStart)
})

test("isAnimating() returns true while scrolling", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
})

expect(scroller.isAnimating()).toBe(false)
scroller.scrollToEnd()
expect(scroller.isAnimating()).toBe(true)

// First frame — still animating
flushRaf(0)
expect(scroller.isAnimating()).toBe(true)

// Complete
flushRaf(180)
expect(scroller.isAnimating()).toBe(false)
})

test("calling scrollToEnd during animation updates target without restarting", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
})

element.scrollTop = 0
scroller.scrollToEnd()
flushRaf(0)

// Content grows — target changes
element.scrollHeight = 1200
scroller.scrollToEnd()

flushRaf(180) // complete
expect(element.scrollTop).toBe(1000) // new scrollHeight - clientHeight
})

test("does nothing if already at bottom", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
})

element.scrollTop = 800 // already at bottom
scroller.scrollToEnd()
expect(scroller.isAnimating()).toBe(false)
})

test("instant mode sets scrollTop directly (for reduced-motion)", () => {
const scroller = createSmoothScroller({
getElement: () => element as any,
duration: 180,
requestAnimationFrame: mockRaf as any,
cancelAnimationFrame: mockCancelRaf,
reducedMotion: true,
})

element.scrollTop = 0
scroller.scrollToEnd()
expect(element.scrollTop).toBe(800) // instant
expect(scroller.isAnimating()).toBe(false)
})
})
Loading
Loading