diff --git a/scripts/gallery-matrix/invariant-baseline.json b/scripts/gallery-matrix/invariant-baseline.json index c9bf43fd3902..db72e23e838d 100644 --- a/scripts/gallery-matrix/invariant-baseline.json +++ b/scripts/gallery-matrix/invariant-baseline.json @@ -2660,6 +2660,8 @@ "agent-pane.tool-row-column#tall-fade": [], "agent-pane.transcript-chevron-column#dark": [], "agent-pane.transcript-chevron-column#light": [], + "agent-pane.transcript-row-alignment#finished": [], + "agent-pane.transcript-row-alignment#finished-hover": [], "agent-pane.transcript-row-alignment#footer": [ "center-line:row subagentDone:agent-docs-check" ], diff --git a/scripts/gallery-matrix/shift-baseline.json b/scripts/gallery-matrix/shift-baseline.json index 9c0e7918b624..35a776ac5b43 100644 --- a/scripts/gallery-matrix/shift-baseline.json +++ b/scripts/gallery-matrix/shift-baseline.json @@ -1767,6 +1767,8 @@ "agent-pane.tool-row-column#tall-fade": 0, "agent-pane.transcript-chevron-column#dark": 0, "agent-pane.transcript-chevron-column#light": 0, + "agent-pane.transcript-row-alignment#finished": 0, + "agent-pane.transcript-row-alignment#finished-hover": 0, "agent-pane.transcript-row-alignment#footer": 0, "agent-pane.transcript-row-alignment#mix": 0, "agent-pane.transcript-row-alignment#off": 0, diff --git a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx index 3eec5096a013..96454ff223ba 100644 --- a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx +++ b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx @@ -12,7 +12,7 @@ import type { AcpmuxRow } from "../model"; import { useRowMark } from "../threadMinimap/promptMarks"; import { AlertCircle, Check, DoubleCheck, Pin, Retry, Speaker, Spinner, StopReading, TurnFork } from "./icons"; import { canReadAloud, readAloud, useReadingAloud } from "./readAloud"; -import { exactTime, relativeTime } from "./relativeTime"; +import { exactTime, relativeTime, replyTime } from "./relativeTime"; import { CopyButton } from "./CopyButton"; import { hasMessageMenuHost, openMessageMenu, openForkMenu } from "./messageMenu"; import { RetryModelMenu } from "./RetryModelMenu"; @@ -41,6 +41,27 @@ export function MessageTime({ at, className }: { at: number; className: string } ); } +/// A reply's time at the end of its action row, after the buttons, as ChatGPT's: the exact date and +/// time ("Oct 18, 5:41 AM"), hidden at rest and shown while the reader points at the reply +/// (conversation.css `.cv-turn-time`; Leo, 2026-11-02: "notice how on hover it reveals the exact +/// timestamp. let's follow those."). `detail`, when given, is its tooltip (the turn's time and tokens). +export function ReplyTime({ at, detail }: { at: number; detail?: string }) { + const now = useMinuteClock(); + const language = usePaneLanguage(); + return ( + + ); +} + /// Shared footer rail for sent, received and answer rows. Timestamp text and every action stay on /// one centered 24px line with equal control slots, including the retry model chevron. export function MessageFooterRow({ children, className }: { children: ReactNode; className?: string }) { @@ -332,7 +353,7 @@ export function ReceivedActions({ row, more }: { row: AcpmuxRow; more?: ReactNod /// The row under a reply that its turn's footer does not serve (model.ts `hasReplyActions`: a /// steer, another agent's message, a sent message or a tool row follows it): the footer's look and -/// buttons for this reply, Copy, Pin, Read aloud and More, then its age, as Claude desktop draws +/// buttons for this reply, Copy, Pin, Read aloud and More, then its time (shown on hover), as Claude desktop draws /// under every reply. Its line is reserved; it shows on hover or keyboard focus (conversation.css). export function ReplyActions({ row }: { row: AcpmuxRow }) { const text = row.text ?? ""; @@ -347,7 +368,7 @@ export function ReplyActions({ row }: { row: AcpmuxRow }) { - + ); } diff --git a/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts b/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts index eb27f3f82c1e..91e8d0aeff5f 100644 --- a/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/TranscriptRowAlignment.gallery.ts @@ -4,7 +4,8 @@ // NEED TO LINE OUR SHIT UP LIKE THEIRS"). One live turn mixes the row kinds the way a long session // does: a reply, a tool call, a reply, a subagent that finished, a reply, a background command that // ended, a reply, another agent's message, a reply. The turns above it end with a "Worked for" line (a folded turn) -// and a footer that says its time and tokens beside the reply's action row. Every row's first ink +// and a footer whose action row starts on the reply's left edge, its time and tokens in the time's +// tooltip (Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons"). Every row's first ink // sits on the prose's left edge, an icon's text one icon column in, and each status row has one room // above and one below; the reply over it keeps its 2026-10-30 room, so above is the larger // (transcript-row-alignment.test.ts). @@ -31,7 +32,7 @@ const received = (name: string, text: string, minutes: number) => }); const rows = [ - // A turn with no tool work: its footer says how long it took and what it cost, beside the actions. + // A turn with no tool work: how long it took and what it cost are the time's tooltip, after the buttons. user("Is the nightly feed current?", 20), assistant("Yes. The newest NEXT nightly is 12 minutes old and matches the branch tip.", 19.9), summary(19.9, { @@ -66,6 +67,8 @@ const rows = [ ]; const base = chat(rows, { title: "Release check", isWorking: true }); +/// The first two turns alone, finished: the chat's last footer is the second turn's. +const finished = chat(rows.slice(0, 7), { title: "Release check", isWorking: false }); const snapshot = { ...base, sessions: [ @@ -80,10 +83,16 @@ const snapshot = { ], }; -/// The pointer on the first turn's footer, so its action row shows beside its time and tokens. +/// The pointer on the first turn's footer, so its action row and its time show. const revealFooter: Play = async (ctx) => { - await ctx.waitFor(() => ctx.document.querySelector(".acpmux-turnSummary .cv-turn-summary") !== null); - await ctx.hover({ selector: ".acpmux-turnSummary .cv-turn-summary" }); + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-turnSummary .cv-turn-action-group") !== null); + await ctx.hover({ selector: ".acpmux-turnSummary .cv-turn-action-group" }); +}; + +/// The pointer on the finished chat's last reply, so its time shows after the buttons. +const revealLastFooter: Play = async (ctx) => { + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-row--final-footer .cv-turn-action-group") !== null); + await ctx.hover({ selector: ".acpmux-row--final-footer .cv-turn-action-group" }); }; export default agentPaneEntry({ @@ -114,9 +123,18 @@ export default agentPaneEntry({ documentAttributes: { "transcript-status-gap": "off" }, }, footer: { - note: "The pointer on the first turn's footer: the copy glyph starts on the reply's left edge, as Codex and Claude draw it, with Worked for after the buttons.", + note: "The pointer on the first turn's footer: the copy glyph's ink starts on the reply's left edge, as ChatGPT, Codex and Claude draw it; the exact time shows after the buttons, and its tooltip has the turn's time and tokens.", snapshot, play: revealFooter, }, + finished: { + note: "A finished chat at rest: its last reply's buttons are in sight with no pointer, the first glyph on the reply's left edge; the time waits for the pointer.", + snapshot: finished, + }, + "finished-hover": { + note: "The pointer on the finished chat's last reply: its exact time shows after the buttons, which do not move.", + snapshot: finished, + play: revealLastFooter, + }, }, }); diff --git a/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx b/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx index a559e8cc2186..71e3ad29ef63 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx @@ -30,20 +30,29 @@ test("a turn under a second says no Worked for", () => { } }); -test("a 5 s turn still says Worked for 5s", () => { - const row = footer({ durationMs: 5_000 }); +test("a 5 s turn says Worked for 5s in its time's tooltip, never in front of the buttons", () => { + // Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons. those buttons + // should always just be there left aligned". + const row = footer({ + durationMs: 5_000, + text: "Done.", + tokens: { output: 359, input: 1_000, cacheRead: 327_800, cacheWrite: 0 }, + }); expect(footerLabel(t, row)).toBe("Worked for 5s"); - expect(renderToStaticMarkup()).toContain("Worked for 5s"); + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("cv-turn-summary"); + expect(markup).toMatch(//); + expect(markup).toMatch(/ - {!messages && label && ( - - {label} - {hasTokens(row.tokens) && ( - <> - - - - )} - - )} {resumable && ( )} - {!messages && } + {!messages && } diff --git a/webviews/src/agent-session/acpmux/conversation/TurnTokens.tsx b/webviews/src/agent-session/acpmux/conversation/TurnTokens.tsx index 7a62c2c11d45..9ef476900510 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnTokens.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnTokens.tsx @@ -37,6 +37,12 @@ export const tokenDetail = (t: Translate, language: PaneLanguage, split: TurnTok cacheWrite: compactTokens(language, split.cacheWrite), }); +/// The split as plain words, "↓ 12.4K out ↑ 3.9M in", for a tooltip (TurnRows.tsx TurnFooter). +export const tokensText = (t: Translate, language: PaneLanguage, split: TurnTokenSplit) => + `${t("turn.tokens.out", { tokens: compactTokens(language, split.output) })} ${t("turn.tokens.in", { + tokens: compactTokens(language, readTokens(split)), + })}`; + /// The tokens' span (no separator before it). `split` wins over `total` when both are known. export function TurnTokens({ total, split }: { total?: number; split?: TurnTokenSplit }) { const t = useT(); diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 1bdbee085d96..5a39b6cbd9fa 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -742,7 +742,8 @@ .cv-received-actions, .cv-received__hover, .cv-reply-actions, - .acpmux-turnSummary .cv-turn-actions { + .acpmux-turnSummary .cv-turn-actions, + .cv-turn-actions .cv-turn-time { transition: opacity var(--cv-actions-out) var(--cv-actions-ease); } .cv-user:hover .cv-user__actions, @@ -754,7 +755,10 @@ .acpmux-assistant:has([data-popup-open]) .cv-reply-actions, .acpmux-turnSummary[data-turn-revealed] .cv-turn-actions, .acpmux-turnSummary:hover .cv-turn-actions, - .acpmux-turnSummary:has([data-popup-open]) .cv-turn-actions { + .acpmux-turnSummary:has([data-popup-open]) .cv-turn-actions, + .acpmux-turnSummary[data-turn-revealed] .cv-turn-time, + .acpmux-turnSummary:hover .cv-turn-time, + .acpmux-assistant:hover .cv-reply-actions .cv-turn-time { transition: opacity var(--cv-actions-in) var(--cv-actions-ease) var(--cv-actions-in-delay); } } @@ -3646,7 +3650,7 @@ a.cv-sha:focus-visible { font-size: var(--text-body); letter-spacing: var(--text-body--letter-spacing); } -/* An empty footer (turns.ts isEmptyFooter: no label, no answer to copy, only Pin, Fork and the age, +/* An empty footer (turns.ts isEmptyFooter: no answer to copy, only Pin, Fork and the time, all hover-only) takes no line: the row is its turn gap alone, and pointed at, the controls show over that gap (at least the meta line's height), so nothing moves (Leo, 2026-10-21: a "Worked for 0s" row took a full line). The chat's last footer keeps that same gap, so its hidden controls never add a @@ -3674,9 +3678,9 @@ a.cv-sha:focus-visible { } /* The row's first glyph starts where the reply's text does, as Codex's and Claude's copy glyph does (Leo, 2026-10-30: "WE NEED TO LINE OUR SHIT UP LIKE THEIRS"; design/grid.md 5.5, the action row's - first glyph on col-left): its 24px button overhangs the column by the glyph's inset, 4px. The - buttons always lead the row; "Worked for 5s" and Continue come after them (Leo, 2026-11-02: - "those buttons should always just be there left aligned"). */ + first glyph on col-left): its button overhangs the column by the glyph's inset, and the row itself + starts on the column (inset-inline below), so the ink lands on it exactly. Nothing comes before the + buttons (Leo, 2026-11-02: "those buttons should always just be there left aligned"). */ .cv-reply-actions > .cv-turn-action-group:first-child > .cv-iconbtn:first-child, .cv-turn-actions .cv-turn-footer-row > .cv-turn-action-group:first-child > .cv-iconbtn:first-child { margin-inline-start: calc((var(--cv-glyph-slot) - var(--cv-meta-row)) / 2); @@ -3778,7 +3782,6 @@ a.cv-sha:focus-visible { outline: 1.5px solid color-mix(in srgb, var(--agent-accent) 55%, transparent); outline-offset: 1px; } -.cv-turn-summary, .cv-turn-note, .cv-turn-time { color: var(--cv-text-dim); @@ -3800,12 +3803,29 @@ a.cv-sha:focus-visible { .acpmux-row--final-footer .cv-turn-actions { opacity: 1; } +/* A reply's time comes after its buttons, hidden at rest, and shows while the reader points at the + reply, as ChatGPT's exact time does (MessageActions.tsx ReplyTime; Leo, 2026-11-02: "notice how on + hover it reveals the exact timestamp"). Its space is kept, so the buttons never move. */ +.cv-turn-actions .cv-turn-time { + opacity: 0; +} +.acpmux-turnSummary[data-turn-revealed] .cv-turn-time, +.acpmux-turnSummary:hover .cv-turn-time, +.acpmux-turnSummary:focus-visible .cv-turn-time, +.acpmux-turnSummary .cv-turn-actions:focus-within .cv-turn-time, +.acpmux-assistant:hover .cv-reply-actions .cv-turn-time, +.acpmux-assistant:focus-visible .cv-reply-actions .cv-turn-time, +.cv-reply-actions:focus-within .cv-turn-time { + opacity: 1; +} /* A reply's own row (MessageActions.tsx ReplyActions: a steer, another agent's message or a tool row follows the reply): the footer's line under it at the meta gap, its space kept, shown while the reader points at the reply or a button in it has keyboard focus. */ .cv-reply-actions { position: absolute; - inset-inline: calc(-1 * var(--cv-action-overhang)); + /* Its left edge on the prose's: the first button's own margin (above) puts that glyph's ink on + col-left. Only the end overhangs, so the last control's box can end past the column. */ + inset-inline: 0 calc(-1 * var(--cv-action-overhang)); top: calc(100% + (var(--grid-gap-block) - var(--cv-meta-row)) / 2); margin: 0; opacity: 0; @@ -3826,7 +3846,6 @@ a.cv-sha:focus-visible { color: var(--agent-accent, var(--cv-text)); background: color-mix(in srgb, var(--agent-accent, currentColor) 14%, transparent); } -.cv-turn-summary, .cv-turn-time { flex: none; white-space: nowrap; @@ -5194,7 +5213,9 @@ article.acpmux-row:not(:has(> :not(.acpmux-row__gap))) { } .acpmux-turnSummary:not(:has(.cv-turn-actions--failed, .cv-turn-actions--resumable)) .cv-turn-actions { position: absolute; - inset-inline: calc(-1 * var(--cv-action-overhang)); + /* Starts on the prose's left edge, as .cv-reply-actions: the first button's margin alone moves its + glyph's ink onto col-left (the row's overhang as well put it 6px left of the prose). */ + inset-inline: 0 calc(-1 * var(--cv-action-overhang)); top: calc((var(--grid-gap-block) - var(--cv-meta-row)) / 2); } /* The last footer's gap to the composer is the block gap too (transcriptGrid.ts tailGap), so its diff --git a/webviews/src/agent-session/acpmux/conversation/relativeTime.ts b/webviews/src/agent-session/acpmux/conversation/relativeTime.ts index 66185f987e46..f3e627985d98 100644 --- a/webviews/src/agent-session/acpmux/conversation/relativeTime.ts +++ b/webviews/src/agent-session/acpmux/conversation/relativeTime.ts @@ -50,3 +50,15 @@ export function exactTime(at: number, locale?: string): string { return new Date(at).toLocaleString(undefined, EXACT); } } + +/// A reply's time in its action row, as ChatGPT writes it there on hover: "Oct 18, 5:41 AM", the +/// year added only when it is not `now`'s ("Oct 18, 2025, 5:41 AM"), in `locale`'s order and words. +export function replyTime(at: number, now: number, locale?: string): string { + const sameYear = new Date(at).getFullYear() === new Date(now).getFullYear(); + const fields: Intl.DateTimeFormatOptions = sameYear ? { ...EXACT, year: undefined } : EXACT; + try { + return new Date(at).toLocaleString(locale, fields); + } catch { + return new Date(at).toLocaleString(undefined, fields); + } +} diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index 8b100d59f843..40fa7694500f 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -100,18 +100,14 @@ const footerSays = (row: AcpmuxRow) => !row.folded && (row.previous !== undefined || row.durationMs === undefined || row.durationMs >= 1000 || row.status === "cancelled"); -/// A footer with nothing to show at rest and only hover controls (Pin, Fork, its age): no label, no -/// answer to copy or read aloud, the turn did not fail, and it offers no Continue (a stopped last -/// turn whose work folds draws its line for Continue alone). It takes no line in the transcript +/// A footer with nothing to show at rest and only hover controls (Pin, Fork, its time): no answer to +/// copy or read aloud, the turn did not fail, and it offers no Continue (a stopped last turn whose +/// work folds draws its line for Continue alone). Its "Worked for" label is the time's tooltip now, +/// never a word in the row (TurnRows.tsx TurnFooter), so it no longer keeps the line. It takes no line in the transcript /// (conversation.css `.cv-turn-actions--empty`, model.ts fallbackRowHeight); pointed at, its controls /// show over the turn gap below, so nothing moves. export const isEmptyFooter = (row: AcpmuxRow) => - row.kind === "turnSummary" && - row.status !== "failed" && - row.status !== "error" && - !row.text && - !row.resumable && - !footerSays(row); + row.kind === "turnSummary" && row.status !== "failed" && row.status !== "error" && !row.text && !row.resumable; const isEditTool = (tool: { kind?: string } | undefined) => tool?.kind === "edit" || tool?.kind === "fileChange"; const isEdit = (row: AcpmuxRow) => row.kind === "activity" && (row.items ?? []).some((item) => isEditTool(item.tool)); diff --git a/webviews/test/reply-footer.test.ts b/webviews/test/reply-footer.test.ts new file mode 100644 index 000000000000..f03437ae7281 --- /dev/null +++ b/webviews/test/reply-footer.test.ts @@ -0,0 +1,192 @@ +// A reply's footer: its buttons first, on the prose's left edge, and its time after them, shown on +// hover; the finished chat's last reply keeps its buttons in sight at rest. +// +// Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons. those buttons +// should always just be there left aligned"; "i should be able to see the copy buttons on your +// responses when your things end"; and on ChatGPT's row: "notice how on hover it reveals the exact +// timestamp. let's follow those." +// +// In gallery entry agent-pane.transcript-row-alignment, light and dark: +// - `footer` (the pointer on the first turn's footer): every footer's first element is its button +// group, and its first glyph's box (the 16px icon box, design/grid.md 5.5) starts on the prose's +// left edge, for a turn whose time and tokens are known (they go in the time's tooltip) and for +// one without them; +// - `finished` (a finished chat, no pointer): the last reply's buttons show at rest, its time is +// hidden; pointed at, the time shows after the last button and the buttons do not move. +// +// CMUX_REPLY_FOOTER_ARTIFACT_DIR=DIR (or fast-checks' FAST_CHECKS_OUT) also writes 2x stills of the +// last reply and its footer, at rest and pointed at, in each theme. +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("reply-footer.test.ts", async () => { + setDefaultTimeout(240_000); + const ENTRY = "agent-pane.transcript-row-alignment"; + const THEMES = [ + { name: "Apple System Colors Light", file: "light" }, + { name: "Catppuccin Mocha", file: "dark" }, + ]; + /** How far apart two edges may be, in CSS px. */ + const TOLERANCE = 0.5; + + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + describe.skip("reply footer", () => test("browser", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + let vite: Awaited> | undefined; + let browser: Browser; + + beforeAll(async () => { + vite = await createServer({ + configFile: path.join(webviews, "vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + await warmGalleryFrame(vite, ENTRY, { browser, params: { variant: "finished" } }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + const open = async (variant: string, theme: string): Promise => { + const page = await browser.newPage({ viewport: { width: 900, height: 1200 }, deviceScaleFactor: 2 }); + const port = (vite!.httpServer!.address() as { port: number }).port; + await page.goto( + `http://127.0.0.1:${port}${galleryFrame(ENTRY, { variant, width: 720, theme, reducedMotion: "true" })}`, + ); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1", undefined, { + timeout: 120_000, + }); + await page.evaluate(() => document.fonts.ready); + return page; + }; + + const artifacts = process.env.CMUX_REPLY_FOOTER_ARTIFACT_DIR ?? process.env.FAST_CHECKS_OUT; + /** A 2x still of the chat's last reply and its footer, with room around them. */ + const still = async (page: Page, name: string) => { + if (!artifacts) return; + const box = await page.evaluate(() => { + const footer = document.querySelector(".acpmux-row--final-footer")!.getBoundingClientRect(); + const replies = [...document.querySelectorAll(".acpmux-row.acpmux-assistant")]; + const reply = replies.at(-1)!.getBoundingClientRect(); + const top = Math.max(0, reply.top - 24); + return { x: Math.max(0, reply.left - 24), y: top, width: reply.width + 48, height: footer.bottom + 32 - top }; + }); + await mkdir(artifacts, { recursive: true }); + await page.screenshot({ path: path.join(artifacts, `reply-footer-${name}.png`), clip: box }); + }; + + type Footer = { + first: string; + glyph: number | undefined; + detail: string | null; + buttons: number[]; + lastButtonRight: number; + actionsOpacity: number; + time: { left: number; opacity: number } | undefined; + }; + + /** Each turn footer's row, measured from its transcript row's left edge (the prose's). */ + const footers = (page: Page, scope = ".acpmux-turnSummary"): Promise => + page.evaluate((scope) => { + const found: Footer[] = []; + for (const row of document.querySelectorAll(scope)) { + const actions = row.querySelector(".cv-turn-actions:not(.cv-turn-actions--empty)"); + const line = actions?.querySelector(".cv-turn-footer-row"); + if (!actions || !line) continue; + const left = row.getBoundingClientRect().left; + const group = line.querySelector(".cv-turn-action-group"); + const glyph = group?.querySelector(".cv-iconbtn svg"); + const buttons = [...(group?.querySelectorAll(".cv-iconbtn") ?? [])]; + const time = line.querySelector(".cv-turn-time"); + found.push({ + first: line.firstElementChild?.className ?? "", + glyph: glyph ? glyph.getBoundingClientRect().left - left : undefined, + detail: time?.getAttribute("title") ?? null, + buttons: buttons.map((button) => button.getBoundingClientRect().left - left), + lastButtonRight: buttons.length ? buttons.at(-1)!.getBoundingClientRect().right - left : 0, + actionsOpacity: Number(getComputedStyle(actions).opacity), + time: time + ? { left: time.getBoundingClientRect().left - left, opacity: Number(getComputedStyle(time).opacity) } + : undefined, + }); + } + return found; + }, scope); + + for (const theme of THEMES) { + test(`a footer's buttons come first, the first glyph on the prose's left edge (${theme.name})`, async () => { + const page = await open("footer", theme.name); + try { + await page.locator(".acpmux-turnSummary .cv-turn-action-group").first().waitFor(); + const measured = await footers(page); + console.log(`REPLY_FOOTER ${theme.file} footer ${JSON.stringify(measured)}`); + // The first turn's footer knows its time and tokens; the folded turn's footer does not. + const withStats = measured.filter((footer) => footer.detail?.includes("Worked for")); + const without = measured.filter((footer) => !footer.detail); + expect(withStats.length, "a footer with the turn's time and tokens").toBeGreaterThan(0); + expect(without.length, "a footer without them").toBeGreaterThan(0); + for (const footer of withStats) expect(footer.detail).toMatch(/Worked for 5s · ↓ .+ out ↑ .+ in/); + for (const footer of measured) { + expect(footer.first).toContain("cv-turn-action-group"); + expect(footer.glyph).toBeDefined(); + expect( + Math.abs(footer.glyph!), + `first glyph at ${footer.glyph}px, not on the prose's edge`, + ).toBeLessThanOrEqual(TOLERANCE); + // The time comes after the last button. + expect(footer.time!.left).toBeGreaterThanOrEqual(footer.lastButtonRight - TOLERANCE); + } + expect(await page.locator(".cv-turn-summary").count()).toBe(0); + } finally { + await page.close(); + } + }); + + test(`a finished chat's last reply shows its buttons at rest and its time on hover (${theme.name})`, async () => { + const page = await open("finished", theme.name); + try { + const last = ".acpmux-turnSummary.acpmux-row--final-footer"; + await page.locator(`${last} .cv-turn-action-group`).waitFor(); + await page.mouse.move(1, 1); + const [rest] = await footers(page, last); + console.log(`REPLY_FOOTER ${theme.file} rest ${JSON.stringify(rest)}`); + await still(page, `${theme.file}-rest`); + expect(rest, "the chat's last footer").toBeDefined(); + expect(rest!.buttons.length).toBeGreaterThan(0); + expect(rest!.actionsOpacity).toBe(1); + expect(rest!.time!.opacity).toBe(0); + expect(Math.abs(rest!.glyph!)).toBeLessThanOrEqual(TOLERANCE); + await page.hover(`${last} .cv-turn-action-group`); + await page.waitForFunction( + (selector) => getComputedStyle(document.querySelector(`${selector} .cv-turn-time`)!).opacity === "1", + last, + { timeout: 10_000 }, + ); + const [hover] = await footers(page, last); + console.log(`REPLY_FOOTER ${theme.file} hover ${JSON.stringify(hover)}`); + await still(page, `${theme.file}-hover`); + expect(hover!.time!.opacity).toBe(1); + expect(hover!.time!.left).toBeGreaterThanOrEqual(hover!.lastButtonRight - TOLERANCE); + expect(hover!.buttons).toEqual(rest!.buttons); + // Earlier footers stay hidden at rest: only the last reply's row is in sight. + await page.mouse.move(1, 1); + const earlier = await footers(page, ".acpmux-turnSummary:not(.acpmux-row--final-footer)"); + for (const footer of earlier) expect(footer.actionsOpacity).toBe(0); + } finally { + await page.close(); + } + }); + } +}); diff --git a/webviews/test/transcript-row-alignment.test.ts b/webviews/test/transcript-row-alignment.test.ts index b40150ca4958..704a332e70c5 100644 --- a/webviews/test/transcript-row-alignment.test.ts +++ b/webviews/test/transcript-row-alignment.test.ts @@ -7,10 +7,11 @@ // In gallery entry agent-pane.transcript-row-alignment (a reply, a tool call, a reply, a subagent // that finished, a reply, a background command that ended, a reply, another agent's message, a // reply; above them a folded -// turn's "Worked for" line and a footer with its time, tokens and actions), measured from the +// turn's "Worked for" line and a footer with its actions and time), measured from the // transcript column's left edge (`col-left`, design/grid.md 5.5) in light and dark: -// - every reply's first glyph, a "Worked for" line's first glyph, the footer's "Worked for" label -// and the first glyph of every action row (a reply's, a footer's) start on col-left; +// - every reply's first glyph, a "Worked for" line's first glyph and the first glyph of every action +// row (a reply's, a footer's) start on col-left; nothing comes before a footer's buttons +// (reply-footer.test.ts); // - every row with a leading icon (a tool row, a subagent's end, a background command's end, // another agent's message) has its 16px icon box on col-left and its words at one shared x; // - each status row between two replies has the same room above it as every other, and the same @@ -105,9 +106,7 @@ await requireBrowserLane("transcript-row-alignment.test.ts", async () => { }; for (const prose of document.querySelectorAll(".acpmux-row.acpmux-assistant .cv-md")) lead("reply", prose); for (const worked of document.querySelectorAll(".cv-worked.is-toggle")) lead("worked-for", worked); - for (const label of document.querySelectorAll(".cv-turn-summary")) lead("footer label", label); // An action row's first glyph: its 16px box, where Codex and Claude start the copy glyph. - // A footer that says its time first leads with that label instead (measured above). for (const actions of document.querySelectorAll(".cv-turn-actions .cv-turn-action-group")) { if (actions.previousElementSibling) continue; const glyph = actions.querySelector(".cv-iconbtn svg"); @@ -250,7 +249,6 @@ await requireBrowserLane("transcript-row-alignment.test.ts", async () => { for (const kind of [ "reply", "worked-for", - "footer label", "footer actions", "tool", "subagent finished",