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
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// l10n-allow-file: gallery fixtures (sample lanes, messages and commands), not shipped UI.
// Every transcript disclosure chevron right after its label's words, the way ChatGPT draws "Worked
// for 11s ⌄" (Leo, 2026-11-03: "this how chatgpt does their chevrons"): small, muted, pointing down
// while closed and up while open, never parked at the row's far edge. The variants try the glyph's
// size and its gap after the words (conversation.css --cv-chevron-glyph and --cv-chevron-gap); the
// selected one is the default. A narrow width shows a cut label keeping its chevron after the
// ellipsis. test/transcript-chevron-after-label.test.ts measures the shipped rows.
import { agentPaneEntry, type AgentPaneVariant } from "../../../gallery/format";
import type { Play } from "../../../gallery/play";
import {
SESSION_ID,
activity,
assistant,
chat,
row,
session,
summary,
thought,
tool,
user,
} from "../../../gallery/fixtures/acpmux";
import type { AcpmuxActivity } from "../model";
import { BACKGROUND_TURN, backgroundTurnRowId } from "./backgroundTurns";

const run = (description: string, command: string): AcpmuxActivity =>
tool(command, "execute", "completed", { description, command, output: "ok\n", exitCode: 0 });

const SENDER = "lane-box-2/cloud-promote";
const RECEIVED =
"backend.yml is green at 4f2a91c, the candidate is ready to promote whenever the production window opens again.";

const snapshot = () => {
const base = chat(
[
user("Promote the candidate once backend.yml is green on the lane.", 20),
activity(
[
thought("Check the lane's last backend.yml run before dispatching anything."),
run("Read the lane's last run", "gh run list -w backend.yml -L 1"),
run("Read the candidate's sha", "git rev-parse --short HEAD"),
],
19.9,
),
assistant("The lane's run is still going; I'll wait for it and then promote.", 19.8),
summary(19.7, { status: "completed", durationMs: 11_000, toolCount: 2 }),
user(RECEIVED, 12, {
incoming: {
kind: "message",
message: { name: SENDER, text: RECEIVED, raw: RECEIVED, sessionId: "s-lane", harness: "claude" },
},
untyped: true,
}),
row(BACKGROUND_TURN, 8, {
id: backgroundTurnRowId("chevrons-scheduled"),
background: { id: "chevrons-scheduled", trigger: "scheduled", running: false, quiet: true },
}),
activity([run("Read the lane's last run", "gh run list -w backend.yml -L 1")], 7.9, {
inBackground: "chevrons-scheduled",
}),
assistant("Nothing new yet.", 7.8, { inBackground: "chevrons-scheduled" }),
activity(
[
run("Check backend.yml dispatch inputs", "gh workflow view backend.yml --yaml"),
run("Dispatch production promote of the candidate", "gh workflow run backend.yml -f env=production"),
],
4,
{ id: "chevrons-run" },
),
],
{
title: "cloud-promote",
sessions: [
session({ sessionId: SESSION_ID, title: "cloud-promote", name: "cloud-promote" }),
session({ sessionId: "s-lane", title: SENDER, name: SENDER }),
],
},
);
return { ...base, summary: { ...base.summary!, family: "claude" } };
};

const STYLE_ID = "gallery-chevron-after-label";

/// Sets the chevron's glyph size and gap, then opens the last "Ran 2 commands" (its steps show the
/// hover chevron after their labels).
const look =
(glyph?: string, gap?: string): Play =>
async (ctx) => {
ctx.document.getElementById(STYLE_ID)?.remove();
if (glyph || gap) {
const style = ctx.document.createElement("style");
style.id = STYLE_ID;
style.textContent = `:root { ${glyph ? `--cv-chevron-glyph: ${glyph};` : ""} ${gap ? `--cv-chevron-gap: ${gap};` : ""} }`;
ctx.document.head.append(style);
}
const line = () =>
[...ctx.document.querySelectorAll<HTMLButtonElement>("button.cv-tool[aria-expanded]")].filter((node) =>
/Ran 2 commands/.test(node.textContent ?? ""),
)[0];
await ctx.waitFor(line, { capMs: 10_000 });
if (line()?.getAttribute("aria-expanded") === "false") line()!.click();
await ctx.waitFor(() => ctx.document.querySelector(".cv-steps"), { capMs: 10_000 });
};

const variants: Record<string, AgentPaneVariant> = {
chatgpt: {
note: 'Selected default: a 12px glyph whose ink starts 5px after the words, as ChatGPT\'s "Worked for 11s ⌄". Down while closed, up while open.',
snapshot: snapshot(),
play: look(),
},
tight: {
note: "A 10px glyph 3px after the words: reads as part of the label, but small at 1x.",
snapshot: snapshot(),
play: look("10px", "3px"),
},
spaced: {
note: "A 12px glyph a full word space (8px) after the words: clearly its own control, a little detached.",
snapshot: snapshot(),
play: look("12px", "8px"),
},
large: {
note: "A 14px glyph 5px after the words: the tool glyphs' size, heavier than ChatGPT's.",
snapshot: snapshot(),
play: look("14px", "5px"),
},
};

export default agentPaneEntry({
id: "agent-pane.transcript-chevron-after-label",
title: "Transcript chevrons: right after the label, pointing down, the ChatGPT way",
area: "Agent pane",
height: 640,
widths: { narrow: 420, normal: 760, wide: 900 },
covers: [
"agent-session/acpmux/conversation/conversation.css",
"agent-session/acpmux/conversation/TranscriptActivityRow.css",
"agent-session/acpmux/subagents/DisclosureRow.css",
],
variants,
});
20 changes: 14 additions & 6 deletions webviews/src/agent-session/acpmux/conversation/RowGrammar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,23 @@ test("an opened send shows its message once, under the row, not again on the lin
}
});

test("the trailing column and chevron use the shared flex row contract", () => {
test("the chevron follows the words and the trailing column ends the line", () => {
// Leo, 2026-11-03: "this how chatgpt does their chevrons". In the transcript a row's chevron
// sits right after its words and its status or duration ends the line
// (test/transcript-chevron-after-label.test.ts measures it in Chromium).
const css = readFileSync(new URL("./conversation.css", import.meta.url), "utf8");
const block = (selector: string) =>
new RegExp(`(?:^|\\n)${selector.replace(/[.]/g, "\\.")} \\{([^}]*)\\}`).exec(css)?.[1] ?? "";
expect(block(".cv-tool")).toContain("display: flex");
const block = (selector: string) => {
const at = css.indexOf(`\n${selector} {`);
return at < 0 ? "" : css.slice(at, css.indexOf("}", at));
};
expect(css).toMatch(/\n\.cv-tool \{[^}]*display: flex/);
expect(block(".cv-tool__chevron")).toContain("order: 3");
expect(block(".acpmux-row .cv-tool > .cv-tool__trail")).toContain("order: 5");
expect(block(".acpmux-row .cv-tool > .cv-tool__trail")).toContain("margin-inline-start: auto");
expect(css).toContain(".cv-tool:not(.cv-agentmsg__line) > .cv-command__meta,");
expect(css).toContain(" order: 4;");
// Message rows use the same shared flex sentence, with the trailing status outside it.
// A message line's sentence joins the line in the transcript, so the chevron can precede Copy;
// outside it (Home's run ledger) the sentence keeps the free width.
expect(block(".acpmux-row .cv-agentmsg__line > .cv-agentmsg__sentence")).toContain("display: contents");
const agentCss = readFileSync(new URL("./AgentMessageRow.css", import.meta.url), "utf8");
const sentence = /\.cv-agentmsg__sentence \{([^}]*)\}/.exec(agentCss)?.[1] ?? "";
expect(sentence).toContain("flex: 1 1 0");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export function SentMessageLine({
row's is (MessageCard.tsx); its click never toggles the row. */}
{text && (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<span onClick={(event) => event.stopPropagation()}>
<span className="cv-sent__actions" onClick={(event) => event.stopPropagation()}>
<SentActions text={text} />
</span>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* Activity rows share the transcript tool grid: icon, sentence, trailing status, chevron. */
/* Activity rows share the transcript tool grid: icon, sentence, the chevron right after the words
(ChatGPT's "Worked for 11s ⌄"; Leo, 2026-11-03: "this how chatgpt does their chevrons"), then the
trailing status at the line's end. */
.cv-agent-activity-row {
display: flex;
min-width: 0;
Expand All @@ -19,7 +21,7 @@
}
.cv-agent-activity__sentence {
display: flex;
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
align-items: center;
order: 1;
Expand All @@ -34,13 +36,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.cv-agent-activity__trail {
order: 3;
.cv-agent-activity__control > .cv-agent-activity__trail {
order: 4;
flex: none;
margin-inline-start: auto;
}
.cv-agent-activity__chevron {
order: 4;
.cv-agent-activity__control > .cv-agent-activity__chevron {
order: 2;
flex: none;
cursor: default;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,8 @@ export function TranscriptActivityRow({
{children}
</span>
{meta !== undefined && <span className="cv-tool__trail cv-agent-activity__trail">{meta}</span>}
<span
className={`cv-tool__chevron cv-agent-activity__chevron cv-rotor${expanded ? " is-open" : ""}`}
aria-hidden="true"
>
<ChevronRight size={14} strokeWidth={1.2} />
<span className="cv-tool__chevron cv-agent-activity__chevron" aria-hidden="true">
<ChevronRight size={14} strokeWidth={1.2} className={`cv-rotor${expanded ? " is-open" : ""}`} />
</span>
</>
);
Expand Down
71 changes: 66 additions & 5 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@
--cv-row-icon-gap: var(--grid-gap);
--cv-tool-text-inset: var(--grid-transcript-text-gap);
--cv-disclosure-gap: var(--ds-space-1, 4px);
/* A transcript row's disclosure chevron, ChatGPT's "Worked for 11s ⌄" (Leo, 2026-11-03: "this how
chatgpt does their chevrons"): its glyph box, and the gap from the last visible word to its ink.
Gallery agent-pane.transcript-chevron-after-label compares sizes and gaps. */
--cv-chevron-glyph: 12px;
--cv-chevron-gap: 5px;
/* The turned ChevronRight's ink starts 4.25/16 into its box (icons.tsx). */
--cv-chevron-ink: calc(var(--cv-chevron-glyph) * 0.265625);
/* 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. */
Expand Down Expand Up @@ -2552,8 +2559,9 @@ pre.cv-math-source {
.cv-iconbtn:focus:not(:focus-visible) {
outline: none;
}
/* One right chevron, turned a quarter when open; a message head's shows on hover. It turns with the
fold it opens (fold.ts), on the same token. */
/* One chevron, the way ChatGPT draws "Worked for 11s ⌄" (Leo, 2026-11-03: "this how chatgpt does
their chevrons"): in a transcript row it points down while closed and turns to point up while open.
A message head's shows on hover. It turns with the fold it opens (fold.ts), on the same token. */
.cv-rotor {
flex: none;
}
Expand All @@ -2577,6 +2585,57 @@ pre.cv-math-source {
.cv-rotor.is-open {
transform: rotate(90deg);
}
.acpmux-row .cv-rotor {
transform: rotate(90deg);
}
.acpmux-row .cv-rotor.is-open {
transform: rotate(-90deg);
}
/* A row's chevron right after its words, never at the line's end: one glyph size, its ink
--cv-chevron-gap after the last visible word (the row's flex gap and the glyph's own inset taken
back). A cut label keeps it after the ellipsis: the chevron is the label's sibling and never
shrinks, the label does. test/transcript-chevron-after-label.test.ts. */
.acpmux-row svg.cv-rotor {
width: var(--cv-chevron-glyph);
height: var(--cv-chevron-glyph);
}
.acpmux-row :is(.cv-tool__chevron, .cv-worked__chevron, .cv-disclosure__toggle:not(.is-chevron) > .cv-rotor) {
flex: none;
width: var(--cv-chevron-glyph);
height: var(--cv-chevron-glyph);
justify-content: flex-start;
margin-inline-start: calc(var(--cv-chevron-gap) - var(--grid-gap) - var(--cv-chevron-ink));
}
/* A message line (SentMessageLine.tsx, AgentMessageRow.tsx; the whole line still toggles): the
sentence takes its words' width, the chevron follows it,
then the hover Copy, and the delivery word sits at the line's end. Home's run ledger, outside the
transcript, keeps its own layout (ledger.css). */
.acpmux-row .cv-sent > .cv-agentmsg__sentence,
.acpmux-row .cv-agentmsg__line .cv-agentmsg__toggle,
.acpmux-row .cv-agentmsg__line .cv-agentmsg__preview {
flex: 0 1 auto;
}
/* A message row's hover Copy is inside its sentence (AgentMessageRow.tsx): in the transcript the
sentence's parts join the line, so the chevron can come before Copy. The preview still takes the
one ellipsis; the chip keeps its own cap. */
.acpmux-row .cv-agentmsg__line > .cv-agentmsg__sentence {
display: contents;
}
.acpmux-row .cv-sent > .cv-sent__actions,
.acpmux-row .cv-agentmsg__line > .cv-agentmsg__sentence > .cv-sent-actions {
order: 4;
}
.acpmux-row .cv-tool > .cv-tool__trail {
order: 5;
margin-inline-start: auto;
}
/* A line that opens a chat keeps its chevron its own 20px button (DisclosureRow.css), the glyph
centred in it. */
.acpmux-row .cv-disclosure__line > .cv-disclosure__toggle.is-chevron {
margin-inline-start: calc(
var(--cv-chevron-gap) - var(--grid-gap) - (20px - var(--cv-chevron-glyph)) / 2 - var(--cv-chevron-ink)
);
}
.cv-rotor.is-hover:not(.cv-tool__chevron) {
visibility: hidden;
}
Expand Down Expand Up @@ -3444,16 +3503,18 @@ button.cv-video-frames__frame:focus-visible {
}
.cv-received .cv-disclosure__label {
display: flex;
flex: 1 1 auto;
/* Its words' width, so the chevron follows them (conversation.css "chevron right after its
words"); the toggle still takes the line's free width as its target. */
flex: 0 1 auto;
align-items: baseline;
gap: calc(var(--ds-space-1) + 2px);
font-weight: 400;
}
.cv-received__hover {
position: absolute;
inset-block: 0;
/* Before the chevron (12px), one small gap clear of it. */
inset-inline-end: calc(12px + var(--ds-space-1));
/* At the line's end: the chevron follows the words, so nothing else sits there. */
inset-inline-end: 0;
display: inline-flex;
align-items: center;
padding-inline-start: var(--ds-space-2);
Expand Down
9 changes: 9 additions & 0 deletions webviews/src/agent-session/acpmux/subagents/subagents.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/* Subagent groups (SubagentGroup.tsx), after the ChatGPT and T3 Code apps. Heights are fixed
and match model.ts: the group line is 48px with 8px above it, each listed subagent 48px, and
the list's last row adds 8px below. */
Expand Down Expand Up @@ -999,6 +999,15 @@
flex: none;
pointer-events: none;
}
/* In the transcript its mark follows the line's words, before the hover buttons; the time stays at
the line's end (conversation.css "chevron right after its words"). It opens a chat, not a fold,
so it keeps pointing right. */
.acpmux-row .cv-subagent-done-row > .cv-subagent-done__chevron {
order: 1;
}
.acpmux-row .cv-subagent-done-row > .cv-subagent-done__note {
order: 2;
}
.cv-subagent-done-row > .cv-tool__chevron.is-idle {
visibility: hidden;
}
Expand Down
11 changes: 8 additions & 3 deletions webviews/test/incoming-message-disclosure-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,10 @@ await requireBrowserLane("incoming-message-disclosure-browser.test.ts", async ()
});

// Leo, 2026-10-29: "this ui is all quite inconsistent". A delivered message and one that waits
// are one row: their chevrons end on one x, one plate under the line, actions only on hover.
test("a delivered message and a waiting one draw the same line: one chevron column, actions on hover", async () => {
// are one row: each chevron right after its words (Leo, 2026-11-03: "this how chatgpt does their
// chevrons"; this test pinned one chevron x at the line's end before), one plate under the line,
// actions only on hover.
test("a delivered message and a waiting one draw the same line: chevron after the words, actions on hover", async () => {
const page = await openChat(
[
...turn(1),
Expand All @@ -444,18 +446,21 @@ await requireBrowserLane("incoming-message-disclosure-browser.test.ts", async ()
all.map((row) => {
const line = row.querySelector<HTMLElement>(":scope > .cv-disclosure__line")!;
const chevron = line.querySelector<HTMLElement>(".cv-rotor")!.getBoundingClientRect();
const words = line.querySelector<HTMLElement>(".cv-received__preview")!.getBoundingClientRect();
const hover = line.querySelector<HTMLElement>(".cv-received__hover");
return {
right: Math.round(chevron.right),
after: chevron.left - words.right,
open: Boolean(line.querySelector(".cv-disclosure__open")),
groups: line.querySelectorAll(".cv-received-actions").length,
hidden: hover ? getComputedStyle(hover).opacity : "missing",
};
}),
);
expect(lines).toHaveLength(2);
expect(lines[0]!.right).toBe(lines[1]!.right);
for (const line of lines) {
expect(line.after).toBeGreaterThanOrEqual(-0.5);
expect(line.after).toBeLessThanOrEqual(8.5);
expect(line.open).toBe(false);
expect(line.groups).toBe(1);
expect(line.hidden).toBe("0");
Expand Down
Loading
Loading