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: 2 additions & 0 deletions scripts/gallery-matrix/invariant-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2660,6 +2660,8 @@
"agent-pane.tool-row-column#tall-fade": [],
"agent-pane.transcript-chevron-column#dark": [],
"agent-pane.transcript-chevron-column#light": [],
"agent-pane.transcript-row-alignment#finished": [],
"agent-pane.transcript-row-alignment#finished-hover": [],
"agent-pane.transcript-row-alignment#footer": [
"center-line:row subagentDone:agent-docs-check"
],
Expand Down
2 changes: 2 additions & 0 deletions scripts/gallery-matrix/shift-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1767,6 +1767,8 @@
"agent-pane.tool-row-column#tall-fade": 0,
"agent-pane.transcript-chevron-column#dark": 0,
"agent-pane.transcript-chevron-column#light": 0,
"agent-pane.transcript-row-alignment#finished": 0,
"agent-pane.transcript-row-alignment#finished-hover": 0,
"agent-pane.transcript-row-alignment#footer": 0,
"agent-pane.transcript-row-alignment#mix": 0,
"agent-pane.transcript-row-alignment#off": 0,
Expand Down
27 changes: 24 additions & 3 deletions webviews/src/agent-session/acpmux/conversation/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { AcpmuxRow } from "../model";
import { useRowMark } from "../threadMinimap/promptMarks";
import { AlertCircle, Check, DoubleCheck, Pin, Retry, Speaker, Spinner, StopReading, TurnFork } from "./icons";
import { canReadAloud, readAloud, useReadingAloud } from "./readAloud";
import { exactTime, relativeTime } from "./relativeTime";
import { exactTime, relativeTime, replyTime } from "./relativeTime";
import { CopyButton } from "./CopyButton";
import { hasMessageMenuHost, openMessageMenu, openForkMenu } from "./messageMenu";
import { RetryModelMenu } from "./RetryModelMenu";
Expand Down Expand Up @@ -41,6 +41,27 @@ export function MessageTime({ at, className }: { at: number; className: string }
);
}

/// A reply's time at the end of its action row, after the buttons, as ChatGPT's: the exact date and
/// time ("Oct 18, 5:41 AM"), hidden at rest and shown while the reader points at the reply
/// (conversation.css `.cv-turn-time`; Leo, 2026-11-02: "notice how on hover it reveals the exact
/// timestamp. let's follow those."). `detail`, when given, is its tooltip (the turn's time and tokens).
export function ReplyTime({ at, detail }: { at: number; detail?: string }) {
const now = useMinuteClock();
const language = usePaneLanguage();
return (
<time
className="cv-turn-time"
dateTime={new Date(at).toISOString()}
/* tooltip-allow: the turn's time and tokens, which the row no longer spells out (Leo, 2026-11-02) */ title={
detail
}
data-tooltip-side="top"
>
{replyTime(at, now, language)}
</time>
);
}

/// Shared footer rail for sent, received and answer rows. Timestamp text and every action stay on
/// one centered 24px line with equal control slots, including the retry model chevron.
export function MessageFooterRow({ children, className }: { children: ReactNode; className?: string }) {
Expand Down Expand Up @@ -332,7 +353,7 @@ export function ReceivedActions({ row, more }: { row: AcpmuxRow; more?: ReactNod

/// The row under a reply that its turn's footer does not serve (model.ts `hasReplyActions`: a
/// steer, another agent's message, a sent message or a tool row follows it): the footer's look and
/// buttons for this reply, Copy, Pin, Read aloud and More, then its age, as Claude desktop draws
/// buttons for this reply, Copy, Pin, Read aloud and More, then its time (shown on hover), as Claude desktop draws
/// under every reply. Its line is reserved; it shows on hover or keyboard focus (conversation.css).
export function ReplyActions({ row }: { row: AcpmuxRow }) {
const text = row.text ?? "";
Expand All @@ -347,7 +368,7 @@ export function ReplyActions({ row }: { row: AcpmuxRow }) {
<ReadAloudButton id={row.id} text={text} />
<MessageMore rowId={row.id} />
</span>
<MessageTime at={row.at} className="cv-turn-time" />
<ReplyTime at={row.at} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// NEED TO LINE OUR SHIT UP LIKE THEIRS"). One live turn mixes the row kinds the way a long session
// does: a reply, a tool call, a reply, a subagent that finished, a reply, a background command that
// ended, a reply, another agent's message, a reply. The turns above it end with a "Worked for" line (a folded turn)
// and a footer that says its time and tokens beside the reply's action row. Every row's first ink
// and a footer whose action row starts on the reply's left edge, its time and tokens in the time's
// tooltip (Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons"). Every row's first ink
// sits on the prose's left edge, an icon's text one icon column in, and each status row has one room
// above and one below; the reply over it keeps its 2026-10-30 room, so above is the larger
// (transcript-row-alignment.test.ts).
Expand All @@ -31,7 +32,7 @@ const received = (name: string, text: string, minutes: number) =>
});

const rows = [
// A turn with no tool work: its footer says how long it took and what it cost, beside the actions.
// A turn with no tool work: how long it took and what it cost are the time's tooltip, after the buttons.
user("Is the nightly feed current?", 20),
assistant("Yes. The newest NEXT nightly is 12 minutes old and matches the branch tip.", 19.9),
summary(19.9, {
Expand Down Expand Up @@ -66,6 +67,8 @@ const rows = [
];

const base = chat(rows, { title: "Release check", isWorking: true });
/// The first two turns alone, finished: the chat's last footer is the second turn's.
const finished = chat(rows.slice(0, 7), { title: "Release check", isWorking: false });
const snapshot = {
...base,
sessions: [
Expand All @@ -80,10 +83,16 @@ const snapshot = {
],
};

/// The pointer on the first turn's footer, so its action row shows beside its time and tokens.
/// The pointer on the first turn's footer, so its action row and its time show.
const revealFooter: Play = async (ctx) => {
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-turnSummary .cv-turn-summary") !== null);
await ctx.hover({ selector: ".acpmux-turnSummary .cv-turn-summary" });
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-turnSummary .cv-turn-action-group") !== null);
await ctx.hover({ selector: ".acpmux-turnSummary .cv-turn-action-group" });
};

/// The pointer on the finished chat's last reply, so its time shows after the buttons.
const revealLastFooter: Play = async (ctx) => {
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-row--final-footer .cv-turn-action-group") !== null);
await ctx.hover({ selector: ".acpmux-row--final-footer .cv-turn-action-group" });
};

export default agentPaneEntry({
Expand Down Expand Up @@ -114,9 +123,18 @@ export default agentPaneEntry({
documentAttributes: { "transcript-status-gap": "off" },
},
footer: {
note: "The pointer on the first turn's footer: the copy glyph starts on the reply's left edge, as Codex and Claude draw it, with Worked for after the buttons.",
note: "The pointer on the first turn's footer: the copy glyph's ink starts on the reply's left edge, as ChatGPT, Codex and Claude draw it; the exact time shows after the buttons, and its tooltip has the turn's time and tokens.",
snapshot,
play: revealFooter,
},
finished: {
note: "A finished chat at rest: its last reply's buttons are in sight with no pointer, the first glyph on the reply's left edge; the time waits for the pointer.",
snapshot: finished,
},
"finished-hover": {
note: "The pointer on the finished chat's last reply: its exact time shows after the buttons, which do not move.",
snapshot: finished,
play: revealLastFooter,
},
},
});
24 changes: 17 additions & 7 deletions webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,29 @@
}
});

test("a 5 s turn still says Worked for 5s", () => {
const row = footer({ durationMs: 5_000 });
test("a 5 s turn says Worked for 5s in its time's tooltip, never in front of the buttons", () => {
// Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons. those buttons
// should always just be there left aligned".
const row = footer({
durationMs: 5_000,
text: "Done.",
tokens: { output: 359, input: 1_000, cacheRead: 327_800, cacheWrite: 0 },
});
expect(footerLabel(t, row)).toBe("Worked for 5s");
expect(renderToStaticMarkup(<TurnFooter row={row} />)).toContain("Worked for 5s");
const markup = renderToStaticMarkup(<TurnFooter row={row} />);
expect(markup).not.toContain("cv-turn-summary");
expect(markup).toMatch(/<span class="cv-message-footer-row cv-turn-footer-row"><span class="cv-turn-action-group">/);
expect(markup).toMatch(/<time class="cv-turn-time"[^>]*title="Worked for 5s · ↓ 359 out ↑ 328.8K in"/);
});

// Leo, 2026-11-02, on "Worked for" drawn in front of Copy: "this should not be placed in front of
// the copy/whatever buttons. those buttons should always just be there left aligned".
test("the buttons lead the footer row; Worked for comes after them", () => {
test("the buttons lead the footer row; Worked for comes after them, in the time's tooltip", () => {
const markup = renderToStaticMarkup(<TurnFooter row={footer({ durationMs: 5_000, text: "Done." })} />);
const group = markup.indexOf("cv-turn-action-group");
expect(group).toBeGreaterThan(-1);
expect(markup.indexOf("Copy")).toBeGreaterThan(group);
expect(markup.indexOf("cv-turn-summary")).toBeGreaterThan(markup.indexOf("Copy"));
expect(markup.indexOf('title="Worked for 5s"')).toBeGreaterThan(markup.indexOf("Copy"));
});

test("a turn stopped within a second still says it stopped", () => {
Expand All @@ -54,11 +63,12 @@
expect(footerLabel(t, footer({ durationMs: 5_000, folded: true }))).toBeUndefined();
});

test("a footer with only hover controls is empty; one with an answer, a label or a failure is not", () => {
test("a footer with only hover controls is empty; one with an answer or a failure is not", () => {
expect(isEmptyFooter(footer({ durationMs: 0 }))).toBe(true);
expect(isEmptyFooter(footer({ durationMs: 5_000, folded: true }))).toBe(true);
expect(isEmptyFooter(footer({ durationMs: 0, text: "Done." }))).toBe(false);
expect(isEmptyFooter(footer({ durationMs: 5_000 }))).toBe(false);
// Its Worked for is the time's tooltip now, so it no longer keeps the line.
expect(isEmptyFooter(footer({ durationMs: 5_000 }))).toBe(true);
expect(isEmptyFooter(footer({ durationMs: 0, status: "failed", error: "boom" }))).toBe(false);
expect(renderToStaticMarkup(<TurnFooter row={footer({ durationMs: 0 })} />)).toContain("cv-turn-actions--empty");
expect(renderToStaticMarkup(<TurnFooter row={footer({ durationMs: 0, text: "Done." })} />)).not.toContain(
Expand All @@ -70,7 +80,7 @@

test("an empty footer adds no line and still fits its hover controls", () => {
// Between two prompts: the 16px turn gap has a 24px floor for the existing hover controls.
expect(layoutConversation([prompt("p"), footer({ durationMs: 0 }), prompt("q")], 720).heights[1]).toBe(24);

Check failure on line 83 in webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx

View workflow job for this annotation

GitHub Actions / web / react-apps-check (8)

error: expect(received).toBe(expected)

Expected: 24 Received: 0 at <anonymous> (/home/runner/_work/cmux/cmux/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx:83:101)

Check failure on line 83 in webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx

View workflow job for this annotation

GitHub Actions / web / react-apps-check (8)

error: expect(received).toBe(expected)

Expected: 24 Received: 0 at <anonymous> (/home/runner/_work/cmux/cmux/webviews/src/agent-session/acpmux/conversation/TurnFooter.test.tsx:83:101)
// The chat's last one keeps that same turn gap before the composer.
expect(layoutConversation([prompt("p"), footer({ durationMs: 0 })], 720).heights[1]).toBe(24);
// A footer with an answer to copy keeps its line.
Expand Down
39 changes: 17 additions & 22 deletions webviews/src/agent-session/acpmux/conversation/TurnRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,23 @@ import { TurnActionsContext } from "./turnActions";
import { errorRecovery, signInRetry, type ErrorRecovery } from "./errorRecovery";
import { footerLabel, isEmptyFooter, workedLabel } from "./turns";
import { turnCopyText } from "./turnCopy";
import { hasTokens, TimedLabel, TurnTokens } from "./TurnTokens";
import { hasTokens, TimedLabel, tokensText, TurnTokens } from "./TurnTokens";
import { AlertCircle, ArrowRight, ChevronRight, Retry } from "./icons";
import {
ForkButton,
forkWithFeedback,
MessageCopy,
MessageFooterRow,
MessageMore,
MessageTime,
PinButton,
ReadAloudButton,
ReplyTime,
ROW_ICON,
} from "./MessageActions";
import { RetryModelMenu } from "./RetryModelMenu";
import { CloudSignIn } from "./CloudSignIn";
import type { CloudSignIn as CloudSignInActions } from "../cloudAgentLogin";
import { useT } from "../i18n";
import { usePaneLanguage, useT } from "../i18n";
import {
failureCopy,
failureKind,
Expand Down Expand Up @@ -257,13 +257,17 @@ export function ToolRows({ row, sessionId }: { row: AcpmuxRow; sessionId?: strin
);
}

/// The quiet row under an answer, as Claude Code desktop's: Copy, Fork from here (when acpmux
/// serves forks), Pin, Read aloud, then the answer's age, and why the turn ended when it did not
/// complete. Retry is the prompt's (UserBubble.tsx), and also here when the turn failed, whose row
/// always shows. A turn without a "Worked for" line (history paged in mid-turn) says its time and
/// count here first.
/// The quiet row under an answer, as ChatGPT's: its buttons first, the first glyph's ink on the
/// prose's left edge whatever the turn did (Copy, Fork from here when acpmux serves forks, Pin, Read
/// aloud, More), then Continue for a stopped last turn, then the answer's exact time, shown on hover.
/// Leo, 2026-11-02: "this should not be placed in front of the copy/whatever buttons. those buttons
/// should always just be there left aligned". A turn without a "Worked for" line (a Claude turn,
/// #30176; history paged in mid-turn) keeps its time and tokens in the time's tooltip, never in the
/// row. Retry is the prompt's (UserBubble.tsx), and also here when the turn failed, whose row always
/// shows; so does the footer that ends the chat (#31503), so a finished reply's Copy is in sight.
export function TurnFooter({ row }: { row: AcpmuxRow }) {
const t = useT();
const language = usePaneLanguage();
const messages = useContext(PresentationContext) === "messages";
const {
fork,
Expand Down Expand Up @@ -295,6 +299,10 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
const teamMachine = route?.hostKind === "cloud" && route.teamMachine === true;
const authenticationFailed = (kind === "subscription-login" || kind === "auth") && !teamMachine;
const label = footerLabel(t, row);
// The turn's time and tokens, for the time's tooltip: "Worked for 13s · ↓ 359 out ↑ 328.8K in".
const detail =
[label, hasTokens(row.tokens) ? tokensText(t, language, row.tokens) : undefined].filter(Boolean).join(" · ") ||
undefined;
// The last turn, stopped and not yet followed by a prompt, offers Continue (Codex's): it sends the
// continue prompt as the composer would, and the button goes once that prompt shows (turns.ts).
const resumable = row.resumable === true && resume !== undefined;
Expand Down Expand Up @@ -434,19 +442,6 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
</button>
)}
</span>
{!messages && label && (
<span className="cv-turn-summary">
{label}
{hasTokens(row.tokens) && (
<>
<span className="cv-worked__sep" aria-hidden="true">
{" · "}
</span>
<TurnTokens split={row.tokens} />
</>
)}
</span>
)}
{resumable && (
<button
type="button"
Expand All @@ -461,7 +456,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
<ArrowRight size={ROW_ICON} aria-hidden="true" />
</button>
)}
{!messages && <MessageTime at={row.at} className="cv-turn-time" />}
{!messages && <ReplyTime at={row.at} detail={detail} />}
</MessageFooterRow>
</div>
</>
Expand Down
6 changes: 6 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/TurnTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export const tokenDetail = (t: Translate, language: PaneLanguage, split: TurnTok
cacheWrite: compactTokens(language, split.cacheWrite),
});

/// The split as plain words, "↓ 12.4K out ↑ 3.9M in", for a tooltip (TurnRows.tsx TurnFooter).
export const tokensText = (t: Translate, language: PaneLanguage, split: TurnTokenSplit) =>
`${t("turn.tokens.out", { tokens: compactTokens(language, split.output) })} ${t("turn.tokens.in", {
tokens: compactTokens(language, readTokens(split)),
})}`;

/// The tokens' span (no separator before it). `split` wins over `total` when both are known.
export function TurnTokens({ total, split }: { total?: number; split?: TurnTokenSplit }) {
const t = useT();
Expand Down
Loading
Loading