From c9ab8aa3e85b06b006e3b20d064fbfade2f8af03 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 01:01:24 -0800 Subject: [PATCH 1/2] Agent pane: a chat the Chief or another agent runs says who, with Take over A chat a Chief started (its mux.parent tag) or another chat's agent started (startedBy, spawnedBy) shows " is managing this chat" over the composer with a Take over button, as ChatGPT does for a chat its agent runs. The name opens the managing chat; Take over hides the line for that chat and focuses the composer. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-22a95c35d6aeabe23acf --- webviews/src/agent-session/acpmux/App.tsx | 24 +++++++ .../acpmux/Localizable.xcstrings | 2 + .../acpmux/ManagedBanner.gallery.tsx | 36 ++++++++++ .../agent-session/acpmux/ManagedBanner.tsx | 41 +++++++++++ .../agent-session/acpmux/managedBanner.css | 10 +++ .../agent-session/acpmux/managedBy.test.ts | 59 +++++++++++++++ .../src/agent-session/acpmux/managedBy.ts | 71 +++++++++++++++++++ .../src/agent-session/acpmux/sessionList.ts | 14 ++++ 8 files changed, 257 insertions(+) create mode 100644 webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx create mode 100644 webviews/src/agent-session/acpmux/ManagedBanner.tsx create mode 100644 webviews/src/agent-session/acpmux/managedBanner.css create mode 100644 webviews/src/agent-session/acpmux/managedBy.test.ts create mode 100644 webviews/src/agent-session/acpmux/managedBy.ts diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 83c4053ed401..b349ddce8c0c 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -535,6 +535,8 @@ import { NewTabFolderAsk } from "./NewTabFolderAsk"; import { MissingFolder, chooseChatFolder } from "./missingFolder"; import { LiveChatChoice } from "./LiveChatChoice"; import { WatchBanner } from "./WatchBanner"; +import { ManagedBanner } from "./ManagedBanner"; +import { isTakenOver, managedBy, takeOverManaged } from "./managedBy"; import { HandoffReviewMessage } from "./handoff/ReviewMessage"; import { handoffStrings } from "./handoff/strings"; import type { HandoffReviewInput } from "./handoff/review"; @@ -5776,6 +5778,14 @@ function AcpmuxPane() { const doneLines = useRef(new Map()); const childDoneLines = useRef(new Map()); const shownSessionId = snapshot.sessionId ?? snapshot.summary?.sessionId; + // Who runs this chat when it is not the person (managedBy.ts): the line over the composer. + const [takenOverAt, setTakenOverAt] = useState(0); + const manager = useMemo( + () => (isTakenOver(shownSessionId) ? undefined : managedBy(shownSessionId, snapshot.sessions)), + // takenOverAt: the person's Take over, read back from the store. + // eslint-disable-next-line react-hooks/exhaustive-deps + [shownSessionId, snapshot.sessions, takenOverAt], + ); const freshAgents = useMemo( () => [...subagentsIn(snapshot.rows, snapshot.subagentViews).values()], [snapshot.rows, snapshot.subagentViews], @@ -11279,6 +11289,20 @@ function AcpmuxPane() { + {/* A chat the Chief or another chat's agent runs: who, and Take over (ManagedBanner.tsx). */} + + {manager && shownSessionId && ( + { + takeOverManaged(shownSessionId); + setTakenOverAt(Date.now()); + composerHandle.current?.focus(); + }} + /> + )} + {/* The tray (subagents/SubagentTray.tsx): in the bottom stack, right above the composer, every running or recently ended subagent, background command and started chat, one card each. */} {trayPlacement === "tray" && trayNode} diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index f768b3b5bd17..e7b9b5fab592 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,8 @@ { "sourceLanguage": "en", "strings": { + "managed.banner": {"comment":"Agent pane: the line over the composer of a chat the Chief or another chat's agent runs; {name} is that Chief or chat (shown bold)","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"{name} يدير هذه المحادثة"}},"bs":{"stringUnit":{"state":"needs_review","value":"{name} upravlja ovim razgovorom"}},"da":{"stringUnit":{"state":"needs_review","value":"{name} styrer denne chat"}},"de":{"stringUnit":{"state":"needs_review","value":"{name} führt diesen Chat"}},"en":{"stringUnit":{"state":"translated","value":"{name} is managing this chat"}},"es":{"stringUnit":{"state":"needs_review","value":"{name} gestiona este chat"}},"fr":{"stringUnit":{"state":"needs_review","value":"{name} gère cette discussion"}},"it":{"stringUnit":{"state":"needs_review","value":"{name} gestisce questa chat"}},"ja":{"stringUnit":{"state":"needs_review","value":"{name} がこのチャットを管理しています"}},"km":{"stringUnit":{"state":"needs_review","value":"{name} កំពុងគ្រប់គ្រងការជជែកនេះ"}},"ko":{"stringUnit":{"state":"needs_review","value":"{name}이(가) 이 채팅을 관리하고 있습니다"}},"nb":{"stringUnit":{"state":"needs_review","value":"{name} styrer denne chatten"}},"pl":{"stringUnit":{"state":"needs_review","value":"{name} zarządza tym czatem"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"{name} está gerenciando este chat"}},"ru":{"stringUnit":{"state":"needs_review","value":"{name} ведёт этот чат"}},"th":{"stringUnit":{"state":"needs_review","value":"{name} กำลังจัดการแชทนี้"}},"tr":{"stringUnit":{"state":"needs_review","value":"{name} bu sohbeti yönetiyor"}},"uk":{"stringUnit":{"state":"needs_review","value":"{name} веде цей чат"}},"vi":{"stringUnit":{"state":"needs_review","value":"{name} đang quản lý cuộc trò chuyện này"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"{name} 正在管理此聊天"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"{name} 正在管理此聊天"}}}}, + "managed.takeOver": {"comment":"Agent pane: the button beside 'X is managing this chat' that hands the chat to the person","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"تولَّ الأمر"}},"bs":{"stringUnit":{"state":"needs_review","value":"Preuzmi"}},"da":{"stringUnit":{"state":"needs_review","value":"Overtag"}},"de":{"stringUnit":{"state":"needs_review","value":"Übernehmen"}},"en":{"stringUnit":{"state":"translated","value":"Take over"}},"es":{"stringUnit":{"state":"needs_review","value":"Tomar el control"}},"fr":{"stringUnit":{"state":"needs_review","value":"Reprendre la main"}},"it":{"stringUnit":{"state":"needs_review","value":"Prendi il controllo"}},"ja":{"stringUnit":{"state":"needs_review","value":"引き継ぐ"}},"km":{"stringUnit":{"state":"needs_review","value":"យកមកគ្រប់គ្រង"}},"ko":{"stringUnit":{"state":"needs_review","value":"직접 맡기"}},"nb":{"stringUnit":{"state":"needs_review","value":"Ta over"}},"pl":{"stringUnit":{"state":"needs_review","value":"Przejmij"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Assumir"}},"ru":{"stringUnit":{"state":"needs_review","value":"Взять на себя"}},"th":{"stringUnit":{"state":"needs_review","value":"รับช่วงต่อ"}},"tr":{"stringUnit":{"state":"needs_review","value":"Devral"}},"uk":{"stringUnit":{"state":"needs_review","value":"Перейняти"}},"vi":{"stringUnit":{"state":"needs_review","value":"Tiếp quản"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"接管"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"接管"}}}}, "prompt.failure.tooLarge": {"comment":"Agent pane: under a prompt refused because it was larger than one message can carry (inline attachments); Retry is not offered, Edit is","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Not sent: this message is too large to send at once. Take out an attachment with Edit, or send the attachments in separate messages."}},"ar":{"stringUnit":{"state":"needs_review","value":"لم تُرسَل: هذه الرسالة أكبر من أن تُرسَل دفعة واحدة. أزِل مرفقًا عبر «تعديل»، أو أرسل المرفقات في رسائل منفصلة."}},"bs":{"stringUnit":{"state":"needs_review","value":"Nije poslano: ova poruka je prevelika da bi se poslala odjednom. Uklonite prilog pomoću Uredi ili pošaljite priloge u zasebnim porukama."}},"da":{"stringUnit":{"state":"needs_review","value":"Ikke sendt: denne besked er for stor til at blive sendt på én gang. Fjern en vedhæftning med Rediger, eller send vedhæftningerne i separate beskeder."}},"de":{"stringUnit":{"state":"needs_review","value":"Nicht gesendet: Diese Nachricht ist zu groß, um auf einmal gesendet zu werden. Entferne mit Bearbeiten einen Anhang oder sende die Anhänge in getrennten Nachrichten."}},"es":{"stringUnit":{"state":"needs_review","value":"No enviado: este mensaje es demasiado grande para enviarlo de una vez. Quita un adjunto con Editar o envía los adjuntos en mensajes separados."}},"fr":{"stringUnit":{"state":"needs_review","value":"Non envoyé : ce message est trop volumineux pour être envoyé en une fois. Retirez une pièce jointe avec Modifier, ou envoyez les pièces jointes dans des messages séparés."}},"it":{"stringUnit":{"state":"needs_review","value":"Non inviato: questo messaggio è troppo grande per essere inviato in una volta. Rimuovi un allegato con Modifica o invia gli allegati in messaggi separati."}},"ja":{"stringUnit":{"state":"needs_review","value":"未送信:このメッセージは一度に送信するには大きすぎます。「編集」で添付ファイルを外すか、添付ファイルを別々のメッセージで送信してください。"}},"km":{"stringUnit":{"state":"needs_review","value":"មិនបានផ្ញើ៖ សារនេះធំពេកមិនអាចផ្ញើក្នុងពេលតែមួយបានទេ។ ដកឯកសារភ្ជាប់មួយចេញដោយប្រើ កែសម្រួល ឬផ្ញើឯកសារភ្ជាប់ក្នុងសារដាច់ដោយឡែក។"}},"ko":{"stringUnit":{"state":"needs_review","value":"보내지 않음: 이 메시지는 한 번에 보내기에 너무 큽니다. 편집으로 첨부 파일을 빼거나 첨부 파일을 별도의 메시지로 보내세요."}},"nb":{"stringUnit":{"state":"needs_review","value":"Ikke sendt: denne meldingen er for stor til å sendes på én gang. Fjern et vedlegg med Rediger, eller send vedleggene i separate meldinger."}},"pl":{"stringUnit":{"state":"needs_review","value":"Nie wysłano: ta wiadomość jest za duża, by wysłać ją naraz. Usuń załącznik przez Edytuj albo wyślij załączniki w osobnych wiadomościach."}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Não enviada: esta mensagem é grande demais para ser enviada de uma vez. Remova um anexo com Editar ou envie os anexos em mensagens separadas."}},"ru":{"stringUnit":{"state":"needs_review","value":"Не отправлено: это сообщение слишком велико, чтобы отправить его за раз. Уберите вложение через «Изменить» или отправьте вложения отдельными сообщениями."}},"th":{"stringUnit":{"state":"needs_review","value":"ไม่ได้ส่ง: ข้อความนี้ใหญ่เกินกว่าจะส่งในครั้งเดียว นำไฟล์แนบออกด้วย แก้ไข หรือส่งไฟล์แนบแยกเป็นหลายข้อความ"}},"tr":{"stringUnit":{"state":"needs_review","value":"Gönderilmedi: bu ileti tek seferde gönderilemeyecek kadar büyük. Düzenle ile bir eki çıkarın ya da ekleri ayrı iletilerde gönderin."}},"uk":{"stringUnit":{"state":"needs_review","value":"Не надіслано: це повідомлення завелике, щоб надіслати його за раз. Приберіть вкладення через «Редагувати» або надішліть вкладення окремими повідомленнями."}},"vi":{"stringUnit":{"state":"needs_review","value":"Chưa gửi: tin nhắn này quá lớn để gửi một lần. Gỡ một tệp đính kèm bằng Sửa, hoặc gửi các tệp đính kèm trong các tin nhắn riêng."}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"未发送:此消息太大,无法一次发送。请用“编辑”移除一个附件,或将附件分成多条消息发送。"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"未傳送:此訊息太大,無法一次傳送。請用「編輯」移除一個附件,或將附件分成多則訊息傳送。"}}}}, "prompt.failure.tooLargeFile": {"comment":"Agent pane: under a prompt refused because it was larger than one message can carry; {name} is the attachment that takes the most of it. Retry is not offered, Edit is","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Not sent: this message is too large to send at once. Take out {name} with Edit, or send it in a message of its own."}},"ar":{"stringUnit":{"state":"needs_review","value":"لم تُرسَل: هذه الرسالة أكبر من أن تُرسَل دفعة واحدة. أزِل {name} عبر «تعديل»، أو أرسله في رسالة مستقلة."}},"bs":{"stringUnit":{"state":"needs_review","value":"Nije poslano: ova poruka je prevelika da bi se poslala odjednom. Uklonite {name} pomoću Uredi ili ga pošaljite u zasebnoj poruci."}},"da":{"stringUnit":{"state":"needs_review","value":"Ikke sendt: denne besked er for stor til at blive sendt på én gang. Fjern {name} med Rediger, eller send den i en besked for sig."}},"de":{"stringUnit":{"state":"needs_review","value":"Nicht gesendet: Diese Nachricht ist zu groß, um auf einmal gesendet zu werden. Entferne {name} mit Bearbeiten oder sende es in einer eigenen Nachricht."}},"es":{"stringUnit":{"state":"needs_review","value":"No enviado: este mensaje es demasiado grande para enviarlo de una vez. Quita {name} con Editar o envíalo en un mensaje aparte."}},"fr":{"stringUnit":{"state":"needs_review","value":"Non envoyé : ce message est trop volumineux pour être envoyé en une fois. Retirez {name} avec Modifier, ou envoyez-le dans un message à part."}},"it":{"stringUnit":{"state":"needs_review","value":"Non inviato: questo messaggio è troppo grande per essere inviato in una volta. Rimuovi {name} con Modifica o invialo in un messaggio a parte."}},"ja":{"stringUnit":{"state":"needs_review","value":"未送信:このメッセージは一度に送信するには大きすぎます。「編集」で {name} を外すか、別のメッセージで送信してください。"}},"km":{"stringUnit":{"state":"needs_review","value":"មិនបានផ្ញើ៖ សារនេះធំពេកមិនអាចផ្ញើក្នុងពេលតែមួយបានទេ។ ដក {name} ចេញដោយប្រើ កែសម្រួល ឬផ្ញើវាក្នុងសារដាច់ដោយឡែក។"}},"ko":{"stringUnit":{"state":"needs_review","value":"보내지 않음: 이 메시지는 한 번에 보내기에 너무 큽니다. 편집으로 {name}을(를) 빼거나 별도의 메시지로 보내세요."}},"nb":{"stringUnit":{"state":"needs_review","value":"Ikke sendt: denne meldingen er for stor til å sendes på én gang. Fjern {name} med Rediger, eller send den i en egen melding."}},"pl":{"stringUnit":{"state":"needs_review","value":"Nie wysłano: ta wiadomość jest za duża, by wysłać ją naraz. Usuń {name} przez Edytuj albo wyślij to w osobnej wiadomości."}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Não enviada: esta mensagem é grande demais para ser enviada de uma vez. Remova {name} com Editar ou envie em uma mensagem separada."}},"ru":{"stringUnit":{"state":"needs_review","value":"Не отправлено: это сообщение слишком велико, чтобы отправить его за раз. Уберите {name} через «Изменить» или отправьте отдельным сообщением."}},"th":{"stringUnit":{"state":"needs_review","value":"ไม่ได้ส่ง: ข้อความนี้ใหญ่เกินกว่าจะส่งในครั้งเดียว นำ {name} ออกด้วย แก้ไข หรือส่งแยกเป็นข้อความของมันเอง"}},"tr":{"stringUnit":{"state":"needs_review","value":"Gönderilmedi: bu ileti tek seferde gönderilemeyecek kadar büyük. Düzenle ile {name} ögesini çıkarın ya da ayrı bir iletide gönderin."}},"uk":{"stringUnit":{"state":"needs_review","value":"Не надіслано: це повідомлення завелике, щоб надіслати його за раз. Приберіть {name} через «Редагувати» або надішліть окремим повідомленням."}},"vi":{"stringUnit":{"state":"needs_review","value":"Chưa gửi: tin nhắn này quá lớn để gửi một lần. Gỡ {name} bằng Sửa, hoặc gửi nó trong một tin nhắn riêng."}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"未发送:此消息太大,无法一次发送。请用“编辑”移除 {name},或单独发送它。"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"未傳送:此訊息太大,無法一次傳送。請用「編輯」移除 {name},或單獨傳送它。"}}}}, "host.tooLarge": {"comment":"Agent pane: the line above the composer when a chat could not be opened because acpmux's reply was larger than one message; the pane no longer retries on its own, the line's Retry does","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This chat is too large to open. Update acpmux, then Retry."}},"ar":{"stringUnit":{"state":"needs_review","value":"هذه المحادثة أكبر من أن تُفتح. حدّث acpmux، ثم أعد المحاولة."}},"bs":{"stringUnit":{"state":"needs_review","value":"Ovaj chat je prevelik za otvaranje. Ažurirajte acpmux, pa pokušajte ponovo."}},"da":{"stringUnit":{"state":"needs_review","value":"Denne chat er for stor til at åbne. Opdater acpmux, og prøv igen."}},"de":{"stringUnit":{"state":"needs_review","value":"Dieser Chat ist zu groß zum Öffnen. Aktualisiere acpmux und versuche es erneut."}},"es":{"stringUnit":{"state":"needs_review","value":"Este chat es demasiado grande para abrirlo. Actualiza acpmux y vuelve a intentarlo."}},"fr":{"stringUnit":{"state":"needs_review","value":"Cette discussion est trop volumineuse pour s’ouvrir. Mettez à jour acpmux, puis réessayez."}},"it":{"stringUnit":{"state":"needs_review","value":"Questa chat è troppo grande per essere aperta. Aggiorna acpmux, poi riprova."}},"ja":{"stringUnit":{"state":"needs_review","value":"このチャットは大きすぎて開けません。acpmux を更新してから再試行してください。"}},"km":{"stringUnit":{"state":"needs_review","value":"ការជជែកនេះធំពេកមិនអាចបើកបានទេ។ ធ្វើបច្ចុប្បន្នភាព acpmux រួចព្យាយាមម្ដងទៀត។"}},"ko":{"stringUnit":{"state":"needs_review","value":"이 채팅은 너무 커서 열 수 없습니다. acpmux를 업데이트한 다음 다시 시도하세요."}},"nb":{"stringUnit":{"state":"needs_review","value":"Denne chatten er for stor til å åpnes. Oppdater acpmux, og prøv igjen."}},"pl":{"stringUnit":{"state":"needs_review","value":"Ten czat jest za duży, by go otworzyć. Zaktualizuj acpmux, a potem spróbuj ponownie."}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Este chat é grande demais para abrir. Atualize o acpmux e tente novamente."}},"ru":{"stringUnit":{"state":"needs_review","value":"Этот чат слишком велик, чтобы его открыть. Обновите acpmux и повторите попытку."}},"th":{"stringUnit":{"state":"needs_review","value":"แชทนี้ใหญ่เกินกว่าจะเปิดได้ อัปเดต acpmux แล้วลองอีกครั้ง"}},"tr":{"stringUnit":{"state":"needs_review","value":"Bu sohbet açılamayacak kadar büyük. acpmux'u güncelleyip yeniden deneyin."}},"uk":{"stringUnit":{"state":"needs_review","value":"Цей чат завеликий, щоб його відкрити. Оновіть acpmux, а тоді повторіть спробу."}},"vi":{"stringUnit":{"state":"needs_review","value":"Cuộc trò chuyện này quá lớn để mở. Hãy cập nhật acpmux rồi thử lại."}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"此聊天太大,无法打开。请更新 acpmux,然后重试。"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"此聊天太大,無法開啟。請更新 acpmux,然後重試。"}}}}, diff --git a/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx b/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx new file mode 100644 index 000000000000..e8303027b8e5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx @@ -0,0 +1,36 @@ +// l10n-allow-file: gallery fixtures (sample chat names), not shipped UI. +import { componentEntry } from "../../gallery/format"; +import type { ManagedBanner } from "./ManagedBanner"; + +type Props = Parameters[0]; + +const onTakeOver = () => undefined; +const onOpenManager = () => undefined; + +export default componentEntry({ + id: "agent-pane.managed-banner", + title: "A chat the Chief or another agent runs", + area: "Agent pane", + height: 120, + covers: ["agent-session/acpmux/ManagedBanner.tsx#ManagedBanner"], + pane: true, + load: () => import("./ManagedBanner").then((module) => module.ManagedBanner), + variants: { + chief: { + props: { manager: { kind: "chief" }, onTakeOver }, + note: "A Chief's subagent: Chief is managing this chat, Take over", + }, + agent: { + props: { manager: { kind: "agent", name: "claude-sr2-coord", sessionId: "s-coord" }, onTakeOver, onOpenManager }, + note: "A sub-session another chat started: its name opens that chat", + }, + "long-name": { + props: { + manager: { kind: "agent", name: "Land the group chat guest composer and approvals across Home and New Tab", sessionId: "s-long" }, + onTakeOver, + onOpenManager, + }, + note: "A long chat title is cut with an ellipsis; Take over keeps its size", + }, + }, +}); diff --git a/webviews/src/agent-session/acpmux/ManagedBanner.tsx b/webviews/src/agent-session/acpmux/ManagedBanner.tsx new file mode 100644 index 000000000000..cba770c4f1bf --- /dev/null +++ b/webviews/src/agent-session/acpmux/ManagedBanner.tsx @@ -0,0 +1,41 @@ +import { useT } from "./i18n"; +import type { ManagedBy } from "./managedBy"; +import "./managedBanner.css"; + +/// The line over the composer of a chat the Chief or another chat's agent runs (managedBy.ts): +/// " is managing this chat", the name bold, and Take over, as ChatGPT shows a chat its agent +/// runs (Leo, 2026-11-01). Its name opens the managing chat when it is one here. Take over hides the +/// line for this chat; the person's own prompt is already theirs to send. +export function ManagedBanner({ + manager, + onTakeOver, + onOpenManager, +}: { + manager: ManagedBy; + onTakeOver(): void; + onOpenManager?(sessionId: string): void; +}) { + const t = useT(); + // l10n-allow: Chief is the product's name for it, never translated. + const name = manager.kind === "chief" ? "Chief" : manager.name; + const [before, after = ""] = t("managed.banner", { name: "\u0000" }).split("\u0000"); + const opens = manager.kind === "agent" && onOpenManager ? manager.sessionId : undefined; + return ( +
+ + {before} + {opens ? ( + + ) : ( + {name} + )} + {after} + + +
+ ); +} diff --git a/webviews/src/agent-session/acpmux/managedBanner.css b/webviews/src/agent-session/acpmux/managedBanner.css new file mode 100644 index 000000000000..8ed1b81d6d32 --- /dev/null +++ b/webviews/src/agent-session/acpmux/managedBanner.css @@ -0,0 +1,10 @@ +/* ManagedBanner.tsx: one quiet line over the composer, centered on the conversation column, with a + pill button, the way ChatGPT shows a chat its agent runs. */ +.acpmux-managed-banner{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 auto;padding:6px var(--cv-inset,max(18px,calc((100% - 760px)/2))) 8px;color:var(--agent-muted);font-size:13px;line-height:18px;min-width:0} +.acpmux-managed-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.acpmux-managed-name{color:var(--agent-text);font-weight:600} +button.acpmux-managed-name{border:0;padding:0;background:none;font:inherit;font-weight:600;cursor:pointer} +button.acpmux-managed-name:hover{text-decoration:underline} +.acpmux-managed-take-over{flex:none;border:0;border-radius:999px;padding:4px 12px;background:var(--agent-text);color:var(--agent-page-bg);font:inherit;font-weight:500;cursor:pointer} +.acpmux-managed-take-over:hover{background:color-mix(in srgb,var(--agent-text) 86%,var(--agent-page-bg))} +.acpmux-managed-banner button:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:2px} diff --git a/webviews/src/agent-session/acpmux/managedBy.test.ts b/webviews/src/agent-session/acpmux/managedBy.test.ts new file mode 100644 index 000000000000..e8a5c47275a7 --- /dev/null +++ b/webviews/src/agent-session/acpmux/managedBy.test.ts @@ -0,0 +1,59 @@ +// Who manages a chat (managedBy.ts): the Chief for a chat whose `mux.parent` names a Chief, the +// starting chat for a sub-session or a chat another agent made, nobody for the person's own chat; +// and Take over is remembered per chat. +import { expect, test } from "bun:test"; +import { isTakenOver, managedBy, takeOverManaged } from "./managedBy"; +import { sessionEntry } from "./sessionList"; + +const entry = (sessionId: string, extra: Record = {}) => sessionEntry({ sessionId, ...extra }); + +test("a Chief's subagent is managed by the Chief", () => { + const sessions = [entry("kid", { tags: { "mux.parent": "optchat-chief:6b68ac4a", "optchat.subagent": "a31" } })]; + expect(sessions[0]!.chiefChild).toBe(true); + expect(managedBy("kid", sessions)).toEqual({ kind: "chief" }); +}); + +test("a bare optchat-chief parent counts; another app's mux.parent does not", () => { + expect(entry("a", { tags: { "mux.parent": "optchat-chief" } }).chiefChild).toBe(true); + expect(entry("b", { tags: { "mux.parent": "optchat-chiefly" } }).chiefChild).toBeUndefined(); + expect(entry("c", { tags: { "mux.parent": "lane-host" } }).chiefChild).toBeUndefined(); + expect(managedBy("c", [entry("c", { tags: { "mux.parent": "lane-host" } })])).toBeUndefined(); +}); + +test("a sub-session is managed by the chat that started it, by that chat's title", () => { + const sessions = [entry("coord", { title: "claude-sr2-coord" }), entry("lane", { startedBy: "coord" })]; + expect(managedBy("lane", sessions)).toEqual({ kind: "agent", name: "claude-sr2-coord", sessionId: "coord" }); +}); + +test("a chat another agent made is managed by that agent's chat", () => { + const sessions = [entry("p", { name: "planner" }), entry("c", { spawnedBy: "p" })]; + expect(managedBy("c", sessions)).toMatchObject({ kind: "agent", sessionId: "p" }); +}); + +test("the person's own chat, a missing chat and an unlisted manager show nothing", () => { + const sessions = [entry("mine"), entry("orphan", { startedBy: "gone" }), entry("self", { startedBy: "self" })]; + expect(managedBy("mine", sessions)).toBeUndefined(); + expect(managedBy("orphan", sessions)).toBeUndefined(); + expect(managedBy("self", sessions)).toBeUndefined(); + expect(managedBy("nope", sessions)).toBeUndefined(); + expect(managedBy(undefined, sessions)).toBeUndefined(); +}); + +test("Take over is remembered for that chat only", () => { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => void data.set(key, value), + } as unknown as Storage; + expect(isTakenOver("kid", storage)).toBe(false); + takeOverManaged("kid", storage); + takeOverManaged("kid", storage); + expect(isTakenOver("kid", storage)).toBe(true); + expect(isTakenOver("other", storage)).toBe(false); + expect(JSON.parse(data.get("cmux.acpmux.takenOver")!)).toEqual(["kid"]); +}); + +test("a corrupt store reads as nothing taken over", () => { + const storage = { getItem: () => "{not json" } as unknown as Storage; + expect(isTakenOver("kid", storage)).toBe(false); +}); diff --git a/webviews/src/agent-session/acpmux/managedBy.ts b/webviews/src/agent-session/acpmux/managedBy.ts new file mode 100644 index 000000000000..668983a5484c --- /dev/null +++ b/webviews/src/agent-session/acpmux/managedBy.ts @@ -0,0 +1,71 @@ +// Who manages a chat: the Chief or the agent that started it. A chat a Chief started carries the +// Chief's `mux.parent` tag (sessionList.ts `chiefChild`); one another chat's agent started is a +// sub-session (`startedBy`) or a chat that agent made (`spawnedBy`). The pane says so over the +// composer ("Chief is managing this chat"), with Take over, as ChatGPT does for a chat its agent +// runs (Leo, 2026-11-01: "they even show chief-operated chats"). +// +// Take over is the person's: the person's own prompt already goes in ahead of the manager's next +// message, and acpmux's pair guard (hub/agent_pairs.rs) counts it as the person's input. Taking +// over hides the line for that chat on this Mac; holding the manager's later messages is acpmux's +// follow-up (agent-group-chats plan, item 3). +import type { AcpmuxSessionEntry } from "./sessionList"; + +export type ManagedBy = + /** A Chief started it. */ + | { kind: "chief" } + /** Another chat's agent started it: that chat, by its title. */ + | { kind: "agent"; name: string; sessionId: string }; + +type Entry = Pick; + +/** Who manages the chat `sessionId`, from the session list; undefined when the person does. A + * manager the list does not hold (closed, on another machine) is not named, so no line shows. */ +export function managedBy(sessionId: string | undefined, sessions: readonly Entry[]): ManagedBy | undefined { + if (!sessionId) return undefined; + const own = sessions.find((entry) => entry.sessionId === sessionId); + if (!own) return undefined; + if (own.chiefChild) return { kind: "chief" }; + const parentId = own.startedBy ?? own.spawnedBy; + if (!parentId || parentId === sessionId) return undefined; + const parent = sessions.find((entry) => entry.sessionId === parentId); + const name = parent?.displayTitle?.trim() || parent?.name?.trim(); + return name ? { kind: "agent", name, sessionId: parentId } : undefined; +} + +const TAKEN_OVER_KEY = "cmux.acpmux.takenOver"; +/** At most this many chats are remembered as taken over; the oldest go first. */ +const TAKEN_OVER_MAX = 200; + +function takenOverList(storage: Pick | undefined): string[] { + try { + const value: unknown = JSON.parse(storage?.getItem(TAKEN_OVER_KEY) ?? "[]"); + return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []; + } catch { + return []; + } +} + +const localStore = (): Storage | undefined => { + try { + return typeof localStorage === "undefined" ? undefined : localStorage; + } catch { + return undefined; + } +}; + +/** Whether the person took over the chat `sessionId` on this Mac. */ +export function isTakenOver(sessionId: string | undefined, storage = localStore()): boolean { + return Boolean(sessionId) && takenOverList(storage).includes(sessionId!); +} + +/** Records that the person took over the chat `sessionId`. */ +export function takeOverManaged(sessionId: string, storage = localStore()): void { + if (!storage) return; + const list = takenOverList(storage).filter((id) => id !== sessionId); + list.push(sessionId); + try { + storage.setItem(TAKEN_OVER_KEY, JSON.stringify(list.slice(-TAKEN_OVER_MAX))); + } catch { + // A full or blocked store only means the line shows again next time. + } +} diff --git a/webviews/src/agent-session/acpmux/sessionList.ts b/webviews/src/agent-session/acpmux/sessionList.ts index 8b91b0d0bb5f..7b3fd3e529e5 100644 --- a/webviews/src/agent-session/acpmux/sessionList.ts +++ b/webviews/src/agent-session/acpmux/sessionList.ts @@ -58,6 +58,8 @@ export type AcpmuxSessionEntry = { /** A sub-session: the chat whose agent started it with its `start_session` tool (acpmux * `startedBy`, kept at any depth and after that chat closes). */ startedBy?: string; + /** Started by a Chief (its `mux.parent` tag, `optchat-chief:`): the Chief manages it. */ + chiefChild?: true; /** A sub-session's name among its parent's (acpmux `shortName`, "tests"). */ shortName?: string; /** What it is doing (acpmux `lane.state`): working, waiting on the person, failed, lost or idle. */ @@ -154,6 +156,17 @@ function tagged(tags: unknown, tag: string): boolean { return typeof tags === "object" && tags !== null && tag in tags; } +/** The tag a Chief puts on every session it starts (cmux_chief::rules::PARENT_TAG), valued + * `optchat-chief` or `optchat-chief:`. */ +const CHIEF_PARENT_TAG = "mux.parent"; + +/** Whether acpmux's tags say a Chief started the session. */ +function chiefParent(tags: unknown): boolean { + if (typeof tags !== "object" || tags === null || Array.isArray(tags)) return false; + const value = (tags as Record)[CHIEF_PARENT_TAG]; + return typeof value === "string" && /^optchat-chief(?::|$)/.test(value); +} + /** The tag acpmux sets on a chat while a sub-session of it waits for a folder's trust (hub/trust_wait.rs). */ const TRUST_PENDING_TAG = "trust.pending:"; @@ -235,6 +248,7 @@ export function sessionEntry(session: Record & { sessionId: string parentId: text(session.parentId), spawnedBy: text(session.spawnedBy), startedBy: text(session.startedBy), + ...(chiefParent(session.tags) && { chiefChild: true as const }), shortName: text(session.shortName), lane: LANES.includes(session.lane?.state) ? session.lane.state : undefined, queued: Number(session.queued) > 0 ? Number(session.queued) : undefined, From ab77b38aabf67daca42ccfbee9c95e231c29071f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 01:22:43 -0800 Subject: [PATCH 2/2] Managed banner: theme tokens, output element, formatting Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-22a95c35d6aeabe23acf --- .../agent-session/acpmux/ManagedBanner.gallery.tsx | 12 ++++++++++-- webviews/src/agent-session/acpmux/ManagedBanner.tsx | 4 ++-- webviews/src/agent-session/acpmux/managedBanner.css | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx b/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx index e8303027b8e5..ebc41884834d 100644 --- a/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx @@ -21,12 +21,20 @@ export default componentEntry({ note: "A Chief's subagent: Chief is managing this chat, Take over", }, agent: { - props: { manager: { kind: "agent", name: "claude-sr2-coord", sessionId: "s-coord" }, onTakeOver, onOpenManager }, + props: { + manager: { kind: "agent", name: "claude-sr2-coord", sessionId: "s-coord" }, + onTakeOver, + onOpenManager, + }, note: "A sub-session another chat started: its name opens that chat", }, "long-name": { props: { - manager: { kind: "agent", name: "Land the group chat guest composer and approvals across Home and New Tab", sessionId: "s-long" }, + manager: { + kind: "agent", + name: "Land the group chat guest composer and approvals across Home and New Tab", + sessionId: "s-long", + }, onTakeOver, onOpenManager, }, diff --git a/webviews/src/agent-session/acpmux/ManagedBanner.tsx b/webviews/src/agent-session/acpmux/ManagedBanner.tsx index cba770c4f1bf..ed2fb030bc9a 100644 --- a/webviews/src/agent-session/acpmux/ManagedBanner.tsx +++ b/webviews/src/agent-session/acpmux/ManagedBanner.tsx @@ -21,7 +21,7 @@ export function ManagedBanner({ const [before, after = ""] = t("managed.banner", { name: "\u0000" }).split("\u0000"); const opens = manager.kind === "agent" && onOpenManager ? manager.sessionId : undefined; return ( -
+ {before} {opens ? ( @@ -36,6 +36,6 @@ export function ManagedBanner({ -
+ ); } diff --git a/webviews/src/agent-session/acpmux/managedBanner.css b/webviews/src/agent-session/acpmux/managedBanner.css index 8ed1b81d6d32..b440f95a688e 100644 --- a/webviews/src/agent-session/acpmux/managedBanner.css +++ b/webviews/src/agent-session/acpmux/managedBanner.css @@ -1,6 +1,6 @@ /* ManagedBanner.tsx: one quiet line over the composer, centered on the conversation column, with a pill button, the way ChatGPT shows a chat its agent runs. */ -.acpmux-managed-banner{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 auto;padding:6px var(--cv-inset,max(18px,calc((100% - 760px)/2))) 8px;color:var(--agent-muted);font-size:13px;line-height:18px;min-width:0} +.acpmux-managed-banner{display:flex;align-items:center;justify-content:center;gap:8px;box-sizing:border-box;width:100%;max-width:760px;margin:0 auto;padding:4px 16px 8px;color:var(--agent-muted);font-size:var(--text-detail);letter-spacing:var(--text-detail--letter-spacing);min-width:0} .acpmux-managed-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .acpmux-managed-name{color:var(--agent-text);font-weight:600} button.acpmux-managed-name{border:0;padding:0;background:none;font:inherit;font-weight:600;cursor:pointer}