From 52d592b666bddcfc1af3aeba2fb6d0aa78ec9a28 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 09:23:47 -0400 Subject: [PATCH 1/5] feat(app): travelling harmonic dot + bottom-up card entry animation The harmonic dot now tracks the LAST prose-fragment card within a running text-part row rather than staying pinned to the first text line. As new paragraph cards settle during streaming, the dot slides smoothly down the rail (CSS transition, 150ms ease-out), keeping the loading indicator visible near the newest content. Changes: - measureDotCentre targets last [data-prose-fragment] instead of first text node when fragments are present (works for both running and done states) - ThoughtRail accepts a settled prop; dot gets .thought-rail-dot--settled class after first measurement to gate the transition (prevents slide on initial mount) - Rail line gets matching height transition so spine extends in sync - Prose-fragment cards get a dedicated entry animation: 150ms ease-out, translateY(10px) + opacity fade, no blur (snappier than the general timeline-enter) - All transitions/animations honour prefers-reduced-motion: reduce - Sanity ceiling (80px) removed for fragment-targeting measurements since the dot can travel far down a long response Closes #265 --- packages/app/src/design-polish.css | 20 +++++++ packages/app/src/index.css | 18 +++++++ .../session/timeline/message-timeline.tsx | 22 +++++--- .../pages/session/timeline/thought-rail.tsx | 7 ++- .../session/timeline/travelling-dot.test.ts | 52 +++++++++++++++++++ 5 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/travelling-dot.test.ts diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index a9487f974..08baf080b 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 987856117..4b23fe85c 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 070543e07..04cfdc2c0 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1465,10 +1465,18 @@ 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): when the row has prose-fragment cards, target + // the LAST one so the dot (running or done) aligns with the newest + // settled chunk. This applies in both running and done states — the + // done-dot should stay at the last card, not jump back to the first. + const fragments = turnEl.querySelectorAll("[data-prose-fragment]") + const lastFragment = 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 +1485,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 +1522,7 @@ export function MessageTimeline(props: { > {(r) => ( - + )} {/* The gutter is reserved for EVERY assistant part, not only the ones diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx index be6fbed06..80cb5416c 100644 --- a/packages/app/src/pages/session/timeline/thought-rail.tsx +++ b/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -128,6 +128,9 @@ 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 }) { // Only the tail of a still-running turn is hollow. Everything above it has, // by definition, been succeeded. (Rule 4 — adjacency.) @@ -172,8 +175,10 @@ 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 . + // The settled class gates the top transition (#265): after the first + // measurement, subsequent dotCentre changes slide smoothly. { + 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/) + }) +}) From 221e159488198cfd67e341f1af9efe33ac76d5f4 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 09:47:38 -0400 Subject: [PATCH 2/5] fix(session-ui): flush text tail when subsequent parts exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a text part is followed by another part (e.g. a tool call), the text content is finalized — the model has moved on. Previously, the streaming signal was keyed only on message.time.completed (message-level), so the text tail stayed withheld until the entire turn finished. This caused a visual ordering bug: the tool row ('Shell [command]') rendered before the preceding text was visible. Now the streaming signal also checks whether subsequent parts exist in the same message. If the text part is not the last part, it's marked as done immediately, flushing the withheld tail so text renders before its following tool row. Part of #265 --- .../session-ui/src/components/message-part.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 505ee43dd..8cf1e2be5 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -2146,9 +2146,19 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return items.filter((x) => !!x).join(" \u00B7 ") }) - const streaming = createMemo( - () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", - ) + const streaming = createMemo(() => { + if (props.message.role !== "assistant") return false + const message = props.message as AssistantMessage + // 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 text = () => readPartText(data.store.part_text_accum_delta, part()) const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) From afda0e44530e836e16ca659c6d50ff898be8eee0 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 09:52:19 -0400 Subject: [PATCH 3/5] fix(app): done-dot stays at top, only running dot travels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The done-dot (filled circle) should mark the row's origin — the first text line — while only the running harmonic dot tracks the last prose-fragment card. Previously measureDotCentre always targeted the last fragment regardless of state, which left the done-dot at the bottom of long responses. Now the measurement is conditional: fragments are targeted only when the rail is running (r.last && r.running). When the turn completes and the ResizeObserver re-measures, it falls back to the original first- text-node behavior and the done-dot snaps to the top. --- .../src/pages/session/timeline/message-timeline.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 04cfdc2c0..a5028568c 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1469,12 +1469,13 @@ export function MessageTimeline(props: { const measureDotCentre = () => { if (!turnEl || !rail()) return const hostTop = turnEl.getBoundingClientRect().top - // Travelling dot (#265): when the row has prose-fragment cards, target - // the LAST one so the dot (running or done) aligns with the newest - // settled chunk. This applies in both running and done states — the - // done-dot should stay at the last card, not jump back to the first. - const fragments = turnEl.querySelectorAll("[data-prose-fragment]") - const lastFragment = fragments.length > 0 ? (fragments[fragments.length - 1] as HTMLElement) : null + // 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 From 5d3d816ec623aa091a4db72e79d009efa1b40f4d Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 12:12:24 -0400 Subject: [PATCH 4/5] =?UTF-8?q?feat(app):=20content-to-composer=20proximit?= =?UTF-8?q?y=20=E2=80=94=20iMessage=20feel=20(#625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the gap between streaming output and the composer to create an iMessage-like feel where content and input share the same surface. Spacing hierarchy: - Within a turn (pt-3): 12px - Text-part margin-top: 12px - Between turns (TurnGap): 24px - Bottom to composer (paddingEnd): 24px Changes: - Hide ThinkingMeta during streaming (historical record only) - Add hover tooltip on running harmonic dot (elapsed + tokens) - Anchor timer to userMessage.time.created (survives session switch) - Thread turnStartedAt through Thinking/AssistantPart row data - Update stop button tooltip to 'Interrupt (Esc)' - Collapse text-part-copy-wrapper to position:absolute (no layout impact) - Remove pt-3 from ThinkingMeta row - Simplify TimelineThinkingMetaRow (remove dead turnRunning branch) - Fix shouldRenderRail test (Thinking row always provides first node) - Update e2e smoke test for new spacer height --- .../app/e2e/smoke/session-timeline.spec.ts | 2 +- .../session/timeline/message-timeline.tsx | 45 +++++------ .../pages/session/timeline/projection.test.ts | 1 + .../session/timeline/rows-current.test.ts | 48 +++++++++++- .../app/src/pages/session/timeline/rows.ts | 13 ++-- .../session/timeline/thought-rail.test.ts | 4 +- .../pages/session/timeline/thought-rail.tsx | 75 +++++++++++++++++-- .../pages/session/timeline/timeline-row.ts | 4 + .../src/components/message-part.css | 10 ++- .../src/v2/components/prompt-input/index.tsx | 2 +- 10 files changed, 162 insertions(+), 42 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index e2d4bdc15..13fc43cd5 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/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index a5028568c..db5c4213b 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, @@ -161,23 +161,19 @@ function TimelineThinkingRow(_props: { reasoningHeading?: string; showReasoningS ) } -function TimelineThinkingMetaRow(props: { turnRunning: boolean; turnDurationMs?: number; tokens?: number }) { +function TimelineThinkingMetaRow(props: { turnDurationMs?: number; tokens?: number }) { return (
- - - + + + - - }> - +
) @@ -658,7 +654,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 +1414,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) @@ -1523,7 +1519,15 @@ export function MessageTimeline(props: { > {(r) => ( - + )} {/* The gutter is reserved for EVERY assistant part, not only the ones @@ -1673,7 +1677,6 @@ export function MessageTimeline(props: { class="w-full px-4 md:px-5 relative" > @@ -2513,8 +2516,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 76563595d..a2dc9809a 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 4376847d1..942ed8819 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 1a50393cb..0dc5f86f3 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 0be8d90e0..19c784904 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 80cb5416c..7213789d7 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 @@ -131,6 +133,10 @@ export function ThoughtRail(props: { /** 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.) @@ -177,12 +183,12 @@ export function ThoughtRail(props: { // cycles Y_l^m silhouettes via SMIL; slow rotation via CSS on the . // The settled class gates the top transition (#265): after the first // measurement, subsequent dotCentre changes slide smoothly. - ) : ( // DONE: 7px ink circle — the rail is one ink stroke (Rule 5). @@ -201,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)", }} /> )} @@ -209,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 8cd3af7c1..e0698441e 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/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 01da11058..a11e33896 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -246,15 +246,20 @@ [data-component="text-part"] { width: 100%; - margin-top: 24px; + margin-top: 12px; + position: relative; [data-slot="text-part-body"] { margin-top: 0; } [data-slot="text-part-copy-wrapper"] { + position: absolute; + left: 0; + bottom: 0; + transform: translateY(100%); + padding-top: 4px; min-height: 24px; - margin-top: 4px; display: flex; align-items: center; justify-content: flex-start; @@ -263,6 +268,7 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; + z-index: 10; [data-component="tooltip-trigger"] { display: inline-flex; 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 354601ccc..e96adeaac 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} /> From b3dab6494d260bb781daed6d0550ac2a299334d0 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 28 Aug 2026 12:54:20 -0400 Subject: [PATCH 5/5] feat(app): replace hover copy with persistent copy-trace button (#625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-text-part hover copy overlay (position: absolute, translateY, z-index conflicts) with a single persistent copy-trace button on the completed-turn footer row. The button copies the full assistant trace — all text parts plus tool command/output, skipping exploration noise (read/glob/grep/list). Changes: - Remove text-part-copy-wrapper from TextPartDisplay and its CSS - Extract buildTrace() into @opencode-ai/session-ui/build-trace (tested) - Add TurnFooter component to AssistantParts (session-ui path) - Add copy button to TimelineThinkingMetaRow (app timeline path) - Add placement prop to MessageActionButton (tooltip below) - Add turn-footer and session-turn-thinking-meta flex layout styles - Remove text-part margin-top: 12px (parent gap handles spacing) - Add i18n key ui.message.copyTrace --- .../session/timeline/message-timeline.tsx | 40 +++- packages/session-ui/package.json | 1 + .../src/components/build-trace.test.ts | 157 +++++++++++++++ .../session-ui/src/components/build-trace.ts | 42 ++++ .../src/components/message-part.css | 44 +---- .../src/components/message-part.tsx | 182 +++++++++--------- packages/ui/src/amicode/amicode.css | 7 + packages/ui/src/i18n/en.ts | 1 + 8 files changed, 349 insertions(+), 125 deletions(-) create mode 100644 packages/session-ui/src/components/build-trace.test.ts create mode 100644 packages/session-ui/src/components/build-trace.ts diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index db5c4213b..4c7b8c095 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -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,12 +163,36 @@ function TimelineThinkingRow(_props: { reasoningHeading?: string; showReasoningS ) } -function TimelineThinkingMetaRow(props: { 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")} + /> +
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 6b2f78a57..752d19452 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 000000000..4a3a13a69 --- /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 000000000..43858217f --- /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 a11e33896..be84cbd6a 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -246,51 +246,21 @@ [data-component="text-part"] { width: 100%; - margin-top: 12px; - position: relative; [data-slot="text-part-body"] { margin-top: 0; } +} - [data-slot="text-part-copy-wrapper"] { - position: absolute; - left: 0; - bottom: 0; - transform: translateY(100%); - padding-top: 4px; - min-height: 24px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - will-change: opacity; - z-index: 10; - - [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 8cf1e2be5..acce26b52 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,53 +2191,7 @@ 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 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 ") - }) const streaming = createMemo(() => { if (props.message.role !== "assistant") return false @@ -2160,28 +2207,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return true }) 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 ( @@ -2196,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/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 02f9606bf..a6f6831cc 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 c492953e8..fbbfa2fc3 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",