Repository navigation
Agent pane: a chat the Chief or another agent runs says who manages it, with Take over #30658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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", | ||
| }, | ||
| }, | ||
| }); |
| 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> | ||
| ); | ||
| } |
| 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} | ||
| .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} | ||
| 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" }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
| 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" }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| 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. | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
--agent-page-bgis transparent in see-through themes, so this button's label becomes transparent against its text-colored background. Use the opaque--agent-accent-texttoken for the button foreground.Prompt for AI agents