Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/app/e2e/smoke/session-timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ test.describe("smoke: session timeline", () => {

const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
await expect(spacer).toBeVisible()
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(24)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/design-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,23 @@
[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 @@ -262,6 +279,9 @@
--motion-enter-rise: 0px;
--motion-enter-blur: 0px;
}
[data-prose-fragment][data-part-enter] {
animation: none;
}
}

/* ── inline code: readable in both schemes ── */
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,18 @@
.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 */
Expand All @@ -400,4 +412,10 @@
/* 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;
}
}
104 changes: 78 additions & 26 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualIt
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 { formatElapsed, formatTokens, ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import { formatElapsed, formatTokens, turnTokens } from "@opencode-ai/ui/amicode-thinking"
import {
AmicodeEntityView,
entityLabel,
Expand All @@ -42,6 +42,7 @@ import {
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import { readPartText, settledChunkBoundary } from "@opencode-ai/session-ui/message-part-text"
import { buildTrace } from "@opencode-ai/session-ui/build-trace"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
Expand Down Expand Up @@ -89,6 +90,7 @@ import { useTabs } from "@/context/tabs"
import { amicodeGet, amicodePost } from "@/utils/amicode-fetch"
import { draftPrompt } from "@/utils/start-prompt"
import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands"
import { writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
Expand Down Expand Up @@ -161,23 +163,43 @@ function TimelineThinkingRow(_props: { reasoningHeading?: string; showReasoningS
)
}

function TimelineThinkingMetaRow(props: { turnRunning: boolean; turnDurationMs?: number; tokens?: number }) {
function TimelineThinkingMetaRow(props: { turnDurationMs?: number; tokens?: number; onCopy?: () => void }) {
const language = useLanguage()
const [copied, setCopied] = createSignal(false)

const handleCopy = () => {
props.onCopy?.()
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

return (
<div data-slot="session-turn-thinking-meta">
<Show when={props.turnRunning} fallback={
<Show when={props.turnDurationMs != null}>
<span class="amc-thinking" data-slot="amc-thinking">
<span class="amc-thinking-meta" aria-hidden="true">
<span class="amc-thinking-elapsed">{formatElapsed(props.turnDurationMs!)}</span>
<Show when={props.tokens != null}>
<span class="amc-thinking-sep">·</span>
<span class="amc-thinking-tokens">↑ {formatTokens(props.tokens!)} tokens</span>
</Show>
</span>
<TooltipV2
value={copied() ? language.t("ui.message.copied") : language.t("ui.message.copyTrace")}
placement="bottom"
gutter={4}
>
<IconButtonV2
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? language.t("ui.message.copied") : language.t("ui.message.copyTrace")}
/>
</TooltipV2>
<Show when={props.turnDurationMs != null}>
<span class="amc-thinking" data-slot="amc-thinking">
<span class="amc-thinking-meta" aria-hidden="true">
<span class="amc-thinking-sep">·</span>
<span class="amc-thinking-elapsed">{formatElapsed(props.turnDurationMs!)}</span>
<Show when={props.tokens != null}>
<span class="amc-thinking-sep">·</span>
<span class="amc-thinking-tokens">↑ {formatTokens(props.tokens!)} tokens</span>
</Show>
</span>
</Show>
}>
<ThinkingLine tokens={props.tokens} />
</span>
</Show>
</div>
)
Expand Down Expand Up @@ -453,6 +475,17 @@ export function MessageTimeline(props: {
if (start === -1) return 0
return turnTokens(msgs.slice(start + 1).filter((m) => m.role === "assistant"))
}

// Copy the full assistant trace for a turn to the clipboard.
const copyTraceForTurn = (userMessageID: string) => {
const msgs = sessionMessages()
const start = msgs.findIndex((m) => m.id === userMessageID)
if (start === -1) return
const assistantMsgs = msgs.slice(start + 1).filter((m): m is AssistantMessage => m.role === "assistant")
const content = buildTrace(assistantMsgs, getMsgParts)
if (!content) return
if (!writeClipboardViaBridge(content)) void navigator.clipboard?.writeText(content)
}
/** True when at least one AssistantPart ROW exists in the projected timeline
* for this turn — meaning renderable, settled output is visible. Reasoning
* parts withheld while streaming do NOT count (they produce no row until
Expand Down Expand Up @@ -658,7 +691,7 @@ export function MessageTimeline(props: {
return showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
paddingEnd: 24,
rangeExtractor: (range) => {
const id = activeMessageID()
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
Expand Down Expand Up @@ -1418,7 +1451,7 @@ export function MessageTimeline(props: {
}
const previousAssistantPart = () => {
const row = input.row()
if (row._tag === "ThinkingMeta") return true
if (row._tag === "ThinkingMeta") return false
if (row._tag !== "AssistantPart") return false
// Gap above if there's a previous assistant part, OR if Thinking row
// sits above (always true since Thinking is always first)
Expand Down Expand Up @@ -1465,10 +1498,19 @@ export function MessageTimeline(props: {
// the count is bounded by the virtualizer's window.
let turnEl: HTMLDivElement | undefined
const [dotCentre, setDotCentre] = createSignal(DEFAULT_DOT_CENTRE)
const [dotSettled, setDotSettled] = createSignal(false)
const measureDotCentre = () => {
if (!turnEl || !rail()) return
const hostTop = turnEl.getBoundingClientRect().top
const walker = document.createTreeWalker(turnEl, NodeFilter.SHOW_TEXT)
// 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)
let node: Node | null
while ((node = walker.nextNode())) {
if (!node.textContent?.trim()) continue
Expand All @@ -1477,10 +1519,12 @@ export function MessageTimeline(props: {
const rect = range.getClientRects()[0]
if (!rect || rect.height === 0) continue
const centre = rect.top + rect.height / 2 - hostTop
// Half-px grid; never above the default (a dot poking into the
// inter-row gap would detach from its own tail cap), and a sanity
// ceiling against mid-virtualisation nonsense measurements.
if (centre > 0 && centre < 80) setDotCentre(Math.max(DEFAULT_DOT_CENTRE, Math.round(centre * 2) / 2))
// 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
}
}
Expand Down Expand Up @@ -1512,7 +1556,15 @@ export function MessageTimeline(props: {
>
<Show when={rail()}>
{(r) => (
<ThoughtRail first={r().first} last={r().last} running={r().running} dotCentre={dotCentre()} />
<ThoughtRail
first={r().first}
last={r().last}
running={r().running}
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}
/>
)}
</Show>
{/* The gutter is reserved for EVERY assistant part, not only the ones
Expand Down Expand Up @@ -1662,9 +1714,9 @@ export function MessageTimeline(props: {
class="w-full px-4 md:px-5 relative"
>
<TimelineThinkingMetaRow
turnRunning={metaRow().turnRunning}
turnDurationMs={metaRow().turnDurationMs}
tokens={assistantTokensForTurn(metaRow().userMessageID) || undefined}
onCopy={() => copyTraceForTurn(metaRow().userMessageID)}
/>
</div>
</TimelineRowFrame>
Expand Down Expand Up @@ -2502,8 +2554,8 @@ export function MessageTimeline(props: {
<div
data-timeline-row="bottom-spacer"
aria-hidden="true"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
class="h-6 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 24}px)` }}
/>
</Show>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/pages/session/timeline/projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
previousAssistantPart: false,
lastAssistantPart: false,
turnRunning: false,
turnStartedAt: 0,
})

const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
Expand Down
48 changes: 45 additions & 3 deletions packages/app/src/pages/session/timeline/rows-current.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ describe("current session timeline rows", () => {
"turn-gap:msg_3",
"user-message:msg_3",
"thinking:msg_3",
"thinking-meta:msg_3",
])
})

Expand Down Expand Up @@ -177,7 +176,6 @@ describe("current session timeline rows", () => {
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
"thinking-meta:msg_2",
])
})

Expand Down Expand Up @@ -218,7 +216,7 @@ describe("current session timeline rows", () => {
// The stale error row must not appear once the turn resumes. The resumed
// text is the streaming tail (no time.end) so it is withheld until it
// completes — Thinking, not the half-streamed part, is what renders.
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking", "ThinkingMeta"])
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking"])
})

test("harmonic dot travels: on Thinking when no output, on last AssistantPart once output lands", () => {
Expand Down Expand Up @@ -309,4 +307,48 @@ describe("current session timeline rows", () => {
expect((p as any).turnRunning).toBe(false)
}
})

test("turnStartedAt is threaded through Thinking and AssistantPart rows from user message time.created", () => {
const source = [
{ id: "msg_u", type: "user", text: "go", time: { created: 1000 } },
{
id: "msg_a",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "output" }],
time: { created: 1050, completed: 1200 },
},
{
id: "msg_b",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "more" }],
time: { created: 1300 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((m) => [m.id, m]))

const result = Timeline.constructSessionMessageRows(
source,
(id) => messages.get(id),
(id) => normalized.parts.get(id) ?? [],
true,
"busy",
true,
normalized.messages.filter((m) => m.role === "user"),
)

// Thinking row carries the user message's time.created as turnStartedAt
const thinking = result.rows.find((r) => r._tag === "Thinking")!
expect((thinking as any).turnStartedAt).toBe(1000)

// AssistantPart rows carry it too (for dot tooltip on last part)
const assistantParts = result.rows.filter((r) => r._tag === "AssistantPart")
for (const part of assistantParts) {
expect((part as any).turnStartedAt).toBe(1000)
}
})
})
13 changes: 8 additions & 5 deletions packages/app/src/pages/session/timeline/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export namespace Timeline {
userMessageID: userMessage.id,
reasoningHeading: heading,
turnRunning: turnIsRunning,
turnStartedAt: userMessage.time.created,
}),
)
}
Expand All @@ -256,20 +257,22 @@ export namespace Timeline {
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: itemIndex === lastRenderableIndex,
turnRunning: turnIsRunning,
turnStartedAt: userMessage.time.created,
railLabel: railLabel(item.group),
}),
)
assistantGroupIndex += 1
})

// ThinkingMeta row renders LAST — timer + tokens always visible at the
// bottom of the turn. This is where the harmonic dot lives while running.
if (assistantPartRefs.length > 0 || turnIsRunning) {
// ThinkingMeta row renders LAST — duration + tokens as a historical record.
// Hidden while streaming (the harmonic dot signals "working"); appears only
// after the turn completes.
if (assistantPartRefs.length > 0 && !turnIsRunning) {
rows.push(
new TimelineRow.ThinkingMeta({
userMessageID: userMessage.id,
turnRunning: turnIsRunning,
turnDurationMs: turnIsRunning ? undefined : computeTurnDuration(userMessage, assistantMessages),
turnRunning: false,
turnDurationMs: computeTurnDuration(userMessage, assistantMessages),
}),
)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/pages/session/timeline/thought-rail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const turn = (n: number, running: boolean) =>
)

describe("thought rail", () => {
test("a finished single-step turn draws no rail — one dot is decoration, not a sequence", () => {
expect(turn(1, false)[0].render).toBe(false)
test("a finished single-step turn still renders a rail — Thinking row above provides the sequence", () => {
expect(turn(1, false)[0].render).toBe(true)
})

test("a RUNNING turn rails from its very first step — the live dot is the only working mark", () => {
Expand Down
Loading
Loading