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 (
- - - + + } + size="normal" + variant="ghost-muted" + onMouseDown={(e) => e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? language.t("ui.message.copied") : language.t("ui.message.copyTrace")} + /> + + + + - - }> - +
) @@ -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",