Skip to content
Merged
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,7 @@ const MessageRow = memo(
blocker: false,
promptIds: [...row.inboxThread.promptIds],
...(row.inboxThread.fromSession ? { fromSession: row.inboxThread.fromSession } : {}),
...(row.inboxThread.fromHarness ? { fromHarness: row.inboxThread.fromHarness } : {}),
},
],
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// default are inboxNoticeLook.ts's (`theme.inboxNoticeLook`). Names and messages are made up.
import { agentPaneEntry } from "../../../gallery/format";
import type { Play } from "../../../gallery/play";
import { SESSION_ID, assistant, chat, session, summary, user } from "../../../gallery/fixtures/acpmux";
import { SESSION_ID, activity, assistant, chat, session, summary, tool, user } from "../../../gallery/fixtures/acpmux";
import { incomingPrompt } from "./incomingMessage";
import { inboxText } from "./inboxNotice";

Expand Down Expand Up @@ -158,6 +158,71 @@ function snapshot(notices: Thread[][], before = false) {
);
}

/// A sender no chat here lists, named like another harness (a Codex lane called `claude-…`): its
/// line draws the neutral agent glyph, never a mark guessed from the name.
const UNLISTED: Thread = {
threadId: "from:claude-mesh-lane/pr:5805",
label: "#5805",
messages: [{ from: "claude-mesh-lane", text: "HQ#5805 at b070cf5 opened its verification issue." }],
};

/// Two messages from one session that wait for the agent's turn to end (PendingMessagesCard.tsx):
/// "2 messages from 1 session", the case Leo named.
const WAITING = [
"Review passed at e34ce27; landing #2210 now.",
"Landed #2210; the nightly picks it up in its next run.",
].map((text, index) => ({
id: `w${index + 1}`,
prompt: text,
message: { name: "grid-lane", sessionId: "s-grid", harness: "claude", text, raw: text },
}));

/// Inbox rows among tool rows, as a coordinator's chat draws them: a run of commands, one thread's
/// line, a delivered group of one sender's threads, one of several senders', and the messages that
/// wait (Leo, 2026-11-01: "i wanted this to be left aligned with the other tool calls" and "let's
/// just make this look better"). Every group's head is a tool row (MessageGroupHead.tsx).
/// inbox-row-icon-column.test.ts measures it.
function amongTools() {
const run = (minutes: number) =>
activity(
[
tool("Run", "execute", "completed", {
command: "gh pr checks 2210",
output: "web / react-apps-base-check pass\n",
}),
tool("Run", "execute", "completed", { command: "git log --oneline -1", output: "6d9ae71 base autofix\n" }),
],
minutes,
);
return chat(
[
user("Hand the three polish lanes their briefs and land what comes back reviewed.", 12),
run(11),
notice([BLOAT], 10),
run(9),
notice([UNLISTED], 8.5),
run(8),
notice([GRID, FYI], 7),
run(6),
notice([PLAN, REVIEW, RAIL], 5),
run(4),
assistant("Landing #2210 once its review comes back…", 3, { streaming: true }),
],
{
title: "polish-coordinator",
isWorking: true,
queue: WAITING,
sessions: [
session({ sessionId: SESSION_ID, title: "polish-coordinator", name: "polish-coordinator" }),
session({ sessionId: "s-log-bloat", title: "ao-log-bloat", name: "ao-log-bloat", harness: "codex" }),
session({ sessionId: "s-grid", title: "grid-lane", name: "grid-lane", harness: "claude" }),
session({ sessionId: "s-picker", title: "picker-lane", name: "picker-lane", harness: "codex" }),
session({ sessionId: "s-review", title: "review-box", name: "review-box", harness: "codex" }),
],
},
);
}

const ONE = [[BLOAT]];
const THREE = [[BLOAT, PLAN, GRID]];
// Leo's case: notices in a row, each its own prompt; six threads in all.
Expand Down Expand Up @@ -196,10 +261,37 @@ export default agentPaneEntry({
covers: [
"agent-session/acpmux/conversation/InboxNoticeCard.tsx",
"agent-session/acpmux/conversation/InboxNoticeCard.css",
"agent-session/acpmux/conversation/MessageGroupHead.tsx",
"agent-session/acpmux/conversation/MessageGroupHead.css",
"agent-session/acpmux/conversation/LaneNoticeBatch.tsx",
"agent-session/acpmux/conversation/PendingMessagesCard.tsx",
"agent-session/acpmux/conversation/inboxNotice.ts",
"agent-session/acpmux/conversation/incomingMessage.ts",
],
variants: {
"icon-column": {
note: "Inbox rows among tool rows: one thread's line, a delivered group of one sender (its harness mark), one of several (the messages glyph), and the messages that wait (\"2 messages from 1 session\"). Every row's glyph sits in the tool rows' icon column, its words start where theirs do, and a group's head is a tool row, their height, gap and type, its chevron shown on hover. A sender no chat lists draws the neutral agent glyph.",
snapshot: amongTools(),
},
"icon-column-look-chevron": {
note: "Gallery version (theme inboxNoticeLook group-chevron): the group's chevron always shown.",
theme: { inboxNoticeLook: "group-chevron" },
snapshot: amongTools(),
},
"icon-column-look-count": {
note: "Gallery version (theme inboxNoticeLook group-count): the count alone; the senders in its tooltip.",
theme: { inboxNoticeLook: "group-count" },
snapshot: amongTools(),
},
"icon-column-open": {
note: "The same, the group of several opened: its threads' lines under it.",
snapshot: amongTools(),
play: async (ctx) => {
await ctx.waitFor(() => ctx.document.querySelectorAll(".cv-message-group__head").length >= 2);
ctx.document.querySelectorAll<HTMLButtonElement>(".cv-message-group__head")[1]?.click();
await ctx.waitFor(() => ctx.document.querySelector(".cv-lane-batch__body .cv-received"));
},
},
"before-six": {
note: "Before: three notices in a row, as acpmux wrote them and the pane drew them: each its text for the agent in the person's bubble.",
snapshot: snapshot(SIX, true),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* acpmux's inbox notice (InboxNoticeCard.tsx): each thread a received line (conversation.css
`.cv-received`), with the thread's label before the preview and "+N more" after it, muted, never cut; the group's head
is the waiting messages' (PendingMessagesCard.css). Colours, glass and type are the transcript's. */
is a tool row (below). Colours, glass and type are the transcript's. */
.cv-inbox__label {
flex: none;
color: var(--cv-text-muted);
Expand Down Expand Up @@ -36,3 +36,12 @@
border: 1px solid var(--cv-card-ring);
border-radius: var(--ds-radius-card, 12px);
}
/* Two or more threads, folded (InboxNoticeCard.tsx, the `group` look): a tool-row head
(MessageGroupHead.css), and opened, the threads' lines start at the tool rows' words. */
.cv-inbox--group {
margin: 0;
container-type: inline-size;
}
.cv-inbox__body {
margin-inline-start: var(--cv-tool-text-inset);
}
66 changes: 37 additions & 29 deletions webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,27 @@
// (`_acpmux/inbox_thread`, through `chat.inbox.thread`) when the line first opens, without marking
// them read (the person reading is not the agent). Nothing of the notice's text for the agent shows.
// Two or more threads (a notice of several, or notices next to each other, mergeInboxNotices) draw
// as one group in the look of the waiting messages' group (PendingMessagesCard.tsx): one line, "N
// messages from M sessions" and the senders muted, that opens to the threads' lines; the versions
// as one group: one line, "N messages from M sessions" and the senders muted, a sibling of the tool rows (the tool row's box, its
// glyph the sender's mark in their icon column), that opens to the threads' lines; the versions
// beside it are inboxNoticeLook.ts's. Whether a line or the group is open lasts while the page does
// (incomingOpened.ts), and the row estimator (model.ts) reads it. Everything opens on the click,
// with no fade in.
import { useContext, useEffect, useId, useLayoutEffect, useMemo, useState, type ReactNode } from "react";
import { useT } from "../i18n";
import { Icon } from "../icons/Icon";
import { rowIconSize } from "../icons/iconSize";
import { DisclosureRow } from "../subagents/DisclosureRow";
import { PromptMarksSession } from "../threadMinimap/promptMarks";
import { mentionedRepository } from "../../../githubReferences";
import { CardCopy } from "./CardCopy";
import { FindReveal, foldFind } from "./chatFind";
import { SenderName } from "./IncomingMessageCard";
import { MessageGroupHead } from "./MessageGroupHead";
import { messageOpened, setMessageOpened } from "./incomingOpened";
import type { IncomingMessage } from "./incomingMessage";
import { inboxCounts, inboxThreadKey, type InboxMessage, type InboxNotice, type InboxThread } from "./inboxNotice";
import { useInboxNoticeLook } from "./inboxNoticeLook";
import { foldsThreads, useInboxNoticeLook } from "./inboxNoticeLook";
import { Markdown } from "./Markdown";
import "./PendingMessagesCard.css";
import "./InboxNoticeCard.css";

const ROW_ICON = rowIconSize(12);

/// Reads a thread's messages, oldest first (App.tsx `readInboxThread`, `chat.inbox.thread`).
export type ReadInboxThread = (threadId: string) => Promise<InboxMessage[]>;

Expand Down Expand Up @@ -96,8 +92,14 @@ function InboxThreadLine({
}, [open, own, fetched, readFor, readThread, thread]);
const name = newestSender(thread);
const sender = useMemo<IncomingMessage>(
() => ({ name, text: "", raw: "", ...(thread.fromSession && { sessionId: thread.fromSession }) }),
[name, thread.fromSession],
() => ({
name,
text: "",
raw: "",
...(thread.fromSession && { sessionId: thread.fromSession }),
...(thread.fromHarness && { harness: thread.fromHarness }),
}),
[name, thread.fromSession, thread.fromHarness],
);
const whole = messages?.map((m) => m.text).join("\n\n") ?? thread.preview;
const repository = githubRepository ?? mentionedRepository(whole);
Expand Down Expand Up @@ -185,7 +187,7 @@ export function InboxNoticeCard({
setOpen(next);
};
const bodyId = useId();
const grouped = notice.threads.length > 1 && look === "group";
const grouped = notice.threads.length > 1 && foldsThreads(look);
// Find opens the group for a match in one of its threads; the thread's line opens itself.
const reveal = useContext(FindReveal);
const holdsMatch = grouped && Boolean(reveal && reveal.rowId === rowId);
Expand Down Expand Up @@ -216,27 +218,33 @@ export function InboxNoticeCard({
senders.length === 1
? t("message.waitingFromOne", { n: messages })
: t("message.waitingFrom", { n: messages, sessions: senders.length });
// One sender: its mark, from its newest thread (its session and bound harness); several: the
// messages glyph.
const lead = [...notice.threads].reverse().find((thread) => newestSender(thread) === senders[0]);
return (
<div className="cv-pending cv-inbox" data-open={open || undefined}>
<div className="cv-inbox cv-inbox--group" data-look={look} data-open={open || undefined}>
{/* Find counts the messages' words, not the head's names. */}
<div className="cv-pending__line" data-find-skip="">
<button
type="button"
className="cv-pending__toggle"
aria-expanded={open}
aria-controls={open ? bodyId : undefined}
title={senders.join(", ")}
onClick={() => show(!open)}
>
<span className="cv-pending__icon">
<Icon name={open ? "disclosure.expanded" : "disclosure.collapsed"} size={ROW_ICON} row />
</span>
<span className="cv-pending__count">{count}</span>
<span className="cv-pending__senders">{senders.join(", ")}</span>
</button>
</div>
<MessageGroupHead
sender={
senders.length === 1 && lead
? {
name: senders[0]!,
text: "",
raw: "",
...(lead.fromSession && { sessionId: lead.fromSession }),
...(lead.fromHarness && { harness: lead.fromHarness }),
}
: undefined
}
count={count}
senders={senders.join(", ")}
open={open}
bodyId={bodyId}
onToggle={() => show(!open)}
className="cv-inbox__head"
/>
{open && (
<div id={bodyId} className="cv-pending__body">
<div id={bodyId} className="cv-inbox__body">
{lines}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
// fuck do you even have this instead of just being able to click on the fucking name of the thing,
// the open chat? ... Just click the left name thing."). The rest of the line opens the message.
// The sender's mark is its harness's: the listed chat's, else the one the message names (a peer's
// relayed message, `fromHarness`), else its name's (agentBrand), and a small bot only when none
// says (Leo, 2026-10-29: "why are some of these icons still only placeholders?"). Reply, at the line's end,
// relayed message, `fromHarness`), and a small bot when neither says, never a mark guessed from the
// sender's name (a Codex lane called `claude-…` drew Claude's; cmux#30247). Reply, at the line's end,
// answers in place: the answer goes to the sending chat in the message's thread
// (AcpmuxDirectClient.reply), from an editor in the panel. An open reply and its draft last while
// the page does; closing the message closes the reply and keeps the draft.
Expand Down Expand Up @@ -67,36 +67,24 @@ const RELATION_LABEL: Record<IncomingRelation, StringKey> = {
/// A harness logo's size in the sender's glyph slot (see SenderName).
const SENDER_LOGO_SIZE = 12;

/// The sender's mark and name at a received line's start (and a waiting group's sender head,
/// PendingMessagesCard.tsx): the name opens the sender's chat when there is one to open.
export function SenderName({ message, onOpen }: { message: IncomingMessage; onOpen(sessionId: string): void }) {
const t = useT();
// A Chief's message names the Chief and draws its avatar (chiefFace.ts); its turn session is no
// chat of the list, so no listed chat with its name stands in for it, and it is no chat to open.
/// The harness whose mark a sender draws: the listed chat's, else the one the message names (acpmux's
/// bound `fromHarness`), the first of them with a mark today, else that harness id itself, so a
/// profile icon that arrives later still replaces the fallback. Never one guessed from the sender's
/// name: a Codex lane called `claude-…` drew Claude's mark (cmux#30247). With neither, undefined,
/// and the line draws the neutral agent glyph.
export function senderHarness(listed: string | undefined, named: string | undefined): string | undefined {
return knownMark(listed, named, listed ?? named);
}

/// The sender's mark alone (a received line's, an inbox group's): its avatar, a Chief's face, else
/// its harness's mark, else the neutral agent glyph in its own stable tone.
export function SenderMark({ message }: { message: IncomingMessage }) {
const chief = useChiefFace(message.chief);
const name = (message.chief && chief?.name) || message.name;
const sender = useSenderChat(message.chief ? "" : message.name, message.sessionId);
// Read only (a guest): the sender's chat is not this reader's to open.
const readOnly = useTranscriptReadOnly();
const sessionId = message.chief || readOnly ? undefined : sender.sessionId;
const avatar = useAgentAvatar(sender.sessionId);
const notice = laneNotice(message);
const inboxNames =
notice?.kind === "inbox"
? [...new Set((notice.threads ?? []).map(({ sender }) => sender).filter(Boolean))].join(", ")
: "";
const from = inboxNames || (message.relation ? t(RELATION_LABEL[message.relation], { name }) : name);
// The first candidate with a mark today; with none, the sender's own harness id, so a profile icon
// that arrives later still replaces the fallback (AgentIdentity stays subscribed to that id).
const harness = knownMark(
sender.harness,
message.harness,
message.name,
message.name.split("/")[0],
sender.harness ?? message.harness,
);
const harness = senderHarness(sender.harness, message.harness);
const picture = message.chief ? chief?.src : avatar;
const icon = picture ? (
return picture ? (
<img className="cv-agent-avatar" src={picture} alt="" width={16} height={16} />
) : message.chief && chief?.text ? (
<span className="cv-chief-text-avatar" aria-hidden="true">
Expand All @@ -114,6 +102,27 @@ export function SenderName({ message, onOpen }: { message: IncomingMessage; onOp
fallbackNode={<Bot size={16} />}
/>
);
}

/// The sender's mark and name at a received line's start (and a waiting group's sender head,
/// PendingMessagesCard.tsx): the name opens the sender's chat when there is one to open.
export function SenderName({ message, onOpen }: { message: IncomingMessage; onOpen(sessionId: string): void }) {
const t = useT();
// A Chief's message names the Chief and draws its avatar (chiefFace.ts); its turn session is no
// chat of the list, so no listed chat with its name stands in for it, and it is no chat to open.
const chief = useChiefFace(message.chief);
const name = (message.chief && chief?.name) || message.name;
const sender = useSenderChat(message.chief ? "" : message.name, message.sessionId);
// Read only (a guest): the sender's chat is not this reader's to open.
const readOnly = useTranscriptReadOnly();
const sessionId = message.chief || readOnly ? undefined : sender.sessionId;
const notice = laneNotice(message);
const inboxNames =
notice?.kind === "inbox"
? [...new Set((notice.threads ?? []).map(({ sender }) => sender).filter(Boolean))].join(", ")
: "";
const from = inboxNames || (message.relation ? t(RELATION_LABEL[message.relation], { name }) : name);
const icon = <SenderMark message={message} />;
// "Open chief-ui (huge-blue)": the chat the name opens, and the other machine it runs on.
const openTitle = sender.host
? t("subagents.openOnHost", { name, host: sender.host })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
font: inherit;
color: var(--cv-text);
}
/* An inbox batch's head is a tool row (MessageGroupHead.css); the batch is its narrow-pane container. */
.cv-lane-batch[data-kind="inbox"] {
container-type: inline-size;
}
.cv-lane-batch__toggle {
display: flex;
box-sizing: border-box;
Expand Down
Loading
Loading