From 619509228ae8472a18a1e3165f6491333129565f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 14:10:57 -0700 Subject: [PATCH 1/4] cmux NEXT agent chat: message hover rows match Claude's (just now, exact time above, 24px buttons, fade) The hover row under prompts and answers now follows Claude Code desktop's code (read from its bundle): "just now" under a minute and counts in weeks, months and years past a week instead of switching to a date; the time's tooltip reads "Oct 20, 2026, 10:16 PM" (year, short month, day, hour, 2-digit minute, in the pane's language) and shows above the time; icons are 16px in touching 24px buttons, the age in the body size; the row fades in after 100ms over 120ms and out over 60ms on Claude's snap curve. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- scripts/cmux-next/type-scale-baseline.tsv | 2 +- .../acpmux/conversation/MessageActions.tsx | 36 +++++---- .../acpmux/conversation/TurnRows.tsx | 4 +- .../acpmux/conversation/UserBubble.gallery.ts | 32 +++++++- .../acpmux/conversation/UserBubble.tsx | 4 +- .../acpmux/conversation/conversation.css | 61 +++++++++------ .../acpmux/conversation/relativeTime.ts | 61 +++++++++++---- webviews/src/ui/titleTooltips.test.ts | 13 +++- webviews/src/ui/titleTooltips.ts | 26 +++++-- webviews/test/user-message-actions.test.tsx | 74 +++++++++++++++---- 10 files changed, 232 insertions(+), 81 deletions(-) diff --git a/scripts/cmux-next/type-scale-baseline.tsv b/scripts/cmux-next/type-scale-baseline.tsv index 418be65f1bd6..17e30616e02c 100644 --- a/scripts/cmux-next/type-scale-baseline.tsv +++ b/scripts/cmux-next/type-scale-baseline.tsv @@ -6,7 +6,7 @@ webviews/src/agent-session/acpmux/checkpoints/styles.css size 2 webviews/src/agent-session/acpmux/chips/chips.css size 1 webviews/src/agent-session/acpmux/composerStates.css size 2 webviews/src/agent-session/acpmux/conversation/HighlightedCode.tsx size 1 -webviews/src/agent-session/acpmux/conversation/conversation.css size 34 +webviews/src/agent-session/acpmux/conversation/conversation.css size 33 webviews/src/agent-session/acpmux/diffTheme.ts size 3 webviews/src/agent-session/acpmux/handoff/styles.css size 4 webviews/src/agent-session/acpmux/keys.css size 1 diff --git a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx index da163698a31c..3730fefc11b4 100644 --- a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx +++ b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx @@ -1,8 +1,9 @@ // The buttons of a message's action row, shared by a prompt's (UserBubble.tsx, PromptActions for // the Chief's), another agent's message's (ReceivedActions) and an answer's (TurnRows.tsx TurnFooter), as Claude Code desktop -// draws them: the message's age ("8 minutes ago", its date and time on hover), a prompt's read -// receipt, Copy, Retry, Fork from here (its title and what it does), Pin and Read aloud. The rows -// show on hover or keyboard focus only (conversation.css). +// draws them: the message's age ("8 minutes ago"; "Oct 20, 2026, 10:16 PM" above it on hover), a +// prompt's read receipt, Copy, Retry, Fork from here (its title and what it does), Pin and Read +// aloud, each glyph ROW_ICON px in a 24 px button. The rows show on hover or keyboard focus only, +// with Claude's fade (conversation.css). import { useContext } from "react"; import { useMinuteClock } from "../clock"; import { usePaneLanguage, useT } from "../i18n"; @@ -10,24 +11,27 @@ import type { AcpmuxRow } from "../model"; import { useRowMark } from "../threadMinimap/promptMarks"; import { Check, DoubleCheck, Pin, Retry, Speaker, TurnFork } from "./icons"; import { canReadAloud, readAloud, useReadingAloud } from "./readAloud"; -import { relativeTime } from "./relativeTime"; +import { exactTime, relativeTime } from "./relativeTime"; import { CopyButton } from "./CopyButton"; import { RetryModelMenu } from "./RetryModelMenu"; import { TurnActionsContext } from "./turnActions"; -/// The message's age, relative and live; the exact date and time on hover. +/// A hover row's glyph size: Claude's 16 px icons, each in a 24 px (--cv-meta-row) button. +export const ROW_ICON = 16; + +/// The message's age, relative and live; the exact date and time above it on hover, as Claude's. export function MessageTime({ at, className }: { at: number; className: string }) { const now = useMinuteClock(); const language = usePaneLanguage(); - const exact = new Intl.DateTimeFormat(language, { - dateStyle: "medium", - timeStyle: "short", - }).format(at); return ( @@ -38,7 +42,7 @@ export function MessageTime({ at, className }: { at: number; className: string } /// row's compact size. export function MessageCopy({ text }: { text: string }) { const t = useT(); - return ; + return ; } /// Fork from here, with Claude's tooltip: the title, then that this chat stays as it is. @@ -53,7 +57,7 @@ export function ForkButton({ onFork }: { onFork(): void }) { data-tooltip-detail={t("turn.forkDetail")} onClick={onFork} > - + ); } @@ -74,7 +78,7 @@ export function PinButton({ rowId }: { rowId: string }) { title={label} onClick={mark.toggle} > - + ); } @@ -95,7 +99,7 @@ export function ReadAloudButton({ id, text }: { id: string; text: string }) { title={label} onClick={() => readAloud(id, text, language)} > - + ); } @@ -126,7 +130,7 @@ export function ReadReceipt({ row }: { row: AcpmuxRow }) { title={label} data-read={read ? "true" : "false"} > - {read ? : } + {read ? : } ); } @@ -147,7 +151,7 @@ export function PromptRetry({ rowId, prompt }: { rowId: string; prompt: string } title={t("turn.retryLabel")} onClick={() => retry(rowId, prompt)} > - + {retryWith && retryWith.models.length > 1 && ( retryWith.retry(rowId, prompt, model)} /> diff --git a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx index 7bcfc057f27e..ef92d35c6db7 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx @@ -18,7 +18,7 @@ import { isFoldedRun } from "./toolRunSummary"; import { TurnActionsContext } from "./turnActions"; import { workedLabel } from "./turns"; import { ChevronRight, Retry } from "./icons"; -import { ForkButton, MessageCopy, MessageTime, PinButton, ReadAloudButton } from "./MessageActions"; +import { ForkButton, MessageCopy, MessageTime, PinButton, ReadAloudButton, ROW_ICON } from "./MessageActions"; import { RetryModelMenu } from "./RetryModelMenu"; import { useT } from "../i18n"; import { failureCopy, failureKind, type FailureRoute } from "../failureCopy"; @@ -184,7 +184,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) { title={t("turn.retryLabel")} onClick={() => retry(row.retryRowId ?? "", prompt)} > - + )} {failed && retry && prompt && retryWith && retryWith.models.length > 1 && ( diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts index 52f6a6806752..02d9d18487a2 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts @@ -1,7 +1,8 @@ // l10n-allow-file: gallery fixtures (sample prompts and replies), not shipped UI. // A prompt's bubble (UserBubble.tsx) and an answer's footer (TurnRows.tsx TurnFooter), with their // hover rows as Claude Code desktop's: under the prompt its age, read receipt, Copy, Edit, Retry, -// Fork and Pin; under the answer Copy, Fork, Pin, Read aloud and its age. Edit shows on a Claude +// Fork and Pin; under the answer Copy, Fork, Pin, Read aloud and its age, whose exact time shows +// above it on hover (the *-time-tooltip plays). Edit shows on a Claude // chat's prompts after its first turn, which acpmux can fork through (cx-ncc.60). The icon buttons // name themselves in a tooltip on hover (the *-tooltip plays). A long prompt opens on its first ten // lines, fading out, with Show more; a pasted block is a chip that opens in place (PromptText.tsx). @@ -55,6 +56,25 @@ const pastedPrompt = () => { ); }; +/// A chat from 15 hours ago: its rows read "15 hours ago", their exact time above it on hover. +const yesterday = () => + claude( + chat([ + user("Add retry with backoff to the uploader", 15 * 60 + 2, { seq: 1 }), + assistant("Done: three tries, doubling from 200 ms, only on 5xx and timeouts.", 15 * 60), + summary(15 * 60, { seq: 3, status: "completed" }), + ]), + ); + +/// Hovers the `selector` time and waits for its exact date and time to show above it. +const hoverTime = (selector: string) => async (ctx: PlayContext) => { + await ctx.waitFor(() => ctx.document.querySelector(selector)); + await ctx.hover({ selector }); + await ctx.waitFor(() => + /\d{4}, \d/.test(ctx.document.querySelector(".ui-title-tooltip:not([hidden])")?.textContent ?? ""), + ); +}; + /// Hovers the latest prompt's `label` button and waits for the pane's tooltip to name it. const hoverTooltip = (label: string) => async (ctx: PlayContext) => { const selector = `.cv-user__actions button[aria-label="${label}"]`; @@ -93,6 +113,16 @@ export default agentPaneEntry({ snapshot: turns(claude), play: async (ctx) => ctx.hover({ text: /uploader\.retry\.test\.ts/ }), }, + "prompt-time-tooltip": { + note: "Play: hover a prompt's age (15 hours ago); its exact date and time show above it (month, day, year, then the time).", + snapshot: yesterday(), + play: hoverTime("time.cv-user__time"), + }, + "answer-time-tooltip": { + note: "Play: hover an answer's age at the end of its row; its exact time shows above it.", + snapshot: yesterday(), + play: hoverTime("time.cv-turn-time"), + }, other: { note: "Another agent forks only at a chat's end: prompts offer Copy only.", snapshot: turns((snapshot) => snapshot), diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index 42a237d24b73..ffd825f24d58 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -21,7 +21,7 @@ import { DeliveryMark } from "./PromptDelivery"; import { ChevronLeft, ChevronRight, ImageIcon, Pencil } from "./icons"; import { ImageViewerContext } from "./imageViewerContext"; import { PromptText } from "./PromptText"; -import { ForkButton, MessageCopy, MessageTime, PinButton, PromptRetry, ReadReceipt } from "./MessageActions"; +import { ForkButton, MessageCopy, MessageTime, PinButton, PromptRetry, ReadReceipt, ROW_ICON } from "./MessageActions"; import { TurnActionsContext } from "./turnActions"; export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { @@ -107,7 +107,7 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { title={t("message.edit")} onClick={() => setEditing(true)} > - + )} diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index da2caa71d6a9..36d39608dda8 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -54,6 +54,12 @@ --cv-meta-row: 24px; /* the meta row's line */ --cv-message-gap: 8px; /* a message (or its meta row) to the next row in its turn */ --cv-turn-gap: 24px; /* a turn's footer to the next prompt */ + /* A message's hover row fades as Claude Code desktop's (--cds-message-actions-reveal-*): in after + 100ms over 120ms, out at once over 60ms, both on its snap curve. */ + --cv-actions-in: 120ms; + --cv-actions-in-delay: 100ms; + --cv-actions-out: 60ms; + --cv-actions-ease: cubic-bezier(0.32, 0.72, 0, 1); } /* A wider column on a wide pane (Debug Settings agent.chatWidth, chatWidth.ts, cx-ncc.107): `wide` caps everything at 960px; `wideBlocks` gives code, diffs, tables, tool output and cards 1120px while @@ -538,19 +544,34 @@ } /* A prompt's action row under its bubble's right edge (UserBubble.tsx), as Claude Code desktop's: shown while the pointer is over the prompt or a key reached it. Hidden, it keeps its one compact - line, so pointing at a prompt moves nothing. */ + line, so pointing at a prompt moves nothing. Its buttons touch, each a 24px square (Claude's). */ .cv-user__actions { display: flex; align-items: center; - gap: 2px; + gap: 0; height: var(--cv-meta-row); margin-top: var(--cv-meta-gap); padding-right: 2px; opacity: 0; } +/* Claude's fade: in after a beat when the pointer arrives, at once from the keyboard, out quickly. */ @media (prefers-reduced-motion: no-preference) { - .cv-user__actions { - transition: opacity 120ms ease-out; + .cv-user__actions, + .cv-received-actions, + .acpmux-turnSummary .cv-turn-actions { + transition: opacity var(--cv-actions-out) var(--cv-actions-ease); + } + .cv-user:hover .cv-user__actions, + .cv-received:hover .cv-received-actions, + .acpmux-turnSummary[data-turn-revealed] .cv-turn-actions, + .acpmux-turnSummary:hover .cv-turn-actions { + transition: opacity var(--cv-actions-in) var(--cv-actions-ease) var(--cv-actions-in-delay); + } + .cv-user__actions:focus-within, + .cv-received-actions:focus-within, + .acpmux-turnSummary:focus-visible .cv-turn-actions, + .acpmux-turnSummary .cv-turn-actions:focus-within { + transition: opacity var(--cv-actions-in) var(--cv-actions-ease); } } .cv-user:hover .cv-user__actions, @@ -558,21 +579,21 @@ .cv-user__actions--versions { opacity: 1; } +/* The rows' icon buttons are Claude's 24px squares (the meta row's line), not the 20px compact ones. */ +:is(.cv-user__actions, .cv-received-actions) .cv-iconbtn--compact:not(.cv-turn-retry-menu) { + width: var(--cv-meta-row); + height: var(--cv-meta-row); +} /* At the end of another agent's message line (MessageActions.tsx ReceivedActions): the same row, on hover or focus only, no box. */ .cv-received-actions { display: inline-flex; align-items: center; - gap: 2px; + gap: 0; margin-inline-start: 8px; white-space: nowrap; opacity: 0; } -@media (prefers-reduced-motion: no-preference) { - .cv-received-actions { - transition: opacity 120ms ease-out; - } -} .cv-received:hover .cv-received-actions, .cv-received-actions:focus-within { opacity: 1; @@ -585,17 +606,19 @@ display: inline-flex; align-items: center; } -/* The prompt's age and its read receipt lead the row, quiet, as Claude Code desktop's. */ +/* The prompt's age and its read receipt lead the row, quiet, as Claude Code desktop's: the age in + the body size, 8px before the receipt, a 16px double check right before Copy. */ .cv-user__time { - margin-inline-end: 4px; + padding-inline-end: 8px; color: var(--cv-text-dim); - font-size: var(--text-caption); + font-size: var(--text-body); + line-height: var(--cv-meta-row); font-variant-numeric: tabular-nums; white-space: nowrap; } .cv-user__read { display: inline-flex; - margin-inline-end: 4px; + align-items: center; color: var(--cv-text-dim); } .cv-user__read.is-unread { @@ -2211,7 +2234,7 @@ button.cv-received__name:focus-visible { padding-inline-start: 2px; color: var(--cv-text-muted); font-family: var(--cv-font); - font-size: 13px; + font-size: var(--text-body); } /* Debug Settings `agentPane.debug.turnActionsPlacement` "right": the earlier row at the column's right edge, the actions after the time. */ @@ -2227,10 +2250,11 @@ button.cv-received__name:focus-visible { .cv-turn-actions .cv-iconbtn:not(.cv-turn-retry-menu) { width: var(--cv-meta-row); } +/* Claude's buttons touch: 24px squares side by side, then its age 8px on (.cv-turn-actions' gap). */ .cv-turn-action-group { display: inline-flex; align-items: center; - gap: 2px; + gap: 0; } .cv-turn-action-group:empty { display: none; @@ -2300,11 +2324,6 @@ button.cv-received__name:focus-visible { .acpmux-turnSummary .cv-turn-actions--failed { opacity: 1; } -@media (prefers-reduced-motion: no-preference) { - .acpmux-turnSummary .cv-turn-actions { - transition: opacity 120ms ease-out; - } -} .cv-turn-summary, .cv-turn-time { flex: none; diff --git a/webviews/src/agent-session/acpmux/conversation/relativeTime.ts b/webviews/src/agent-session/acpmux/conversation/relativeTime.ts index 772e9ceb471b..c5447c3a6db0 100644 --- a/webviews/src/agent-session/acpmux/conversation/relativeTime.ts +++ b/webviews/src/agent-session/acpmux/conversation/relativeTime.ts @@ -1,20 +1,51 @@ -// A message's age as Claude Code desktop writes it under a prompt: "now", "5 minutes ago", -// "1 hour ago", "yesterday", then the date past a week. Intl words it in the pane's language. +// A message's age and its exact time, as Claude Code desktop writes them in a message's hover row: +// "just now" under a minute, then "5 minutes ago", "1 hour ago", "yesterday", "3 days ago", +// "last week", "2 months ago", "last year" (it never switches to a date there), and on hover +// "Oct 20, 2026, 10:16 PM". Intl words both in the pane's language. + +/// Claude's units, largest first, in seconds: the age reads in the largest one it has reached. +const UNITS: readonly (readonly [Intl.RelativeTimeFormatUnit, number])[] = [ + ["year", 31_536_000], + ["month", 2_592_000], + ["week", 604_800], + ["day", 86_400], + ["hour", 3_600], + ["minute", 60], +]; /// `at`'s age at `now`, in `locale`. export function relativeTime(at: number, now: number, locale?: string): string { // A time past `now` is a prompt the minute clock (clock.ts) has not caught up with, or another - // computer's clock running ahead: it reads "now". - const seconds = Math.max(0, now - at) / 1000; - const words = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - // Each unit rounds, and a count that rounds up to the next unit reads in it ("1 hour ago", never - // "60 minutes ago"). - const minutes = Math.round(seconds / 60); - if (minutes < 1) return words.format(0, "second"); - if (minutes < 60) return words.format(-minutes, "minute"); - const hours = Math.round(seconds / 3600); - if (hours < 24) return words.format(-hours, "hour"); - const days = Math.round(seconds / 86_400); - if (days < 7) return words.format(-days, "day"); - return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(at); + // computer's clock running ahead: it reads as just sent, never "in 1 minute". + const seconds = Math.round(Math.min(0, at - now) / 1000); + const age = -seconds; + if (age < 60) { + const words = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + // English says "just now" (Claude's words); other languages their own word for now. + return words.resolvedOptions().locale.toLowerCase().startsWith("en") ? "just now" : words.format(0, "second"); + } + const [unit, size] = UNITS.find(([, length]) => age >= length) ?? ["minute", 60]; + // The count rounds within its unit, as Claude's does (Math.round on the negative count: 90 + // minutes is "1 hour ago", 36 hours "yesterday"); "auto" says "yesterday", "last week". + return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(Math.round(seconds / size), unit); +} + +/// Claude's exact time for a message: "Oct 20, 2026, 10:16 PM" in en-US, the same fields in +/// `locale`'s order and words ("20. Okt. 2026, 22:16" in German). +const EXACT: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}; + +/// `at` as Claude's hover tooltip writes it, in `locale`. +export function exactTime(at: number, locale?: string): string { + try { + return new Date(at).toLocaleString(locale, EXACT); + } catch { + // A language tag Intl does not take: the system's. + return new Date(at).toLocaleString(undefined, EXACT); + } } diff --git a/webviews/src/ui/titleTooltips.test.ts b/webviews/src/ui/titleTooltips.test.ts index 270980bab084..8ac2f05e729a 100644 --- a/webviews/src/ui/titleTooltips.test.ts +++ b/webviews/src/ui/titleTooltips.test.ts @@ -5,7 +5,7 @@ import { afterEach, expect, jest, test } from "bun:test"; import { JSDOM } from "jsdom"; import { HoverGroup, HoverIntent } from "./hoverIntent"; -import { installTooltips, tooltipWidth } from "./titleTooltips"; +import { installTooltips, tooltipPosition, tooltipWidth } from "./titleTooltips"; test("a tooltip for truncated text wraps up to its source's width, never narrower than the line", () => { // Untruncated: the standard tooltip width. @@ -292,3 +292,14 @@ test("a tooltip near the right edge is measured at its full width, wherever the proto.getBoundingClientRect = original; } }); + +test("a top-side tooltip (a message's time) sits above its element, below only when there is no room above", () => { + const view = { width: 800, height: 600 }; + const size = { width: 140, height: 22 }; + const time = { left: 100, top: 300, width: 80, height: 24 }; + // Centered on the time, 6px above it (as Claude's), and below by default. + expect(tooltipPosition(time, size, view, "top")).toEqual({ left: 70, top: 272 }); + expect(tooltipPosition(time, size, view)).toEqual({ left: 70, top: 330 }); + // At the pane's top edge: below. + expect(tooltipPosition({ ...time, top: 10 }, size, view, "top")).toEqual({ left: 70, top: 40 }); +}); diff --git a/webviews/src/ui/titleTooltips.ts b/webviews/src/ui/titleTooltips.ts index bc80a5ae2acd..c5210f21a13a 100644 --- a/webviews/src/ui/titleTooltips.ts +++ b/webviews/src/ui/titleTooltips.ts @@ -6,6 +6,7 @@ // a control with no other name keeps it as its `aria-label`. Controls built on ./Tooltip keep theirs. // A `data-tooltip-shortcut` shows after the text in the secondary ink, like the native tooltip's; // a `data-tooltip-detail` on a line under it, also secondary (Fork from here, Claude's). +// A `data-tooltip-side="top"` puts it above the element rather than below (a message's time). // Text the element cuts off shows in full, as wide as the element (`tooltipWidth`). import { HOVER_INTENT, HoverGroup, HoverIntent, tooltipGroup, type HoverClock } from "./hoverIntent"; @@ -56,14 +57,25 @@ function clips(element: Element): boolean { } /// Where a `size` tooltip for `anchor` goes in a `view` window: below and centered, above when -/// there is no room below, and shifted to stay inside the edges. +/// there is no room below, and shifted to stay inside the edges. `side` "top" (an element's +/// `data-tooltip-side="top"`: a message's time, as Claude's) puts it above, below only when there +/// is no room above. export function tooltipPosition( anchor: Box, size: { width: number; height: number }, view: { width: number; height: number }, + side: "top" | "bottom" = "bottom", ) { const below = anchor.top + anchor.height + GAP; - const top = below + size.height + EDGE <= view.height ? below : Math.max(EDGE, anchor.top - GAP - size.height); + const above = anchor.top - GAP - size.height; + const top = + side === "top" + ? above >= EDGE || below + size.height + EDGE > view.height + ? Math.max(EDGE, above) + : below + : below + size.height + EDGE <= view.height + ? below + : Math.max(EDGE, above); const centered = anchor.left + anchor.width / 2 - size.width / 2; const left = Math.min(Math.max(EDGE, centered), Math.max(EDGE, view.width - EDGE - size.width)); return { left: Math.round(left), top: Math.round(top) }; @@ -179,10 +191,12 @@ export function installTooltips( }); tip.style.minWidth = `${width.min}px`; tip.style.maxWidth = `${width.max}px`; - const position = tooltipPosition(anchor, tip.getBoundingClientRect(), { - width: doc.documentElement.clientWidth, - height: doc.documentElement.clientHeight, - }); + const position = tooltipPosition( + anchor, + tip.getBoundingClientRect(), + { width: doc.documentElement.clientWidth, height: doc.documentElement.clientHeight }, + target.getAttribute("data-tooltip-side") === "top" ? "top" : "bottom", + ); tip.style.left = `${position.left}px`; tip.style.top = `${position.top}px`; if (fadeIn) { diff --git a/webviews/test/user-message-actions.test.tsx b/webviews/test/user-message-actions.test.tsx index fff592bc029f..0006436f60c5 100644 --- a/webviews/test/user-message-actions.test.tsx +++ b/webviews/test/user-message-actions.test.tsx @@ -88,12 +88,12 @@ test("a read prompt's row reads its age, Read by the agent, Copy, Edit and Fork, test("a prompt still being sent has no receipt, and one that cannot fork offers no Fork", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", pending: true, text: "Next" }, {}); - expect(labels()).toEqual(["now", "Copy"]); + expect(labels()).toEqual(["just now", "Copy"]); }); test("a steer the agent has not read yet shows one check: delivered when the step ends", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", steer: "waiting", text: "Also this" }, {}); - expect(labels()).toEqual(["now", "Delivered when the current step ends", "Copy"]); + expect(labels()).toEqual(["just now", "Delivered when the current step ends", "Copy"]); expect(host.querySelector(".cv-user__read")?.getAttribute("data-read")).toBe("false"); // No "Steering" / "Steered" line under the bubble any more. expect(host.textContent).not.toContain("Steer"); @@ -101,7 +101,7 @@ test("a steer the agent has not read yet shows one check: delivered when the ste test("a read steer reads Read by the agent when the chat names no agent", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", steer: "landed", text: "Also this" }, {}); - expect(labels()).toEqual(["now", "Read by the agent", "Copy"]); + expect(labels()).toEqual(["just now", "Read by the agent", "Copy"]); }); test("Retry under a prompt sends it again (shown on the last ended turn's prompt)", async () => { @@ -119,30 +119,72 @@ test("a steer this pane sent reads delivered while it waits for the agent, thoug const delivery = { state: "read", promptId: "p-1", sessionId: "s-1", sentAt: Date.now() } as const; const steer: AcpmuxRow = { id: "user-7", version: 1, at: Date.now(), kind: "user", seq: 7, text: "Rebase first" }; await draw({ ...steer, steer: "waiting", delivery }, {}); - expect(labels()).toEqual(["now", "Delivered when the current step ends", "Copy"]); + expect(labels()).toEqual(["just now", "Delivered when the current step ends", "Copy"]); await draw({ ...steer, steer: "landed", delivery }, {}); - expect(labels()).toEqual(["now", "Read by the agent", "Copy"]); + expect(labels()).toEqual(["just now", "Read by the agent", "Copy"]); }); test("a prompt this pane sent shows no receipt on its way, one check once delivered, two once read", async () => { const sent = { promptId: "p-2", sessionId: "s-1", sentAt: Date.now() }; const prompt: AcpmuxRow = { id: "user-8", version: 1, at: Date.now(), kind: "user", seq: 8, text: "Ship it" }; await draw({ ...prompt, delivery: { ...sent, state: "sending" } }, {}); - expect(labels()).toEqual(["now", "Copy"]); + expect(labels()).toEqual(["just now", "Copy"]); await draw({ ...prompt, delivery: { ...sent, state: "delivered" } }, {}); - expect(labels()).toEqual(["now", "Delivered", "Copy"]); + expect(labels()).toEqual(["just now", "Delivered", "Copy"]); await draw({ ...prompt, delivery: { ...sent, state: "read" } }, { route: { name: "Codex" } }); - expect(labels()).toEqual(["now", "Read by Codex", "Copy"]); + expect(labels()).toEqual(["just now", "Read by Codex", "Copy"]); }); -test("the relative time words a prompt's age in the pane's language", async () => { +test("the relative time words a message's age as Claude's hover row does, in the pane's language", async () => { const { relativeTime } = await import("../src/agent-session/acpmux/conversation/relativeTime"); - const now = Date.UTC(2026, 9, 20, 12); - expect(relativeTime(now - 20_000, now, "en")).toBe("now"); - expect(relativeTime(now - 5 * 60_000, now, "en")).toBe("5 minutes ago"); - expect(relativeTime(now - HOUR, now, "en")).toBe("1 hour ago"); - expect(relativeTime(now - 26 * HOUR, now, "en")).toBe("yesterday"); + const now = Date.UTC(2026, 9, 21, 13, 16); + const MINUTE = 60_000; + const DAY = 24 * HOUR; + expect(relativeTime(now, now, "en-US")).toBe("just now"); + expect(relativeTime(now - 59_000, now, "en-US")).toBe("just now"); + expect(relativeTime(now - MINUTE, now, "en-US")).toBe("1 minute ago"); + expect(relativeTime(now - 5 * MINUTE, now, "en-US")).toBe("5 minutes ago"); + // Each unit starts at its full length and rounds within it, as Claude's (Math.round of the + // negative count rounds a half toward now). + expect(relativeTime(now - 59 * MINUTE, now, "en-US")).toBe("59 minutes ago"); + expect(relativeTime(now - HOUR, now, "en-US")).toBe("1 hour ago"); + expect(relativeTime(now - 90 * MINUTE, now, "en-US")).toBe("1 hour ago"); + expect(relativeTime(now - 15 * HOUR, now, "en-US")).toBe("15 hours ago"); + expect(relativeTime(now - 23.6 * HOUR, now, "en-US")).toBe("24 hours ago"); + expect(relativeTime(now - DAY, now, "en-US")).toBe("yesterday"); + expect(relativeTime(now - 36 * HOUR, now, "en-US")).toBe("yesterday"); + expect(relativeTime(now - 3 * DAY, now, "en-US")).toBe("3 days ago"); + // Past a week it keeps counting; it never switches to a date. + expect(relativeTime(now - 7 * DAY, now, "en-US")).toBe("last week"); + expect(relativeTime(now - 15 * DAY, now, "en-US")).toBe("2 weeks ago"); + expect(relativeTime(now - 30 * DAY, now, "en-US")).toBe("last month"); + expect(relativeTime(now - 100 * DAY, now, "en-US")).toBe("3 months ago"); + expect(relativeTime(now - 400 * DAY, now, "en-US")).toBe("last year"); + // Ahead of the minute clock: still just now, never "in 1 minute". + expect(relativeTime(now + 30_000, now, "en-US")).toBe("just now"); + expect(relativeTime(now + 5 * MINUTE, now, "en-US")).toBe("just now"); + // Other languages: Intl's words, and their own word for now. expect(relativeTime(now - 3 * HOUR, now, "de")).toBe("vor 3 Stunden"); - // Ahead of the minute clock: still "now", never "in 1 minute". - expect(relativeTime(now + 30_000, now, "en")).toBe("now"); + expect(relativeTime(now - DAY, now, "de")).toBe("gestern"); + expect(relativeTime(now - 10_000, now, "de")).toBe("jetzt"); +}); + +test("the hover tooltip writes the exact time as Claude's: Oct 20, 2026, 10:16 PM", async () => { + const { exactTime } = await import("../src/agent-session/acpmux/conversation/relativeTime"); + // Built in local time, so the fields read back the same in any time zone. + const evening = new Date(2026, 9, 20, 22, 16).getTime(); + expect(exactTime(evening, "en-US")).toBe("Oct 20, 2026, 10:16\u202fPM"); + expect(exactTime(new Date(2026, 0, 5, 9, 3).getTime(), "en-US")).toBe("Jan 5, 2026, 9:03\u202fAM"); + // The same fields in the pane's language. + expect(exactTime(evening, "de")).toBe("20. Okt. 2026, 22:16"); + // A tag Intl refuses falls back to the system's language, never a throw. + expect(() => exactTime(evening, "not a language")).not.toThrow(); +}); + +test("the time under a prompt names its exact time, shown above it", async () => { + const at = new Date(2026, 9, 20, 22, 16).getTime(); + await draw({ id: "user-3", version: 1, at, kind: "user", seq: 3, text: "Ship it" }, {}); + const time = host.querySelector("time.cv-user__time")!; + expect(time.getAttribute("title")).toBe("Oct 20, 2026, 10:16\u202fPM"); + expect(time.getAttribute("data-tooltip-side")).toBe("top"); }); From 6e975dee9e3e72ffae2266fe61f4069e78018905 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 14:30:49 -0700 Subject: [PATCH 2/4] Prompt hover row: Claude's order first (Copy, Retry, Fork), then Edit and Pin; receipt in a 24px square; prompt age stays caption Claude's Code tab draws a prompt's row as age, read receipt, Copy, Rewind, Fork; cmux's Edit sat between Copy and Fork. It now follows Fork, with Pin. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- .../acpmux/conversation/UserBubble.gallery.ts | 4 ++-- .../acpmux/conversation/UserBubble.tsx | 11 ++++++----- .../acpmux/conversation/conversation.css | 10 +++++++--- webviews/test/user-message-actions.test.tsx | 19 +++++++++++-------- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts index 02d9d18487a2..4896f7146418 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts @@ -1,7 +1,7 @@ // l10n-allow-file: gallery fixtures (sample prompts and replies), not shipped UI. // A prompt's bubble (UserBubble.tsx) and an answer's footer (TurnRows.tsx TurnFooter), with their -// hover rows as Claude Code desktop's: under the prompt its age, read receipt, Copy, Edit, Retry, -// Fork and Pin; under the answer Copy, Fork, Pin, Read aloud and its age, whose exact time shows +// hover rows as Claude Code desktop's: under the prompt its age, read receipt, Copy, Retry, Fork, +// then cmux's Edit and Pin; under the answer Copy, Fork, Pin, Read aloud and its age, whose exact time shows // above it on hover (the *-time-tooltip plays). Edit shows on a Claude // chat's prompts after its first turn, which acpmux can fork through (cx-ncc.60). The icon buttons // name themselves in a tooltip on hover (the *-tooltip plays). A long prompt opens on its first ten diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index ffd825f24d58..e37f591fc238 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -1,7 +1,8 @@ // A prompt's bubble, with its action row under its right edge as Claude Code desktop draws one: -// the prompt's age ("8 minutes ago", its date and time on hover), its read receipt (one check once -// acpmux has it, two once the agent read it, "Read by Claude Code" on hover), then Copy, Edit, Retry, -// Fork from here and Pin (cx-ncc.60, Claude parity; Leo, GUI push 2026-10-20). The row shows on +// the prompt's age ("8 minutes ago", "Oct 20, 2026, 10:16 PM" above it on hover), its read receipt +// (one check once acpmux has it, two once the agent read it, "Read by Claude Code" on hover), then +// Copy, Retry and Fork from here in Claude's order, then cmux's own Edit and Pin, which Claude's +// Code tab has not got there (cx-ncc.60, Claude parity; Leo, GUI push 2026-10-20). The row shows on // hover or keyboard focus only, its height kept, so nothing moves when it appears. Edit opens the // prompt in place; Send forks the chat through the turn before it and sends the edited text there // (the App's `edit.send`), Escape or Cancel puts the bubble back. Edit shows only on a prompt the @@ -99,6 +100,8 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { + + {forkThrough !== undefined && fork!(forkThrough)} />} {through !== undefined && ( )} - - {forkThrough !== undefined && fork!(forkThrough)} />} )} diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 36d39608dda8..f5312fbbd8e6 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -607,11 +607,12 @@ align-items: center; } /* The prompt's age and its read receipt lead the row, quiet, as Claude Code desktop's: the age in - the body size, 8px before the receipt, a 16px double check right before Copy. */ + the caption size, 6px before the receipt, a 16px double check in a 24px square right before Copy. + (An answer's age is in the body size, 8px after its buttons: .cv-turn-time.) */ .cv-user__time { - padding-inline-end: 8px; + padding-inline-end: 6px; color: var(--cv-text-dim); - font-size: var(--text-body); + font-size: var(--text-caption); line-height: var(--cv-meta-row); font-variant-numeric: tabular-nums; white-space: nowrap; @@ -619,6 +620,9 @@ .cv-user__read { display: inline-flex; align-items: center; + justify-content: center; + width: var(--cv-meta-row); + height: var(--cv-meta-row); color: var(--cv-text-dim); } .cv-user__read.is-unread { diff --git a/webviews/test/user-message-actions.test.tsx b/webviews/test/user-message-actions.test.tsx index 0006436f60c5..97f006b02f46 100644 --- a/webviews/test/user-message-actions.test.tsx +++ b/webviews/test/user-message-actions.test.tsx @@ -1,6 +1,6 @@ // A prompt's action row matches Claude Code desktop's (GUI push 2026-10-20): under the bubble, its age -// ("1 hour ago"), its read receipt (one check delivered, two read, "Read by "), then Copy, Edit, -// Retry, Fork and Pin. Before, a prompt showed a lone bookmark there and a steer said "Steered · the +// ("1 hour ago"), its read receipt (one check delivered, two read, "Read by "), then Copy, +// Retry and Fork in Claude's order, then cmux's Edit and Pin. Before, a prompt showed a lone bookmark there and a steer said "Steered · the // agent read it" under it. The row shows on hover or focus only. import { afterAll, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; @@ -55,7 +55,7 @@ async function draw(row: AcpmuxRow, actions: object) { ); } -test("a read prompt's row reads its age, Read by the agent, Copy, Edit and Fork, in Claude's order", async () => { +test("a read prompt's row reads its age, Read by the agent, Copy and Fork in Claude's order, then Edit", async () => { const forked: number[] = []; const row: AcpmuxRow = { id: "user-4", @@ -73,7 +73,8 @@ test("a read prompt's row reads its age, Read by the agent, Copy, Edit and Fork, promptTurns: new Map([["user-4", 5]]), route: { name: "Claude Code", harness: "claude" }, }); - expect(labels()).toEqual(["1 hour ago", "Read by Claude Code", "Copy", "Edit", "Fork from here"]); + // Claude's row (age, receipt, Copy, Fork), then cmux's own Edit. + expect(labels()).toEqual(["1 hour ago", "Read by Claude Code", "Copy", "Fork from here", "Edit"]); expect(host.querySelector(".cv-user__read")?.getAttribute("data-read")).toBe("true"); expect(host.querySelector("time.cv-user__time")?.getAttribute("dateTime")).toBe(new Date(row.at).toISOString()); // The exact time on hover. @@ -170,11 +171,13 @@ test("the relative time words a message's age as Claude's hover row does, in the }); test("the hover tooltip writes the exact time as Claude's: Oct 20, 2026, 10:16 PM", async () => { - const { exactTime } = await import("../src/agent-session/acpmux/conversation/relativeTime"); + const { exactTime: exact } = await import("../src/agent-session/acpmux/conversation/relativeTime"); + // ICU puts a narrow no-break space before AM/PM; it reads as a space. + const exactTime = (at: number, locale?: string) => exact(at, locale).replace(/\s/g, " "); // Built in local time, so the fields read back the same in any time zone. const evening = new Date(2026, 9, 20, 22, 16).getTime(); - expect(exactTime(evening, "en-US")).toBe("Oct 20, 2026, 10:16\u202fPM"); - expect(exactTime(new Date(2026, 0, 5, 9, 3).getTime(), "en-US")).toBe("Jan 5, 2026, 9:03\u202fAM"); + expect(exactTime(evening, "en-US")).toBe("Oct 20, 2026, 10:16 PM"); + expect(exactTime(new Date(2026, 0, 5, 9, 3).getTime(), "en-US")).toBe("Jan 5, 2026, 9:03 AM"); // The same fields in the pane's language. expect(exactTime(evening, "de")).toBe("20. Okt. 2026, 22:16"); // A tag Intl refuses falls back to the system's language, never a throw. @@ -185,6 +188,6 @@ test("the time under a prompt names its exact time, shown above it", async () => const at = new Date(2026, 9, 20, 22, 16).getTime(); await draw({ id: "user-3", version: 1, at, kind: "user", seq: 3, text: "Ship it" }, {}); const time = host.querySelector("time.cv-user__time")!; - expect(time.getAttribute("title")).toBe("Oct 20, 2026, 10:16\u202fPM"); + expect(time.getAttribute("title")?.replace(/\s/g, " ")).toBe("Oct 20, 2026, 10:16 PM"); expect(time.getAttribute("data-tooltip-side")).toBe("top"); }); From b6d6a77c26c3d355c0608c85b1ddf260e611126b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 14:47:39 -0700 Subject: [PATCH 3/4] Prompt hover row is Leo's: read check, age, dot, Copy, Retry, Fork; no Pin Leo (2026-10-21): under a user message, left to right: the read check ("Read by "), the relative time (its absolute date on hover), a dot, Copy, Retry and Fork, each naming itself on hover; no Pin. Retry now shows under every prompt (it resends that prompt) and names itself "Retry" with "Send this prompt again" under it. Fork stays hidden where the harness cannot fork. cmux's Edit follows Fork where the chat can fork before the prompt. The Chief's prompt row matches. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- .../acpmux/conversation/MessageActions.tsx | 25 +++++-- .../acpmux/conversation/UserBubble.gallery.ts | 8 +-- .../acpmux/conversation/UserBubble.tsx | 27 +++---- .../acpmux/conversation/conversation.css | 18 +++-- webviews/test/thread-minimap-rail.test.tsx | 22 +++--- webviews/test/user-message-actions.test.tsx | 71 ++++++++++++++----- 6 files changed, 110 insertions(+), 61 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx index 3730fefc11b4..bd44cbd4f9ea 100644 --- a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx +++ b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx @@ -38,6 +38,15 @@ export function MessageTime({ at, className }: { at: number; className: string } ); } +/// The dot between a prompt's age and its buttons (Leo's spec for the row under a prompt). +export function MessageDot() { + return ( + + ); +} + /// Copy through CopyButton (its name as a tooltip, a check once the clipboard took the text), at the /// row's compact size. export function MessageCopy({ text }: { text: string }) { @@ -136,8 +145,9 @@ export function ReadReceipt({ row }: { row: AcpmuxRow }) { } /// Retry, and Retry with another model when the harness has several: sends prompt row `rowId` -/// (`prompt`, as the agent got it) again. It shows on the last ended turn's prompt only -/// (VirtualTranscript marks that row `data-retryable`; conversation.css). +/// (`prompt`, as the agent got it) again. Under a prompt it shows on every one, as Claude's; at the +/// end of another agent's message only on the last ended turn's (VirtualTranscript marks that row +/// `data-retryable`; conversation.css). export function PromptRetry({ rowId, prompt }: { rowId: string; prompt: string }) { const t = useT(); const { retry, retryWith } = useContext(TurnActionsContext); @@ -148,7 +158,8 @@ export function PromptRetry({ rowId, prompt }: { rowId: string; prompt: string } type="button" className="cv-iconbtn cv-iconbtn--compact" aria-label={t("turn.retry")} - title={t("turn.retryLabel")} + title={t("turn.retry")} + data-tooltip-detail={t("turn.retryLabel")} onClick={() => retry(rowId, prompt)} > @@ -160,16 +171,16 @@ export function PromptRetry({ rowId, prompt }: { rowId: string; prompt: string } ); } -/// The row under a prompt the Chief sent (ChiefPromptBubble): its age, its read receipt, Copy, -/// Retry and Pin, as under the person's own bubble. +/// The row under a prompt the Chief sent (ChiefPromptBubble): its read receipt, its age, a dot, +/// Copy and Retry, as under the person's own bubble. export function PromptActions({ row, text }: { row: AcpmuxRow; text: string }) { return (
- + + -
); } diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts index 4896f7146418..3e4d5b70f84a 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts @@ -1,8 +1,8 @@ // l10n-allow-file: gallery fixtures (sample prompts and replies), not shipped UI. // A prompt's bubble (UserBubble.tsx) and an answer's footer (TurnRows.tsx TurnFooter), with their -// hover rows as Claude Code desktop's: under the prompt its age, read receipt, Copy, Retry, Fork, -// then cmux's Edit and Pin; under the answer Copy, Fork, Pin, Read aloud and its age, whose exact time shows -// above it on hover (the *-time-tooltip plays). Edit shows on a Claude +// hover rows: under the prompt its read receipt, its age, a dot, Copy, Retry and Fork (Leo's spec), +// then cmux's Edit; under the answer Claude's Copy, Fork, Pin, Read aloud and its age. Each age shows +// its exact date and time above it on hover (the *-time-tooltip plays). Edit shows on a Claude // chat's prompts after its first turn, which acpmux can fork through (cx-ncc.60). The icon buttons // name themselves in a tooltip on hover (the *-tooltip plays). A long prompt opens on its first ten // lines, fading out, with Show more; a pasted block is a chip that opens in place (PromptText.tsx). @@ -104,7 +104,7 @@ export default agentPaneEntry({ snapshot: turns(claude), }, "prompt-hover": { - note: "Hovering the latest prompt shows its row under the bubble's right edge; nothing else shows.", + note: "Hovering the latest prompt shows its row under the bubble's right edge: read check, age, dot, Copy, Retry, Fork, Edit.", snapshot: turns(claude), play: async (ctx) => ctx.hover({ text: "Now cover it with a test that fakes two timeouts" }), }, diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index e37f591fc238..806e8a9650d1 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -1,13 +1,14 @@ -// A prompt's bubble, with its action row under its right edge as Claude Code desktop draws one: -// the prompt's age ("8 minutes ago", "Oct 20, 2026, 10:16 PM" above it on hover), its read receipt -// (one check once acpmux has it, two once the agent read it, "Read by Claude Code" on hover), then -// Copy, Retry and Fork from here in Claude's order, then cmux's own Edit and Pin, which Claude's -// Code tab has not got there (cx-ncc.60, Claude parity; Leo, GUI push 2026-10-20). The row shows on -// hover or keyboard focus only, its height kept, so nothing moves when it appears. Edit opens the -// prompt in place; Send forks the chat through the turn before it and sends the edited text there -// (the App's `edit.send`), Escape or Cancel puts the bubble back. Edit shows only on a prompt the -// chat can fork before (`editableTurns`), so it is never dead. Retry sends the prompt again; it shows -// on the prompt of the last ended turn only (MessageActions.tsx PromptRetry). A prompt +// A prompt's bubble, with its action row under its right edge (Leo's spec, after Claude Code +// desktop's): the read receipt (one check once acpmux has it, two once the agent read it, "Read by +// Claude Code" on hover), the prompt's age ("3 days ago"; "Oct 18, 2026, 10:16 PM" above it on +// hover), a dot, then Copy, Retry and Fork from here, each naming itself on hover, then cmux's Edit +// where the chat can fork before the prompt. No Pin here: an answer's row has it. Fork hides where +// the harness cannot fork there (cx-ncc.60, Claude parity; Leo, GUI push 2026-10-20/21). The row +// shows on hover or keyboard focus only, its height kept, so nothing moves when it appears. Edit +// opens the prompt in place; Send forks the chat through the turn before it and sends the edited +// text there (the App's `edit.send`), Escape or Cancel puts the bubble back. Edit shows only on a +// prompt the chat can fork before (`editableTurns`), so it is never dead. Retry sends the prompt +// again (MessageActions.tsx PromptRetry). A prompt // with versions (an edit made it, or replaced it) shows < n/m > before them, always (branches.ts, // cx-ncc.61). Fork forks the chat through the prompt's own turn, as its right-click Fork does. The // images a prompt sent show above its bubble, each opening the image viewer (Leo, 2026-10-13: they @@ -22,7 +23,7 @@ import { DeliveryMark } from "./PromptDelivery"; import { ChevronLeft, ChevronRight, ImageIcon, Pencil } from "./icons"; import { ImageViewerContext } from "./imageViewerContext"; import { PromptText } from "./PromptText"; -import { ForkButton, MessageCopy, MessageTime, PinButton, PromptRetry, ReadReceipt, ROW_ICON } from "./MessageActions"; +import { ForkButton, MessageCopy, MessageDot, MessageTime, PromptRetry, ReadReceipt, ROW_ICON } from "./MessageActions"; import { TurnActionsContext } from "./turnActions"; export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { @@ -97,8 +98,9 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { )} - + + {forkThrough !== undefined && fork!(forkThrough)} />} @@ -113,7 +115,6 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { )} - )} diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index f5312fbbd8e6..1dff905ff6f4 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -598,25 +598,33 @@ .cv-received-actions:focus-within { opacity: 1; } -/* Retry sends the last ended turn's prompt again (VirtualTranscript marks its row; MessageActions.tsx). */ +/* Retry sends a prompt again: under every prompt (Leo's spec, as Claude's), at the end of another + agent's message only on the last ended turn's (VirtualTranscript marks its row; MessageActions.tsx). */ .cv-message-retry { display: none; } +.cv-user__actions .cv-message-retry, .acpmux-row[data-retryable] .cv-message-retry { display: inline-flex; align-items: center; } -/* The prompt's age and its read receipt lead the row, quiet, as Claude Code desktop's: the age in - the caption size, 6px before the receipt, a 16px double check in a 24px square right before Copy. - (An answer's age is in the body size, 8px after its buttons: .cv-turn-time.) */ +/* The prompt's read receipt and its age lead the row, quiet (Leo's spec): a 16px double check in a + 24px square, the age in the caption size, a dot, then the buttons. (An answer's age is in the + body size, 8px after its buttons: .cv-turn-time.) */ .cv-user__time { - padding-inline-end: 6px; + padding-inline-start: 2px; color: var(--cv-text-dim); font-size: var(--text-caption); line-height: var(--cv-meta-row); font-variant-numeric: tabular-nums; white-space: nowrap; } +.cv-user__dot { + padding-inline: 4px; + color: var(--cv-text-dim); + font-size: var(--text-caption); + line-height: var(--cv-meta-row); +} .cv-user__read { display: inline-flex; align-items: center; diff --git a/webviews/test/thread-minimap-rail.test.tsx b/webviews/test/thread-minimap-rail.test.tsx index b1a097ca207d..9c4bf18baa4b 100644 --- a/webviews/test/thread-minimap-rail.test.tsx +++ b/webviews/test/thread-minimap-rail.test.tsx @@ -358,31 +358,27 @@ test("a press released off the rail ends there: a later hover does not scrub", a } }); -test("a bookmark shows as the tick's dot and as the prompt row's Pin; the dot, the card and the Pin toggle it", async () => { +test("a bookmark shows as the tick's dot; the dot and the card toggle it; a prompt's row has no Pin", async () => { const view = await mount(8); - const rowButton = (index: number) => - document.querySelector(`.acpmux-row[data-row-id="user-${index + 1}"] .cv-message-pin`)!; try { + // Leo's prompt row (2026-10-21): check, age, Copy, Retry, Fork; no Pin under a prompt. + expect(document.querySelector('.acpmux-row[data-row-id^="user-"] .cv-message-pin')).toBeNull(); expect(tick(2).hasAttribute("data-bookmarked")).toBe(false); - expect(rowButton(2).getAttribute("aria-pressed")).toBe("false"); - // The prompt's own row marks it: the rail's tick gets its dot. - await act(async () => rowButton(2).click()); + // The card marks it: the rail's tick gets its dot. + await act(async () => tick(2).focus()); + const cardButton = () => card()!.querySelector(".acpmux-minimap__bookmark")!; + await act(async () => cardButton().click()); expect(tick(2).hasAttribute("data-bookmarked")).toBe(true); expect(tick(2).querySelector(".acpmux-minimap__dot")).not.toBeNull(); - expect(rowButton(2).getAttribute("aria-pressed")).toBe("true"); // The card shows it filled, and its button removes it. - await act(async () => tick(2).focus()); - const cardButton = card()!.querySelector(".acpmux-minimap__bookmark")!; - expect(cardButton.getAttribute("aria-pressed")).toBe("true"); - await act(async () => cardButton.click()); + expect(cardButton().getAttribute("aria-pressed")).toBe("true"); + await act(async () => cardButton().click()); expect(tick(2).hasAttribute("data-bookmarked")).toBe(false); - expect(rowButton(2).getAttribute("aria-pressed")).toBe("false"); // A click on the dot's spot past a hovered tick toggles the mark and does not jump. const before = view.scroller.scrollTop; await enter(5); await act(async () => tick(5).querySelector("[data-dot-hit]")!.click()); expect(tick(5).hasAttribute("data-bookmarked")).toBe(true); - expect(rowButton(5).getAttribute("aria-pressed")).toBe("true"); expect(view.scroller.scrollTop).toBe(before); await act(async () => tick(5).querySelector("[data-dot-hit]")!.click()); expect(tick(5).hasAttribute("data-bookmarked")).toBe(false); diff --git a/webviews/test/user-message-actions.test.tsx b/webviews/test/user-message-actions.test.tsx index 97f006b02f46..32e8a4ad2289 100644 --- a/webviews/test/user-message-actions.test.tsx +++ b/webviews/test/user-message-actions.test.tsx @@ -1,6 +1,6 @@ -// A prompt's action row matches Claude Code desktop's (GUI push 2026-10-20): under the bubble, its age -// ("1 hour ago"), its read receipt (one check delivered, two read, "Read by "), then Copy, -// Retry and Fork in Claude's order, then cmux's Edit and Pin. Before, a prompt showed a lone bookmark there and a steer said "Steered · the +// A prompt's action row under its bubble follows Leo's spec (2026-10-21), after Claude's: the read receipt (one check delivered, two read, "Read by +// "), its age ("3 days ago", the exact time on hover), a dot, then Copy, Retry and Fork, then +// cmux's Edit; no Pin. Before, a prompt showed a lone bookmark there and a steer said "Steered · the // agent read it" under it. The row shows on hover or focus only. import { afterAll, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; @@ -35,10 +35,16 @@ afterAll(() => { }); const HOUR = 3_600_000; +// Each item of the row by its name: a control's label (Retry's wrapper by its button's), else its text. const labels = () => [...host.querySelectorAll(".cv-user__actions > *")].map( - (node) => node.getAttribute("aria-label") ?? node.textContent?.trim(), + (node) => + node.getAttribute("aria-label") ?? + node.querySelector("button")?.getAttribute("aria-label") ?? + node.textContent?.trim(), ); +/// The tooltip text an item of the row shows on hover (its `title`, which ui/titleTooltips.ts draws). +const tooltip = (selector: string) => host.querySelector(`.cv-user__actions ${selector}`)?.getAttribute("title"); async function draw(row: AcpmuxRow, actions: object) { const { createRoot } = await import("react-dom/client"); @@ -55,12 +61,13 @@ async function draw(row: AcpmuxRow, actions: object) { ); } -test("a read prompt's row reads its age, Read by the agent, Copy and Fork in Claude's order, then Edit", async () => { +test("a read prompt's row is Leo's: check, age, dot, Copy, Retry, Fork, each naming itself; no Pin", async () => { const forked: number[] = []; + const sent: [string, string][] = []; const row: AcpmuxRow = { id: "user-4", version: 1, - at: Date.now() - HOUR - 60_000, + at: Date.now() - 3 * 24 * HOUR - 60_000, kind: "user", seq: 4, text: "Cover it", @@ -69,32 +76,58 @@ test("a read prompt's row reads its age, Read by the agent, Copy and Fork in Cla edit: { seqs: new Map([["user-4", 3]]), send: async () => true }, fork: (seq: number) => forked.push(seq), forkAny: true, + retry: (rowId: string, prompt: string) => sent.push([rowId, prompt]), // Fork goes through the prompt's own turn, the one its summary closes. promptTurns: new Map([["user-4", 5]]), route: { name: "Claude Code", harness: "claude" }, }); - // Claude's row (age, receipt, Copy, Fork), then cmux's own Edit. - expect(labels()).toEqual(["1 hour ago", "Read by Claude Code", "Copy", "Fork from here", "Edit"]); + // Leo's row, then cmux's own Edit where the chat can fork before the prompt. + expect(labels()).toEqual(["Read by Claude Code", "3 days ago", "·", "Copy", "Retry", "Fork from here", "Edit"]); + expect(host.querySelector(".cv-message-pin")).toBeNull(); expect(host.querySelector(".cv-user__read")?.getAttribute("data-read")).toBe("true"); expect(host.querySelector("time.cv-user__time")?.getAttribute("dateTime")).toBe(new Date(row.at).toISOString()); - // The exact time on hover. - expect(host.querySelector("time.cv-user__time")?.getAttribute("title")).toBeTruthy(); + // Each names itself on hover; the time gives its exact date and time. + expect(tooltip(".cv-user__read")).toBe("Read by Claude Code"); + expect(tooltip("time")?.replace(/\s/g, " ")).toBe( + new Date(row.at) + .toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) + .replace(/\s/g, " "), + ); + expect(tooltip('button[aria-label="Copy"]')).toBe("Copy"); + expect(tooltip('button[aria-label="Retry"]')).toBe("Retry"); + expect(tooltip('button[aria-label="Fork from here"]')).toBe("Fork from here"); const forkButton = host.querySelector('button[aria-label="Fork from here"]')!; expect(forkButton.getAttribute("data-tooltip-detail")).toBe( "Starts a new session from this point. This one stays as it is.", ); act(() => forkButton.click()); expect(forked).toEqual([5]); + act(() => host.querySelector('button[aria-label="Retry"]')!.click()); + expect(sent).toEqual([["user-4", "Cover it"]]); +}); + +test("a chat that cannot fork at a prompt hides Fork there rather than showing it disabled", async () => { + await draw( + { id: "user-2", version: 1, at: Date.now(), kind: "user", seq: 2, text: "First" }, + { fork: () => {}, forkSeq: 9, promptTurns: new Map([["user-2", 3]]), retry: () => {} }, + ); + expect(labels()).toEqual(["Read by the agent", "just now", "·", "Copy", "Retry"]); }); test("a prompt still being sent has no receipt, and one that cannot fork offers no Fork", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", pending: true, text: "Next" }, {}); - expect(labels()).toEqual(["just now", "Copy"]); + expect(labels()).toEqual(["just now", "·", "Copy"]); }); test("a steer the agent has not read yet shows one check: delivered when the step ends", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", steer: "waiting", text: "Also this" }, {}); - expect(labels()).toEqual(["just now", "Delivered when the current step ends", "Copy"]); + expect(labels()).toEqual(["Delivered when the current step ends", "just now", "·", "Copy"]); expect(host.querySelector(".cv-user__read")?.getAttribute("data-read")).toBe("false"); // No "Steering" / "Steered" line under the bubble any more. expect(host.textContent).not.toContain("Steer"); @@ -102,10 +135,10 @@ test("a steer the agent has not read yet shows one check: delivered when the ste test("a read steer reads Read by the agent when the chat names no agent", async () => { await draw({ id: "user-9", version: 1, at: Date.now(), kind: "user", steer: "landed", text: "Also this" }, {}); - expect(labels()).toEqual(["just now", "Read by the agent", "Copy"]); + expect(labels()).toEqual(["Read by the agent", "just now", "·", "Copy"]); }); -test("Retry under a prompt sends it again (shown on the last ended turn's prompt)", async () => { +test("Retry under a prompt sends it again (shown under every prompt)", async () => { const sent: [string, string][] = []; await draw( { id: "user-4", version: 1, at: Date.now(), kind: "user", seq: 4, text: "Cover it" }, @@ -120,20 +153,20 @@ test("a steer this pane sent reads delivered while it waits for the agent, thoug const delivery = { state: "read", promptId: "p-1", sessionId: "s-1", sentAt: Date.now() } as const; const steer: AcpmuxRow = { id: "user-7", version: 1, at: Date.now(), kind: "user", seq: 7, text: "Rebase first" }; await draw({ ...steer, steer: "waiting", delivery }, {}); - expect(labels()).toEqual(["just now", "Delivered when the current step ends", "Copy"]); + expect(labels()).toEqual(["Delivered when the current step ends", "just now", "·", "Copy"]); await draw({ ...steer, steer: "landed", delivery }, {}); - expect(labels()).toEqual(["just now", "Read by the agent", "Copy"]); + expect(labels()).toEqual(["Read by the agent", "just now", "·", "Copy"]); }); test("a prompt this pane sent shows no receipt on its way, one check once delivered, two once read", async () => { const sent = { promptId: "p-2", sessionId: "s-1", sentAt: Date.now() }; const prompt: AcpmuxRow = { id: "user-8", version: 1, at: Date.now(), kind: "user", seq: 8, text: "Ship it" }; await draw({ ...prompt, delivery: { ...sent, state: "sending" } }, {}); - expect(labels()).toEqual(["just now", "Copy"]); + expect(labels()).toEqual(["just now", "·", "Copy"]); await draw({ ...prompt, delivery: { ...sent, state: "delivered" } }, {}); - expect(labels()).toEqual(["just now", "Delivered", "Copy"]); + expect(labels()).toEqual(["Delivered", "just now", "·", "Copy"]); await draw({ ...prompt, delivery: { ...sent, state: "read" } }, { route: { name: "Codex" } }); - expect(labels()).toEqual(["just now", "Read by Codex", "Copy"]); + expect(labels()).toEqual(["Read by Codex", "just now", "·", "Copy"]); }); test("the relative time words a message's age as Claude's hover row does, in the pane's language", async () => { From dbe3eb31a06ff882217796a9a640e7a0f6985954 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 15:38:09 -0700 Subject: [PATCH 4/4] Hover rows: "just now" is English-only by design (l10n-allow); fades on the motion scale; tests count only stray Retry - relativeTime: mark Claude's English "just now" l10n-allow (other languages get Intl's own word). - The row's fade uses the motion scale: in --cmux-motion-micro (120ms, Claude's), out --cmux-motion-quick (80ms; Claude's 60ms is off scale). - Retry hides under a prompt still on its way (a second send would duplicate it). - Another agent's message row keeps 6px between its age and Copy. - suggested-tasks: the reminder and notification turns offer no Retry of their own; the person's prompt now has its own (Leo's row). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- .../acpmux/conversation/UserBubble.tsx | 3 ++- .../acpmux/conversation/conversation.css | 11 ++++++++--- .../acpmux/conversation/relativeTime.ts | 1 + .../test/agent-pane-suggested-tasks.test.ts | 19 ++++++++++++++++--- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index 806e8a9650d1..c1ecef52fb96 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -102,7 +102,8 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) { - + {/* Not while it is still on its way: a second send would duplicate it. */} + {!row.pending && row.delivery?.state !== "sending" && } {forkThrough !== undefined && fork!(forkThrough)} />} {through !== undefined && (