diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 1c64e2833c4f..60dc18a4ba1e 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -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 } : {}), }, ], }} diff --git a/webviews/src/agent-session/acpmux/conversation/InboxNotice.gallery.ts b/webviews/src/agent-session/acpmux/conversation/InboxNotice.gallery.ts index b23de489422c..d9d93cc95102 100644 --- a/webviews/src/agent-session/acpmux/conversation/InboxNotice.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/InboxNotice.gallery.ts @@ -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"; @@ -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. @@ -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(".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), diff --git a/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.css b/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.css index 534ff08794ef..768cb577f441 100644 --- a/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.css +++ b/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.css @@ -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); @@ -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); +} diff --git a/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.tsx b/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.tsx index 580985e2bd78..9222d872454a 100644 --- a/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.tsx +++ b/webviews/src/agent-session/acpmux/conversation/InboxNoticeCard.tsx @@ -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; @@ -96,8 +92,14 @@ function InboxThreadLine({ }, [open, own, fetched, readFor, readThread, thread]); const name = newestSender(thread); const sender = useMemo( - () => ({ 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); @@ -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); @@ -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 ( -
+
{/* Find counts the messages' words, not the head's names. */} -
- -
+ show(!open)} + className="cv-inbox__head" + /> {open && ( -
+
{lines}
)} diff --git a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx index 82dacf508ae4..f6977f8016a9 100644 --- a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx +++ b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx @@ -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. @@ -67,36 +67,24 @@ const RELATION_LABEL: Record = { /// 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 ? ( ) : message.chief && chief?.text ? (