Skip to content
132 changes: 132 additions & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -209740,6 +209740,138 @@
}
}
},
"needsYou.waitingPermission": {
"comment": "Agent pane: a Needs you item for a chat that waits on the person to allow a tool, when which tool cannot be read here",
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "needs_review",
"value": "بانتظار سماحك بأداة"
}
},
"bs": {
"stringUnit": {
"state": "needs_review",
"value": "Čeka da dozvolite alat"
}
},
"da": {
"stringUnit": {
"state": "needs_review",
"value": "Venter på, at du tillader et værktøj"
}
},
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Wartet, dass du ein Werkzeug erlaubst"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting for you to allow a tool"
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Esperando que permitas una herramienta"
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Attend que vous autorisiez un outil"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "In attesa che tu consenta uno strumento"
}
},
"ja": {
"stringUnit": {
"state": "needs_review",
"value": "ツールの許可を待っています"
}
},
"km": {
"stringUnit": {
"state": "needs_review",
"value": "កំពុងរង់ចាំអ្នកអនុញ្ញាតឧបករណ៍មួយ"
}
},
"ko": {
"stringUnit": {
"state": "needs_review",
"value": "도구 허용을 기다리는 중"
}
},
"nb": {
"stringUnit": {
"state": "needs_review",
"value": "Venter på at du tillater et verktøy"
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Czeka, aż zezwolisz na narzędzie"
}
},
"pt-BR": {
"stringUnit": {
"state": "needs_review",
"value": "Aguardando você permitir uma ferramenta"
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Ждёт, пока вы разрешите инструмент"
}
},
"th": {
"stringUnit": {
"state": "needs_review",
"value": "กำลังรอให้คุณอนุญาตเครื่องมือ"
}
},
"tr": {
"stringUnit": {
"state": "needs_review",
"value": "Bir araca izin vermenizi bekliyor"
}
},
"uk": {
"stringUnit": {
"state": "needs_review",
"value": "Чекає, поки ви дозволите інструмент"
}
},
"vi": {
"stringUnit": {
"state": "needs_review",
"value": "Đang chờ bạn cho phép một công cụ"
}
},
"zh-Hans": {
"stringUnit": {
"state": "needs_review",
"value": "等待你允许一个工具"
}
},
"zh-Hant": {
"stringUnit": {
"state": "needs_review",
"value": "等待你允許一個工具"
}
}
}
},
"needsYou.chief": {
"comment": "Agent pane: who asked, when the Chief agent asked",
"extractionState": "manual",
Expand Down
20 changes: 17 additions & 3 deletions webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,31 @@ const sessions = [
indicator: "input",
updatedAt: ago(14),
}),
// Its ask lives on the other computer: the row reads the question acpmux names
// (`waitingPrompt`), never the agent's last message (`preview`).
session({
sessionId: "s-remote",
title: "Release notes on build box",
host: "build-box",
hostKind: "cloud",
peer: "build-box",
indicator: "input",
pendingPermissions: 1,
// Its ask lives on the build box: the row says the question its summary names, never this reply.
preview: "Drafted the notes from the merged pull requests.",
waitingOn: { on: "question", prompt: "Which tag should the notes start from?" },
preview: "I drafted the notes from the merged PRs since the last nightly.",
updatedAt: ago(32),
}),
// A permission whose tool is not read here reads as a permission, not a question.
session({
sessionId: "s-grouped",
title: "Clean the build cache",
pendingPermissions: 1,
indicator: "input",
waitingOn: { on: "permission" },
preview: "The cache holds 14 GB of old builds.",
updatedAt: ago(45),
}),
];

const rows = [
Expand Down Expand Up @@ -135,12 +149,12 @@ export default agentPaneEntry({
covers: ["agent-session/acpmux/needsYou/NeedsYou.tsx", "agent-session/acpmux/needsYou/JumpPill.tsx#NeedsYouPill"],
variants: {
list: {
note: "The list open: this chat's command, the Chief's merge, a subagent's choice, a verify ask and a chat on another computer.",
note: "The list open: this chat's command, the Chief's merge, a subagent's choice, a verify ask, a question on another computer (its prompt, not the last message) and a permission not read here.",
snapshot: snapshot(),
play: openList,
},
"badge-and-pill": {
note: "Closed: the badge counts five asks and Jump to latest reads Needs your input · 5.",
note: "Closed: the badge counts six asks and Jump to latest reads Needs your input · 6.",
snapshot: snapshot(),
},
"pill-one-ask": {
Expand Down
5 changes: 3 additions & 2 deletions webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,11 @@ function Avatar({ item }: { item: NeedsYouItem }) {
);
}

/// The ask's line: its own text, else what its kind says.
/// The ask's line: its own text, else what its kind says (a permission asks to allow, anything
/// else for an answer).
export function askText(item: NeedsYouItem, t: Translate): string {
if (item.kind === "held") return t("needsYou.held", { n: item.held ?? 1, peer: item.ask });
return item.ask || t("needsYou.waiting");
return item.ask || t(item.kind === "approval" ? "needsYou.waitingPermission" : "needsYou.waiting");
}

/// The side session's first prompt (`_acpmux/btw`): the ask, then the person's question, so the
Expand Down
38 changes: 27 additions & 11 deletions webviews/src/agent-session/acpmux/needsYou/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,14 @@ export function needsYouItems(input: NeedsYouInput): NeedsYouItem[] {
});
}
}
// A chat that waits on the person with no ask read here (a chat on another computer, or one
// whose ask is still being read): one item, so it is never missing from the list. Its summary
// still names what it waits on (acpmux `waitingOn`, with a question's first prompt): the row
// says that question; only a chat that names no question falls back to its last reply.
// A chat that waits on the person with no ask read here (a chat on another computer, a grouped
// request, or one whose ask is still being read): one item, so it is never missing from the list.
for (const session of input.sessions) {
if (asked.has(session.sessionId) || session.archived) continue;
if (session.indicator !== "input" && !(session.pendingPermissions && session.pendingPermissions > 0)) continue;
const waiting = session.waitingOn;
const prompt = waiting?.on === "question" ? waiting.prompt : undefined;
const ask = prompt ?? session.preview ?? "";
if (asked.has(session.sessionId) || session.archived || !waitsOnPerson(session)) continue;
items.push({
id: `chat:${session.sessionId}`,
kind: !waiting ? "input" : waiting.on === "permission" ? "approval" : VERIFY.test(ask) ? "verify" : "decision",
...waitingAsk(session),
sessionId: session.sessionId,
ask,
who: who(session, session.sessionId, byId),
...(session.updatedAt ? { at: session.updatedAt } : {}),
answers: [],
Expand All @@ -128,6 +121,29 @@ export function needsYouItems(input: NeedsYouInput): NeedsYouItem[] {
return items.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
}

/// Something is pending on the person: acpmux counts an open request, names what the chat waits
/// on, or says the chat is `waiting` (a secret request sets that with neither, hub/secrets.rs). A
/// chat on another computer, or one whose status is unknown, still shows by its indicator alone. A
/// local chat acpmux says is not waiting has nothing pending, whatever its dot last said.
function waitsOnPerson(session: AcpmuxSessionEntry): boolean {
if ((session.pendingPermissions ?? 0) > 0 || session.waitingOn || session.status === "waiting") return true;
return session.indicator === "input" && (Boolean(session.peer) || session.status === undefined);
}

/// What a chat whose ask is not read here waits on, as acpmux names it: a question by its first
/// prompt (`waitingPrompt`), a permission as a permission. Never the chat's last message
/// (`preview`): that is what the agent said, not what it asks (Leo 2026-10-21). An empty ask
/// reads as its kind's line (NeedsYou.tsx `askText`).
function waitingAsk(session: AcpmuxSessionEntry): Pick<NeedsYouItem, "kind" | "ask"> {
const waiting = session.waitingOn;
if (waiting?.on === "question") {
const ask = waiting.prompt ?? "";
return { kind: VERIFY.test(ask) ? "verify" : "decision", ask };
}
if (waiting?.on === "permission") return { kind: "approval", ask: "" };
return { kind: "input", ask: "" };
}

function askItem(
watched: WatchedAsk,
session: AcpmuxSessionEntry | undefined,
Expand Down
101 changes: 93 additions & 8 deletions webviews/test/agent-pane-needs-you.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ test("a question with several items or many choices gets no one-click answers",
test("a chat waiting with no readable ask still shows, once; an archived chat never does", () => {
const items = needsYouItems({
sessions: [
session("remote", { indicator: "input", updatedAt: 5, preview: "Pick a branch" }),
session("remote", { indicator: "input", peer: "build-box", updatedAt: 5, preview: "Pick a branch" }),
session("counted", { pendingPermissions: 2 }),
session("asked", { pendingPermissions: 1 }),
session("gone", { indicator: "input", archived: true }),
Expand All @@ -125,13 +125,95 @@ test("a chat waiting with no readable ask still shows, once; an archived chat ne
});
expect(items.map((item) => item.id)).toEqual(["acpmux:p1", "chat:remote", "chat:counted"]);
expect(items[1]!.kind).toBe("input");
expect(items[1]!.ask).toBe("Pick a branch");
});

// Leo 2026-10-21: the list said "Waiting 5m" for a chat and showed the agent's last message, not the
// question it waited on. A chat whose ask is not read here shows what acpmux says it waits on: a
// question by its prompt, a permission as a permission, and never the chat's last message.
test("a waiting chat shows the question or permission it waits on, never its last message", () => {
const items = needsYouItems({
sessions: [
session("question", {
indicator: "input",
pendingPermissions: 1,
updatedAt: 9,
preview: "I compared both branches.",
waitingOn: { on: "question", prompt: "Which branch should the release start from?" },
}),
session("unworded", {
indicator: "input",
pendingPermissions: 1,
updatedAt: 8,
preview: "Here is the plan.",
waitingOn: { on: "question" },
}),
session("permission", {
indicator: "input",
pendingPermissions: 1,
updatedAt: 7,
preview: "Running the suite now.",
waitingOn: { on: "permission" },
}),
session("remote", { indicator: "input", peer: "build-box", updatedAt: 6, preview: "Drafted the notes." }),
],
asks: [],
});
expect(items.map((item) => [item.id, item.kind, item.ask])).toEqual([
["chat:question", "decision", "Which branch should the release start from?"],
["chat:unworded", "decision", ""],
["chat:permission", "approval", ""],
["chat:remote", "input", ""],
]);
const html = renderToStaticMarkup(
<NeedsYouPanel
items={items}
actions={{ answer: async () => undefined, goTo: () => undefined, ask: async () => "" }}
/>,
);
expect(html).toContain("Which branch should the release start from?");
// A question reads as a question, a permission as a permission.
expect(html.split(translate("needsYou.waiting")).length - 1).toBe(2);
expect(html.split(translate("needsYou.waitingPermission")).length - 1).toBe(1);
expect(html).toContain(translate("needsYou.kind.decision"));
expect(html).toContain(translate("needsYou.kind.approval"));
for (const said of ["I compared both branches.", "Here is the plan.", "Running the suite now.", "Drafted the notes."])
expect(html).not.toContain(said);
});

test("a chat with nothing pending is not listed as waiting", () => {
// acpmux says the chat is idle: a stale dot claims no wait.
const items = needsYouItems({
sessions: [session("done", { status: "idle", indicator: "input", updatedAt: 5, preview: "All set." })],
asks: [],
});
expect(items).toEqual([]);
});

// Review of #23875: acpmux marks a chat `waiting` for a secret request too (hub/secrets.rs), with no
// pending permission and no `waitingOn`. That chat stays listed, with the plain waiting line and
// never its last message.
test("a local chat waiting on a secret stays listed and shows no last message", () => {
const items = needsYouItems({
sessions: [
session("secret", { status: "waiting", indicator: "input", updatedAt: 5, preview: "I need the deploy token." }),
],
asks: [],
});
expect(items.map((item) => [item.id, item.kind, item.ask])).toEqual([["chat:secret", "input", ""]]);
const html = renderToStaticMarkup(
<NeedsYouPanel
items={items}
actions={{ answer: async () => undefined, goTo: () => undefined, ask: async () => "" }}
/>,
);
expect(html).toContain(translate("needsYou.waiting"));
expect(html).not.toContain("I need the deploy token.");
});

// Leo 2026-10-21: a row read "Waiting 5m" over the chat's last reply while a question waited. A chat
// whose ask the page cannot read (another computer's) still names it in its summary (acpmux
// `waitingOn`, `waitingPrompt`): the row shows that question; only a chat that names nothing falls
// back to its last reply.
// `waitingOn`, `waitingPrompt`): the row shows that question. A row never shows the last reply: a
// chat that names no question text reads as its kind's line (NeedsYou.tsx `askText`).
test("a waiting chat's row shows the question it waits on, not its last reply", () => {
const items = needsYouItems({
sessions: [
Expand Down Expand Up @@ -159,9 +241,9 @@ test("a waiting chat's row shows the question it waits on, not its last reply",
});
expect(items.map((item) => [item.id, item.kind, item.ask])).toEqual([
["chat:question", "decision", "Which branch should the docs target?"],
["chat:permission", "approval", "The tests pass; pushing next."],
["chat:unnamed", "decision", "Which tag should the notes start from?"],
["chat:plain", "input", "Pick a branch"],
["chat:permission", "approval", ""],
["chat:unnamed", "decision", ""],
["chat:plain", "input", ""],
]);
expect(items.every((item) => item.answers.length === 0)).toBe(true);
const html = renderToStaticMarkup(
Expand Down Expand Up @@ -262,7 +344,10 @@ test("an answer goes to the ask's own chat: a permission by option, a question i
},
});
// A waiting chat with no ask read is answered at its source.
const waiting = needsYouItems({ sessions: [session("r", { indicator: "input" })], asks: [] })[0]!;
const waiting = needsYouItems({
sessions: [session("r", { indicator: "input", pendingPermissions: 1 })],
asks: [],
})[0]!;
expect(answerParams(waiting, { id: "x", label: "x", tone: "choice" })).toBeUndefined();
});

Expand Down