Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/cmux-next/type-scale-baseline.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion webviews/src/agent-session/acpmux/composer.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
61 changes: 38 additions & 23 deletions webviews/src/agent-session/acpmux/conversation/MessageActions.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,57 @@
// 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";
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 (
<time
className={className}
dateTime={new Date(at).toISOString()}
/* tooltip-allow: the row shows the age; the tooltip gives the exact date and time, as Claude's */ title={exact}
/* tooltip-allow: the row shows the age; the tooltip gives the exact date and time, as Claude's */ title={exactTime(
at,
language,
)}
data-tooltip-side="top"
>
{relativeTime(at, now, language)}
</time>
);
}

/// The dot between a prompt's age and its buttons (Leo's spec for the row under a prompt).
export function MessageDot() {
return (
<span className="cv-user__dot" aria-hidden="true">
·
</span>
);
}

/// 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 <CopyButton text={text} label={t("turn.copy")} className="cv-iconbtn cv-iconbtn--compact" size={14} />;
return <CopyButton text={text} label={t("turn.copy")} className="cv-iconbtn cv-iconbtn--compact" size={ROW_ICON} />;
}

/// Fork from here, with Claude's tooltip: the title, then that this chat stays as it is.
Expand All @@ -53,7 +66,7 @@ export function ForkButton({ onFork }: { onFork(): void }) {
data-tooltip-detail={t("turn.forkDetail")}
onClick={onFork}
>
<TurnFork size={14} />
<TurnFork size={ROW_ICON} />
</button>
);
}
Expand All @@ -74,7 +87,7 @@ export function PinButton({ rowId }: { rowId: string }) {
title={label}
onClick={mark.toggle}
>
<Pin size={14} filled={mark.marked} />
<Pin size={ROW_ICON} filled={mark.marked} />
</button>
);
}
Expand All @@ -95,7 +108,7 @@ export function ReadAloudButton({ id, text }: { id: string; text: string }) {
title={label}
onClick={() => readAloud(id, text, language)}
>
<Speaker size={14} />
<Speaker size={ROW_ICON} />
</button>
);
}
Expand Down Expand Up @@ -126,14 +139,15 @@ export function ReadReceipt({ row }: { row: AcpmuxRow }) {
title={label}
data-read={read ? "true" : "false"}
>
{read ? <DoubleCheck size={14} /> : <Check size={14} />}
{read ? <DoubleCheck size={ROW_ICON} /> : <Check size={ROW_ICON} />}
</span>
);
}

/// 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);
Expand All @@ -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)}
>
<Retry size={14} />
<Retry size={ROW_ICON} />
</button>
{retryWith && retryWith.models.length > 1 && (
<RetryModelMenu retryWith={retryWith} onPick={(model) => retryWith.retry(rowId, prompt, model)} />
Expand All @@ -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 (
<div className="cv-user__actions">
<MessageTime at={row.at} className="cv-user__time" />
<ReadReceipt row={row} />
<MessageTime at={row.at} className="cv-user__time" />
<MessageDot />
<MessageCopy text={text} />
<PromptRetry rowId={row.id} prompt={row.resend ?? row.text ?? ""} />
<PinButton rowId={row.id} />
</div>
);
}
Expand Down
4 changes: 2 additions & 2 deletions webviews/src/agent-session/acpmux/conversation/TurnRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -184,7 +184,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
title={t("turn.retryLabel")}
onClick={() => retry(row.retryRowId ?? "", prompt)}
>
<Retry size={14} />
<Retry size={ROW_ICON} />
</button>
)}
{failed && retry && prompt && retryWith && retryWith.models.length > 1 && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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}"]`;
Expand Down Expand Up @@ -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" }),
},
Expand All @@ -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),
Expand Down
33 changes: 18 additions & 15 deletions webviews/src/agent-session/acpmux/conversation/UserBubble.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }) {
Expand Down Expand Up @@ -97,9 +99,13 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) {
</button>
</span>
)}
<MessageTime at={row.at} className="cv-user__time" />
<ReadReceipt row={row} />
<MessageTime at={row.at} className="cv-user__time" />
<MessageDot />
<MessageCopy text={row.text ?? text} />
{/* Not while it is still on its way: a second send would duplicate it. */}
{!row.pending && row.delivery?.state !== "sending" && <PromptRetry rowId={row.id} prompt={resend} />}
{forkThrough !== undefined && <ForkButton onFork={() => fork!(forkThrough)} />}
{through !== undefined && (
<button
type="button"
Expand All @@ -108,12 +114,9 @@ export function UserBubble({ row, text }: { row: AcpmuxRow; text: string }) {
title={t("message.edit")}
onClick={() => setEditing(true)}
>
<Pencil size={14} />
<Pencil size={ROW_ICON} />
</button>
)}
<PromptRetry rowId={row.id} prompt={resend} />
{forkThrough !== undefined && <ForkButton onFork={() => fork!(forkThrough)} />}
<PinButton rowId={row.id} />
</div>
)}
</>
Expand Down
Loading