diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index 21e085c69113..1522ee9476dc 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -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", diff --git a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts index 6c455230458a..39e704ed153e 100644 --- a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts +++ b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts @@ -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 = [ @@ -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": { diff --git a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx index 03b1585a89ae..6a312436fe72 100644 --- a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx +++ b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx @@ -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 diff --git a/webviews/src/agent-session/acpmux/needsYou/model.ts b/webviews/src/agent-session/acpmux/needsYou/model.ts index b766472f7e60..efe43b83fed2 100644 --- a/webviews/src/agent-session/acpmux/needsYou/model.ts +++ b/webviews/src/agent-session/acpmux/needsYou/model.ts @@ -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: [], @@ -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 { + 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, diff --git a/webviews/test/agent-pane-needs-you.test.tsx b/webviews/test/agent-pane-needs-you.test.tsx index c9fbdd7a7115..60892894fb01 100644 --- a/webviews/test/agent-pane-needs-you.test.tsx +++ b/webviews/test/agent-pane-needs-you.test.tsx @@ -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 }), @@ -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( + 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( + 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: [ @@ -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( @@ -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(); });