diff --git a/scripts/cmux-next/type-scale-baseline.tsv b/scripts/cmux-next/type-scale-baseline.tsv
index ebc13fe6ad19..f7984933387b 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 31
+webviews/src/agent-session/acpmux/conversation/conversation.css size 30
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/composer.gallery.ts b/webviews/src/agent-session/acpmux/composer.gallery.ts
index f0b55ba6962c..f59e94e87afd 100644
--- a/webviews/src/agent-session/acpmux/composer.gallery.ts
+++ b/webviews/src/agent-session/acpmux/composer.gallery.ts
@@ -939,7 +939,7 @@ export default agentPaneEntry({
snapshot: steerMarks(),
},
"steer-read-hover": {
- note: "Play: hover the steer the agent read: the same row as any prompt (age, two checks, Copy, Retry, Pin), and the check's tooltip says who read it. No Steered or Steering text anywhere in the transcript.",
+ note: "Play: hover the steer the agent read: the same row as any prompt (two checks, age, Copy, Retry, Fork), and the check's tooltip says who read it. No Steered or Steering text anywhere in the transcript.",
snapshot: steerMarks(),
play: async (ctx) => {
const steer = ctx.find({ text: "Use the cache dir, not tmp" }).closest(".cv-user");
diff --git a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx
index da163698a31c..bd44cbd4f9ea 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,35 +11,47 @@ 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 (
);
}
+/// 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 }) {
const t = useT();
- return ;
+ return ;
}
/// Fork from here, with Claude's tooltip: the title, then that this chat stays as it is.
@@ -53,7 +66,7 @@ export function ForkButton({ onFork }: { onFork(): void }) {
data-tooltip-detail={t("turn.forkDetail")}
onClick={onFork}
>
-
+
);
}
@@ -74,7 +87,7 @@ export function PinButton({ rowId }: { rowId: string }) {
title={label}
onClick={mark.toggle}
>
-
+
);
}
@@ -95,7 +108,7 @@ export function ReadAloudButton({ id, text }: { id: string; text: string }) {
title={label}
onClick={() => readAloud(id, text, language)}
>
-
+
);
}
@@ -126,14 +139,15 @@ export function ReadReceipt({ row }: { row: AcpmuxRow }) {
title={label}
data-read={read ? "true" : "false"}
>
- {read ? : }
+ {read ? : }
);
}
/// 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);
@@ -144,10 +158,11 @@ 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)}
>
-
+
{retryWith && retryWith.models.length > 1 && (
retryWith.retry(rowId, prompt, model)} />
@@ -156,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/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..3e4d5b70f84a 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
+// 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).
@@ -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}"]`;
@@ -84,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" }),
},
@@ -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 906fbc50fe39..ec49b0d2562b 100644
--- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx
+++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx
@@ -1,12 +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", 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
-// 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 +24,7 @@ import { ChevronLeft, ChevronRight, ImageIcon, Pencil } from "./icons";
import { ImageViewerContext } from "./imageViewerContext";
import { useStableImage } from "./imageEntries";
import { PromptText } from "./PromptText";
-import { ForkButton, MessageCopy, MessageTime, PinButton, PromptRetry, ReadReceipt } 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,9 +99,13 @@ 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 && (
)}
-
- {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 afd3b558433a..6e44f2c33a60 100644
--- a/webviews/src/agent-session/acpmux/conversation/conversation.css
+++ b/webviews/src/agent-session/acpmux/conversation/conversation.css
@@ -55,6 +55,13 @@
--cv-message-gap: 8px; /* a message (or its meta row) to the next row in its turn */
--cv-live-gap: 10px; /* over the running turn's live line, inside its row: 16px under a reply */
--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 (Claude's 60ms is off the motion scale: its quick 80ms), both on
+ Claude's snap curve. */
+ --cv-actions-in: var(--cmux-motion-micro, 120ms);
+ --cv-actions-in-delay: 100ms;
+ --cv-actions-out: var(--cmux-motion-quick, 80ms);
+ --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
@@ -547,19 +554,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,
@@ -567,44 +589,62 @@
.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;
}
-/* 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 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 {
- margin-inline-end: 4px;
+ 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;
}
+/* At the end of another agent's message line its age comes straight before Copy. */
+.cv-received-actions .cv-user__time {
+ padding-inline-end: 6px;
+}
+.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;
- margin-inline-end: 4px;
+ 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 {
@@ -2175,7 +2215,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. */
@@ -2191,10 +2231,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;
@@ -2264,11 +2305,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..66185f987e46 100644
--- a/webviews/src/agent-session/acpmux/conversation/relativeTime.ts
+++ b/webviews/src/agent-session/acpmux/conversation/relativeTime.ts
@@ -1,20 +1,52 @@
-// 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.
+ // l10n-allow: English only by design (Claude's wording); every other language gets Intl's own word.
+ 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/agent-pane-suggested-tasks.test.ts b/webviews/test/agent-pane-suggested-tasks.test.ts
index f9b0c7d36ec5..ea14c7a56dbd 100644
--- a/webviews/test/agent-pane-suggested-tasks.test.ts
+++ b/webviews/test/agent-pane-suggested-tasks.test.ts
@@ -207,6 +207,18 @@ await requireBrowserLane("agent-pane-suggested-tasks.test.ts", async () => {
extra?: () => Record_[];
width?: number;
};
+ /// The Retry buttons a page shows: under the person's prompts (each prompt's own, Leo's prompt row),
+ /// and anywhere else (a turn's footer, another agent's message).
+ async function retryButtons(page: Page): Promise<{ prompts: number; elsewhere: number }> {
+ return page.evaluate(() => {
+ const shown = [...document.querySelectorAll('button[aria-label="Retry"]')].filter(
+ (button) => button.getClientRects().length > 0,
+ );
+ const prompts = shown.filter((button) => button.closest(".cv-user__actions")).length;
+ return { prompts, elsewhere: shown.length - prompts };
+ });
+ }
+
async function openChat({ offered, experiment = "on", extra, width = 900 }: Setup): Promise {
calls = [];
seq = 0;
@@ -864,8 +876,8 @@ await requireBrowserLane("agent-pane-suggested-tasks.test.ts", async () => {
expect(order.footers.some((index) => index > order.first && index < order.second)).toBe(true);
expect(await page.locator("body").innerText()).not.toContain("The date changed.");
// The last turn is the reminder's, which has no prompt of the person's to send again: no
- // Retry resends the earlier prompt in its place.
- expect(await page.getByRole("button", { name: "Retry", exact: true }).count()).toBe(0);
+ // Retry resends the earlier prompt in its place. Only the person's one prompt has its own.
+ expect(await retryButtons(page)).toEqual({ prompts: 1, elsewhere: 0 });
await page.close();
});
@@ -909,7 +921,8 @@ await requireBrowserLane("agent-pane-suggested-tasks.test.ts", async () => {
],
});
await page.getByText("The build is done.").first().waitFor({ timeout: 10_000 });
- expect(await page.getByRole("button", { name: "Retry", exact: true }).count()).toBe(0);
+ // Only the person's one prompt has its own Retry; the notification's turn offers none.
+ expect(await retryButtons(page)).toEqual({ prompts: 1, elsewhere: 0 });
await page.close();
});
diff --git a/webviews/test/thread-minimap-rail.test.tsx b/webviews/test/thread-minimap-rail.test.tsx
index f896bfef8b25..3e6b735989b6 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 3b485e3c5cc6..8a42323c26dc 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
+// 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, Edit and Fork, in Claude's order", 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,31 +76,58 @@ test("a read prompt's row reads its age, Read by the agent, Copy, Edit and Fork,
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" },
});
- expect(labels()).toEqual(["1 hour ago", "Read by Claude Code", "Copy", "Edit", "Fork from here"]);
+ // 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(["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(["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");
@@ -101,13 +135,13 @@ 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(["Read by the agent", "just now", "·", "Copy"]);
// The same two checks as any read prompt, and no "Steered · the agent read it" line.
expect(host.querySelector(".cv-user__read")?.getAttribute("data-read")).toBe("true");
expect(host.textContent).not.toContain("Steer");
});
-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" },
@@ -122,30 +156,74 @@ 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(["Delivered when the current step ends", "just now", "·", "Copy"]);
await draw({ ...steer, steer: "landed", delivery }, {});
- expect(labels()).toEqual(["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(["now", "Copy"]);
+ expect(labels()).toEqual(["just now", "·", "Copy"]);
await draw({ ...prompt, delivery: { ...sent, state: "delivered" } }, {});
- expect(labels()).toEqual(["now", "Delivered", "Copy"]);
+ expect(labels()).toEqual(["Delivered", "just now", "·", "Copy"]);
await draw({ ...prompt, delivery: { ...sent, state: "read" } }, { route: { name: "Codex" } });
- expect(labels()).toEqual(["now", "Read by Codex", "Copy"]);
+ expect(labels()).toEqual(["Read by Codex", "just now", "·", "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: 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 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.
+ 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")?.replace(/\s/g, " ")).toBe("Oct 20, 2026, 10:16 PM");
+ expect(time.getAttribute("data-tooltip-side")).toBe("top");
});