diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts
index e2d4bdc15a..13fc43cd51 100644
--- a/packages/app/e2e/smoke/session-timeline.spec.ts
+++ b/packages/app/e2e/smoke/session-timeline.spec.ts
@@ -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)
diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css
index a9487f9747..08baf080be 100644
--- a/packages/app/src/design-polish.css
+++ b/packages/app/src/design-polish.css
@@ -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
@@ -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 ── */
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 987856117b..4b23fe85c9 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -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 */
@@ -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;
+ }
}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 070543e07a..4c7b8c0952 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -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,
@@ -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"
@@ -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"
@@ -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 (
-
-
-
- {formatElapsed(props.turnDurationMs!)}
-
- ·
- ↑ {formatTokens(props.tokens!)} tokens
-
-
+
+ }
+ size="normal"
+ variant="ghost-muted"
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={handleCopy}
+ aria-label={copied() ? language.t("ui.message.copied") : language.t("ui.message.copyTrace")}
+ />
+
+
+
+
+ ·
+ {formatElapsed(props.turnDurationMs!)}
+
+ ·
+ ↑ {formatTokens(props.tokens!)} tokens
+
-
- }>
-
+
)
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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
}
}
@@ -1512,7 +1556,15 @@ export function MessageTimeline(props: {
>
{(r) => (
-
+
)}
{/* The gutter is reserved for EVERY assistant part, not only the ones
@@ -1662,9 +1714,9 @@ export function MessageTimeline(props: {
class="w-full px-4 md:px-5 relative"
>
copyTraceForTurn(metaRow().userMessageID)}
/>
@@ -2502,8 +2554,8 @@ export function MessageTimeline(props: {
diff --git a/packages/app/src/pages/session/timeline/projection.test.ts b/packages/app/src/pages/session/timeline/projection.test.ts
index 76563595db..a2dc9809ad 100644
--- a/packages/app/src/pages/session/timeline/projection.test.ts
+++ b/packages/app/src/pages/session/timeline/projection.test.ts
@@ -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 })
diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts
index 4376847d13..942ed88199 100644
--- a/packages/app/src/pages/session/timeline/rows-current.test.ts
+++ b/packages/app/src/pages/session/timeline/rows-current.test.ts
@@ -61,7 +61,6 @@ describe("current session timeline rows", () => {
"turn-gap:msg_3",
"user-message:msg_3",
"thinking:msg_3",
- "thinking-meta:msg_3",
])
})
@@ -177,7 +176,6 @@ describe("current session timeline rows", () => {
"turn-gap:msg_2",
"user-message:msg_2",
"thinking:msg_2",
- "thinking-meta:msg_2",
])
})
@@ -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", () => {
@@ -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)
+ }
+ })
})
diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts
index 1a50393cb7..0dc5f86f3c 100644
--- a/packages/app/src/pages/session/timeline/rows.ts
+++ b/packages/app/src/pages/session/timeline/rows.ts
@@ -234,6 +234,7 @@ export namespace Timeline {
userMessageID: userMessage.id,
reasoningHeading: heading,
turnRunning: turnIsRunning,
+ turnStartedAt: userMessage.time.created,
}),
)
}
@@ -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),
}),
)
}
diff --git a/packages/app/src/pages/session/timeline/thought-rail.test.ts b/packages/app/src/pages/session/timeline/thought-rail.test.ts
index 0be8d90e0d..19c784904d 100644
--- a/packages/app/src/pages/session/timeline/thought-rail.test.ts
+++ b/packages/app/src/pages/session/timeline/thought-rail.test.ts
@@ -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", () => {
diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx
index be6fbed069..7213789d7d 100644
--- a/packages/app/src/pages/session/timeline/thought-rail.tsx
+++ b/packages/app/src/pages/session/timeline/thought-rail.tsx
@@ -84,7 +84,9 @@
// // tail: { top: first ? "0px" : NEG, height: first ? "0px" : `calc(${STEP_GAP} + ${dotCentre}px)` }
+import { createSignal, onCleanup, Show } from "solid-js"
import { HarmonicDot, HARMONIC_SIZE } from "@opencode-ai/ui/amicode-harmonic-dot"
+import { formatElapsed, formatTokens } from "@opencode-ai/ui/amicode-thinking"
const NODE = 7 // dot diameter, px — matches the site's Step
@@ -128,6 +130,13 @@ export function ThoughtRail(props: {
/** measured centre of the row's first text line (px from the row's top);
* defaults to DEFAULT_DOT_CENTRE for unmeasured/prose rows */
dotCentre?: number
+ /** true once the first measurement has landed — gates the CSS transition
+ * so the initial mount uses the grow animation alone (#265) */
+ settled?: boolean
+ /** epoch-ms when the user message was created — anchors the tooltip timer */
+ turnStartedAt?: number
+ /** streamed token count for this turn — shown in the tooltip */
+ tokens?: number
}) {
// Only the tail of a still-running turn is hollow. Everything above it has,
// by definition, been succeeded. (Rule 4 — adjacency.)
@@ -172,12 +181,14 @@ export function ThoughtRail(props: {
// RUNNING: spherical-harmonic morphing dot — 13px SVG centred on LINE_X.
// The grow animation (7→13px) is a CSS @keyframes on mount; the morph
// cycles Y_l^m silhouettes via SMIL; slow rotation via CSS on the .
-
) : (
// DONE: 7px ink circle — the rail is one ink stroke (Rule 5).
@@ -196,7 +207,7 @@ export function ThoughtRail(props: {
width: `${NODE}px`,
height: `${NODE}px`,
border: "1px solid var(--v2-text-text-base)",
- background: "var(--v2-text-text-base)",
+ background: "var(--v2-text-text-base)",
}}
/>
)}
@@ -204,6 +215,61 @@ export function ThoughtRail(props: {
)
}
+/** Running dot with a hover tooltip showing elapsed time + tokens (#625).
+ * The tooltip only renders while hovered to keep DOM cost near zero. The
+ * timer ticks from `turnStartedAt` (the user message's `time.created`), so
+ * it survives component remount across session switches. */
+function DotWithTooltip(props: {
+ dotCentre: number
+ settled?: boolean
+ turnStartedAt?: number
+ tokens?: number
+}) {
+ const [hovered, setHovered] = createSignal(false)
+ const [elapsedMs, setElapsedMs] = createSignal(0)
+
+ // Tick the timer only while hovered — no cost when tooltip is hidden
+ let clock: ReturnType | undefined
+ const startTicking = () => {
+ if (props.turnStartedAt == null) return
+ setElapsedMs(Date.now() - props.turnStartedAt)
+ clock = setInterval(() => setElapsedMs(Date.now() - props.turnStartedAt!), 1000)
+ }
+ const stopTicking = () => {
+ if (clock != null) clearInterval(clock)
+ clock = undefined
+ }
+ onCleanup(stopTicking)
+
+ return (
+ { setHovered(true); startTicking() }}
+ onMouseLeave={() => { setHovered(false); stopTicking() }}
+ >
+
+
+
+ {formatElapsed(elapsedMs())}
+
+ ·
+ {formatTokens(props.tokens!)} tokens
+
+
+
+
+ )
+}
+
/**
* Eyebrow naming a step's action, for rows whose content doesn't open with its
* own title (assistant prose, reasoning). Sits on the dot's line so the rail
diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts
index 8cd3af7c17..e0698441e5 100644
--- a/packages/app/src/pages/session/timeline/timeline-row.ts
+++ b/packages/app/src/pages/session/timeline/timeline-row.ts
@@ -27,6 +27,8 @@ export namespace TimelineRow {
lastAssistantPart: boolean
/** the turn is still working, so the tail step is in flight rather than done */
turnRunning: boolean
+ /** epoch-ms when the user message was created — anchors the dot tooltip timer */
+ turnStartedAt: number
/** eyebrow naming the action for steps whose content doesn't already open
* with its own title — reasoning ("Reasoning") only. Prose carries no
* caption (the words are the step), and tool cards and the Explored /
@@ -38,6 +40,8 @@ export namespace TimelineRow {
reasoningHeading?: string
/** the turn is still actively streaming */
turnRunning: boolean
+ /** epoch-ms when the user message was created — anchors the dot tooltip timer */
+ turnStartedAt: number
}> {}
export class ThinkingMeta extends Data.TaggedClass("ThinkingMeta")<{
userMessageID: string
diff --git a/packages/app/src/pages/session/timeline/travelling-dot.test.ts b/packages/app/src/pages/session/timeline/travelling-dot.test.ts
new file mode 100644
index 0000000000..dd62fb86bc
--- /dev/null
+++ b/packages/app/src/pages/session/timeline/travelling-dot.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+
+// Regression guard for the travelling dot + bottom-up card animation (#265).
+// These CSS declarations are load-bearing: removing them silently breaks the
+// dot's smooth travel and the card entry motion.
+
+const indexCss = readFileSync(resolve(__dirname, "../../../index.css"), "utf8")
+const polishCss = readFileSync(resolve(__dirname, "../../../design-polish.css"), "utf8")
+
+describe("travelling dot transition (#265)", () => {
+ test("settled dot has top transition", () => {
+ expect(indexCss).toContain("thought-rail-dot--settled")
+ expect(indexCss).toMatch(/thought-rail-dot--settled[^}]*transition[^}]*top/)
+ })
+
+ test("rail line has height transition", () => {
+ expect(indexCss).toMatch(/thought-rail-line[^}]*transition[^}]*height/)
+ })
+
+ test("reduced motion disables dot transition", () => {
+ // Inside a prefers-reduced-motion block, the settled class gets transition: none
+ expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-dot--settled[\s\S]*transition:\s*none/)
+ })
+
+ test("reduced motion disables rail line transition", () => {
+ expect(indexCss).toMatch(/prefers-reduced-motion[\s\S]*thought-rail-line[\s\S]*transition:\s*none/)
+ })
+})
+
+describe("prose fragment entry animation (#265)", () => {
+ test("prose-fragment-enter keyframe exists", () => {
+ expect(polishCss).toContain("prose-fragment-enter")
+ })
+
+ test("prose-fragment-enter uses translateY", () => {
+ expect(polishCss).toMatch(/prose-fragment-enter[\s\S]*translateY\(10px\)/)
+ })
+
+ test("prose-fragment cards use prose-fragment-enter animation", () => {
+ expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*prose-fragment-enter/)
+ })
+
+ test("prose-fragment-enter has 150ms duration", () => {
+ expect(polishCss).toMatch(/data-prose-fragment.*data-part-enter[\s\S]*150ms/)
+ })
+
+ test("reduced motion disables prose-fragment entrance", () => {
+ expect(polishCss).toMatch(/prefers-reduced-motion[\s\S]*data-prose-fragment.*data-part-enter[\s\S]*animation:\s*none/)
+ })
+})
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index 6b2f78a57c..752d19452e 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -9,6 +9,7 @@
"./session-diff": "./src/components/session-diff.ts",
"./message-file": "./src/components/message-file.ts",
"./message-part-text": "./src/components/message-part-text.ts",
+ "./build-trace": "./src/components/build-trace.ts",
"./markdown-stream": "./src/components/markdown-stream.ts",
"./markdown-cache": "./src/components/markdown-cache.tsx",
"./markdown-file-refs": "./src/components/markdown-file-refs.ts",
diff --git a/packages/session-ui/src/components/build-trace.test.ts b/packages/session-ui/src/components/build-trace.test.ts
new file mode 100644
index 0000000000..4a3a13a69b
--- /dev/null
+++ b/packages/session-ui/src/components/build-trace.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, test } from "bun:test"
+import { buildTrace } from "./build-trace"
+import type { AssistantMessage, Part as PartType } from "@opencode-ai/sdk/v2"
+
+function msg(id: string): AssistantMessage {
+ return {
+ id,
+ sessionID: "s1",
+ role: "assistant",
+ providerID: "p1",
+ modelID: "m1",
+ time: { created: 1000, completed: 2000 },
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ } as AssistantMessage
+}
+
+function textPart(id: string, text: string): PartType {
+ return { id, sessionID: "s1", messageID: "msg1", type: "text", text } as PartType
+}
+
+function bashPart(id: string, command: string, output: string): PartType {
+ return {
+ id,
+ sessionID: "s1",
+ messageID: "msg1",
+ type: "tool",
+ callID: id,
+ tool: "bash",
+ state: { status: "completed", input: { command }, output, title: "", metadata: {}, time: { start: 0, end: 1 } },
+ } as PartType
+}
+
+function toolPart(id: string, tool: string, output: string): PartType {
+ return {
+ id,
+ sessionID: "s1",
+ messageID: "msg1",
+ type: "tool",
+ callID: id,
+ tool,
+ state: { status: "completed", input: {}, output, title: "", metadata: {}, time: { start: 0, end: 1 } },
+ } as PartType
+}
+
+function errorPart(id: string, tool: string, error: string): PartType {
+ return {
+ id,
+ sessionID: "s1",
+ messageID: "msg1",
+ type: "tool",
+ callID: id,
+ tool,
+ state: { status: "error", input: {}, error, time: { start: 0, end: 1 } },
+ } as PartType
+}
+
+function skipPart(id: string, tool: string): PartType {
+ return {
+ id,
+ sessionID: "s1",
+ messageID: "msg1",
+ type: "tool",
+ callID: id,
+ tool,
+ state: { status: "completed", input: {}, output: "some output", title: "", metadata: {}, time: { start: 0, end: 1 } },
+ } as PartType
+}
+
+describe("buildTrace", () => {
+ test("concatenates text parts from a single message", () => {
+ const parts: Record = {
+ msg1: [textPart("p1", "Hello world"), textPart("p2", "Second paragraph")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("Hello world\n\nSecond paragraph")
+ })
+
+ test("includes bash command and output", () => {
+ const parts: Record = {
+ msg1: [textPart("p1", "Running a command"), bashPart("p2", "echo hello", "hello")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("Running a command\n\n$ echo hello\nhello")
+ })
+
+ test("includes non-exploration tool output", () => {
+ const parts: Record = {
+ msg1: [toolPart("p1", "edit", "File edited successfully")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("[edit] File edited successfully")
+ })
+
+ test("includes error tool output", () => {
+ const parts: Record = {
+ msg1: [errorPart("p1", "bash", "command not found")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("[bash] Error: command not found")
+ })
+
+ test("skips exploration tools (read, glob, grep, list)", () => {
+ const parts: Record = {
+ msg1: [
+ textPart("p1", "Looking at files"),
+ skipPart("p2", "read"),
+ skipPart("p3", "glob"),
+ skipPart("p4", "grep"),
+ skipPart("p5", "list"),
+ textPart("p6", "Found what I needed"),
+ ],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("Looking at files\n\nFound what I needed")
+ })
+
+ test("skips reasoning and other non-content parts", () => {
+ const parts: Record = {
+ msg1: [
+ { id: "r1", sessionID: "s1", messageID: "msg1", type: "reasoning", text: "thinking..." } as PartType,
+ textPart("p1", "The answer is 42"),
+ ],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("The answer is 42")
+ })
+
+ test("handles multiple messages in a turn", () => {
+ const parts: Record = {
+ msg1: [textPart("p1", "First message")],
+ msg2: [textPart("p2", "Second message"), bashPart("p3", "ls", "file.txt")],
+ }
+ const result = buildTrace([msg("msg1"), msg("msg2")], (id) => parts[id] ?? [])
+ expect(result).toBe("First message\n\nSecond message\n\n$ ls\nfile.txt")
+ })
+
+ test("trims whitespace from text and output", () => {
+ const parts: Record = {
+ msg1: [textPart("p1", " spaced "), bashPart("p2", "echo x", " output ")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("spaced\n\n$ echo x\noutput")
+ })
+
+ test("returns empty string for empty messages", () => {
+ const result = buildTrace([], () => [])
+ expect(result).toBe("")
+ })
+
+ test("bash part with no command still includes output", () => {
+ const parts: Record = {
+ msg1: [bashPart("p1", "", "some output")],
+ }
+ const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [])
+ expect(result).toBe("some output")
+ })
+})
diff --git a/packages/session-ui/src/components/build-trace.ts b/packages/session-ui/src/components/build-trace.ts
new file mode 100644
index 0000000000..43858217f1
--- /dev/null
+++ b/packages/session-ui/src/components/build-trace.ts
@@ -0,0 +1,42 @@
+import type { AssistantMessage, Part as PartType } from "@opencode-ai/sdk/v2"
+
+// Skipped tool types when building the copy-trace content — these are internal
+// bookkeeping or exploration noise, not user-facing output.
+const TRACE_SKIP_TOOLS = new Set(["read", "glob", "grep", "list"])
+
+/**
+ * Build a copyable trace string from an assistant turn's messages and parts.
+ * Concatenates text parts with tool command+output, skipping exploration noise.
+ */
+export function buildTrace(
+ messages: AssistantMessage[],
+ getParts: (messageID: string) => PartType[],
+): string {
+ const segments: string[] = []
+
+ for (const message of messages) {
+ for (const part of getParts(message.id)) {
+ if (!part) continue
+ if (part.type === "text") {
+ const text = part.text?.trim()
+ if (text) segments.push(text)
+ } else if (part.type === "tool") {
+ if (TRACE_SKIP_TOOLS.has(part.tool)) continue
+ if (part.state.status === "completed") {
+ const input = part.state.input ?? {}
+ const output = part.state.output?.trim() ?? ""
+ if (part.tool === "bash" || part.tool === "shell") {
+ const cmd = typeof input.command === "string" ? input.command : ""
+ segments.push(cmd ? `$ ${cmd}\n${output}` : output)
+ } else if (output) {
+ segments.push(`[${part.tool}] ${output}`)
+ }
+ } else if (part.state.status === "error") {
+ segments.push(`[${part.tool}] Error: ${part.state.error}`)
+ }
+ }
+ }
+ }
+
+ return segments.join("\n\n")
+}
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index 01da110582..be84cbd6a6 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -246,45 +246,21 @@
[data-component="text-part"] {
width: 100%;
- margin-top: 24px;
[data-slot="text-part-body"] {
margin-top: 0;
}
+}
- [data-slot="text-part-copy-wrapper"] {
- min-height: 24px;
- margin-top: 4px;
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 10px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s ease;
- will-change: opacity;
-
- [data-component="tooltip-trigger"] {
- display: inline-flex;
- width: fit-content;
- }
- }
+[data-slot="turn-footer"] {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 8px;
- [data-slot="text-part-meta"] {
+ [data-slot="turn-footer-meta"] {
user-select: none;
}
-
- [data-slot="text-part-copy-wrapper"][data-interrupted] {
- width: 100%;
- justify-content: flex-end;
- gap: 12px;
- }
-
- &:hover [data-slot="text-part-copy-wrapper"],
- &:focus-within [data-slot="text-part-copy-wrapper"] {
- opacity: 1;
- pointer-events: auto;
- }
}
[data-component="compaction-part"] {
diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx
index 505ee43dd8..acce26b52d 100644
--- a/packages/session-ui/src/components/message-part.tsx
+++ b/packages/session-ui/src/components/message-part.tsx
@@ -74,6 +74,7 @@ import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText, splitSettledChunks } from "./message-part-text"
+import { buildTrace } from "./build-trace"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
const reducedMotion = () =>
@@ -220,14 +221,16 @@ function MessageActionButton(
icon: "check" | "copy" | "reset"
label: JSX.Element
useV2?: boolean
+ placement?: "top" | "bottom"
},
) {
const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon)
+ const placement = () => props.placement ?? "top"
return (
+
}
>
-
+
}
size="normal"
@@ -964,10 +967,100 @@ export function AssistantParts(props: {
+
>
)
}
+function TurnFooter(props: {
+ messages: AssistantMessage[]
+ turnDurationMs?: number
+ working?: boolean
+}) {
+ const data = useData()
+ const i18n = useI18n()
+ const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
+ const [copied, setCopied] = createSignal(false)
+
+ const lastMessage = createMemo(() => props.messages.at(-1))
+
+ const model = createMemo(() => {
+ const message = lastMessage()
+ if (!message) return ""
+ const match = data.store.provider?.all?.get(message.providerID)
+ return match?.models?.[message.modelID]?.name ?? message.modelID
+ })
+
+ const duration = createMemo(() => {
+ const message = lastMessage()
+ if (!message) return ""
+ const completed = message.time.completed
+ const ms =
+ typeof props.turnDurationMs === "number"
+ ? props.turnDurationMs
+ : typeof completed === "number"
+ ? completed - message.time.created
+ : -1
+ if (!(ms >= 0)) return ""
+ const total = Math.round(ms / 1000)
+ if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
+ const minutes = Math.floor(total / 60)
+ const seconds = total % 60
+ return i18n.t("ui.message.duration.minutesSeconds", {
+ minutes: numfmt().format(minutes),
+ seconds: numfmt().format(seconds),
+ })
+ })
+
+ const interrupted = createMemo(() => {
+ const message = lastMessage()
+ return !!message?.error?.name && message.error.name === "MessageAbortedError"
+ })
+
+ const meta = createMemo(() => {
+ const message = lastMessage()
+ if (!message) return ""
+ const agent = message.agent
+ const items = [
+ agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
+ model(),
+ duration(),
+ interrupted() ? i18n.t("ui.message.interrupted") : "",
+ ]
+ return items.filter((x) => !!x).join(" \u00B7 ")
+ })
+
+ const handleCopyTrace = async () => {
+ const emptyParts: PartType[] = []
+ const content = buildTrace(props.messages, (id) => list(data.store.part?.[id], emptyParts))
+ if (!content) return
+ if (await writeClipboard(content)) {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+ }
+
+ return (
+
+
+ event.preventDefault()}
+ onClick={handleCopyTrace}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyTrace")}
+ />
+
+
+ {meta()}
+
+
+
+
+ )
+}
// One-line command for a bash part's row in the shell group. Logic lives in
// ../amicode/shell-row.ts so the fallback chain is testable — it shipped a bug
// where a pending part rendered the model's prose description as if it were the
@@ -2098,80 +2191,22 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
- const i18n = useI18n()
- const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const part = () => props.part as TextPart
- const interrupted = createMemo(
- () =>
- props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
- )
-
- const model = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const message = props.message as AssistantMessage
- const match = data.store.provider?.all?.get(message.providerID)
- return match?.models?.[message.modelID]?.name ?? message.modelID
- })
- const duration = createMemo(() => {
- if (props.message.role !== "assistant") return ""
+ const streaming = createMemo(() => {
+ if (props.message.role !== "assistant") return false
const message = props.message as AssistantMessage
- const completed = message.time.completed
- const ms =
- typeof props.turnDurationMs === "number"
- ? props.turnDurationMs
- : typeof completed === "number"
- ? completed - message.time.created
- : -1
- if (!(ms >= 0)) return ""
- const total = Math.round(ms / 1000)
- if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
- const minutes = Math.floor(total / 60)
- const seconds = total % 60
- return i18n.t("ui.message.duration.minutesSeconds", {
- minutes: numfmt().format(minutes),
- seconds: numfmt().format(seconds),
- })
- })
-
- const meta = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const agent = (props.message as AssistantMessage).agent
- const items = [
- agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
- model(),
- duration(),
- interrupted() ? i18n.t("ui.message.interrupted") : "",
- ]
- return items.filter((x) => !!x).join(" \u00B7 ")
+ // Message is complete → not streaming
+ if (typeof message.time.completed === "number") return false
+ // If subsequent parts exist after this text part, the model has moved on
+ // (e.g. to a tool call) — the text content is finalized, flush the tail
+ // so it renders before the tool row appears (#265).
+ const allParts = data.store.part?.[props.message.id] ?? []
+ const myIndex = allParts.findIndex((p) => p?.id === part().id)
+ if (myIndex >= 0 && myIndex < allParts.length - 1) return false
+ return true
})
-
- const streaming = createMemo(
- () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
- )
const text = () => readPartText(data.store.part_text_accum_delta, part())
- const isLastTextPart = createMemo(() => {
- const last = (data.store.part?.[props.message.id] ?? [])
- .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
- .at(-1)
- return last?.id === part().id
- })
- const showCopy = createMemo(() => {
- if (props.message.role !== "assistant") return isLastTextPart()
- if (props.showAssistantCopyPartID === null) return false
- if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
- return isLastTextPart()
- })
- const [copied, setCopied] = createSignal(false)
-
- const handleCopy = async () => {
- const content = text()
- if (!content) return
- if (await writeClipboard(content)) {
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
- }
return (
@@ -2186,23 +2221,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
so remounts never re-animate. */}
-
-
- event.preventDefault()}
- onClick={handleCopy}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
- />
-
-
- {meta()}
-
-
-
-
)
diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx
index 354601ccc9..e96adeaaca 100644
--- a/packages/session-ui/src/v2/components/prompt-input/index.tsx
+++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx
@@ -261,7 +261,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel="Send"
- stopLabel="Stop"
+ stopLabel="Interrupt (Esc)"
onSubmit={props.controller.submit}
onStop={props.controller.stop}
/>
diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css
index 02f9606bf4..a6f6831cc1 100644
--- a/packages/ui/src/amicode/amicode.css
+++ b/packages/ui/src/amicode/amicode.css
@@ -73,6 +73,13 @@
.amc-thinking-sep { opacity: 0.55; }
.amc-thinking-hint { font-style: italic; }
+/* Turn footer: the completed-turn meta row (copy + elapsed + tokens) */
+[data-slot="session-turn-thinking-meta"] {
+ display: flex;
+ align-items: center;
+ gap: 0;
+}
+
/* ---- an opened skill file (message-part.tsx, ToolRegistry "skill") ------- */
/* Expanding a skill used to dump its instructions as bare markdown straight into the
* transcript, because [data-component="tool-output"] carries no surface of its own — no
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index c492953e84..fbbfa2fc30 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -175,6 +175,7 @@ export const dict: Record = {
"ui.message.forkMessage": "Fork to new session",
"ui.message.revertMessage": "Revert message",
"ui.message.copyResponse": "Copy response",
+ "ui.message.copyTrace": "Copy trace",
"ui.message.copied": "Copied",
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",