Skip to content
Closed
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
24 changes: 24 additions & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -5776,6 +5778,14 @@ function AcpmuxPane() {
const doneLines = useRef<DoneLineCache>(new Map());
const childDoneLines = useRef<DoneLineCache>(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],
Expand Down Expand Up @@ -11279,6 +11289,20 @@ function AcpmuxPane() {
</button>
</output>
</Notice>
{/* A chat the Chief or another chat's agent runs: who, and Take over (ManagedBanner.tsx). */}
<Notice id="managed-by" tier="status" when={!quick && manager}>
{manager && shownSessionId && (
<ManagedBanner
manager={manager}
onOpenManager={openSenderChat}
onTakeOver={() => {
takeOverManaged(shownSessionId);
setTakenOverAt(Date.now());
composerHandle.current?.focus();
}}
/>
)}
</Notice>
{/* 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}
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions webviews/src/agent-session/acpmux/ManagedBanner.gallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// l10n-allow-file: gallery fixtures (sample chat names), not shipped UI.
import { componentEntry } from "../../gallery/format";
import type { ManagedBanner } from "./ManagedBanner";

type Props = Parameters<typeof ManagedBanner>[0];

const onTakeOver = () => undefined;
const onOpenManager = () => undefined;

export default componentEntry<Props>({
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",
},
},
});
41 changes: 41 additions & 0 deletions webviews/src/agent-session/acpmux/ManagedBanner.tsx
Original file line number Diff line number Diff line change
@@ -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):
/// "<name> 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 (
<output className="acpmux-managed-banner" data-managed={manager.kind}>
<span className="acpmux-managed-text">
{before}
{opens ? (
<button type="button" className="acpmux-managed-name" onClick={() => onOpenManager?.(opens)}>
{name}
</button>
) : (
<strong className="acpmux-managed-name">{name}</strong>
)}
{after}
</span>
<button type="button" className="acpmux-managed-take-over" onClick={onTakeOver}>
{t("managed.takeOver")}
</button>
</output>
);
}
10 changes: 10 additions & 0 deletions webviews/src/agent-session/acpmux/managedBanner.css
Original file line number Diff line number Diff line change
@@ -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: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}
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: --agent-page-bg is transparent in see-through themes, so this button's label becomes transparent against its text-colored background. Use the opaque --agent-accent-text token for the button foreground.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/managedBanner.css, line 8:

<comment>`--agent-page-bg` is transparent in see-through themes, so this button's label becomes transparent against its text-colored background. Use the opaque `--agent-accent-text` token for the button foreground.</comment>

<file context>
@@ -0,0 +1,10 @@
+.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}
</file context>
Suggested change
.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{flex:none;border:0;border-radius:999px;padding:4px 12px;background:var(--agent-text);color:var(--agent-accent-text);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}
59 changes: 59 additions & 0 deletions webviews/src/agent-session/acpmux/managedBy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => 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" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assertion never checks the manager’s displayed name, so a regression that returns the wrong or empty title for spawnedBy still passes. Assert the complete manager object, including name: "planner".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/managedBy.test.ts, line 30:

<comment>This assertion never checks the manager’s displayed name, so a regression that returns the wrong or empty title for `spawnedBy` still passes. Assert the complete manager object, including `name: "planner"`.</comment>

<file context>
@@ -0,0 +1,59 @@
+
+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" });
+});
+
</file context>

});

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<string, string>();
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);
});
71 changes: 71 additions & 0 deletions webviews/src/agent-session/acpmux/managedBy.ts
Original file line number Diff line number Diff line change
@@ -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<AcpmuxSessionEntry, "sessionId" | "chiefChild" | "startedBy" | "spawnedBy" | "displayTitle" | "name">;

/** 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" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This branch shows the Chief banner from the child tag alone, without confirming that the Chief is present in the session list. A stale or remote child can therefore claim that Chief is managing it when no matching Chief session is listed; retain the Chief home id and require a matching cmux.chief entry before returning { kind: "chief" }.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/managedBy.ts, line 27:

<comment>This branch shows the Chief banner from the child tag alone, without confirming that the Chief is present in the session list. A stale or remote child can therefore claim that Chief is managing it when no matching Chief session is listed; retain the Chief home id and require a matching `cmux.chief` entry before returning `{ kind: "chief" }`.</comment>

<file context>
@@ -0,0 +1,71 @@
+  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;
</file context>

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<Storage, "getItem"> | 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.
}
}
14 changes: 14 additions & 0 deletions webviews/src/agent-session/acpmux/sessionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<home>`): 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. */
Expand Down Expand Up @@ -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:<home id>`. */
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<string, unknown>)[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:";

Expand Down Expand Up @@ -235,6 +248,7 @@ export function sessionEntry(session: Record<string, any> & { 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,
Expand Down
Loading