From 97058a84f0262cc82ad749ba35aeaf858d2dd3ed Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 15:39:49 -0700 Subject: [PATCH 01/23] cmux NEXT: Needs you list and the Needs your input pill One pinned list of every open ask (approvals, decisions, verify asks, held agent-pair messages) from every agent chat and the Chief, behind a count badge in the chat header, with one-click answers, Go to source and Ask about this. While asks are open, Jump to latest becomes a "Needs your input" pill that goes to the ask and cycles through the others. The blue dot is the one needs-you mark (sidebar, Chief dock, badge, pill). Built on acpmux's own pending requests (watch + _acpmux/info), so an ask answered anywhere leaves the list. acpmux pending --json gains a cmux://needs-you/ link that the pane draws as a chip. Both are Debug Settings toggles, on by default. Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=e8f63fd9-94e8-4878-bcd4-704659aba1f2&host=cosmic-gray --- .../AgentPaneTunables+NeedsYou.swift | 38 + .../CmuxNextAgentPane/AgentPaneTunables.swift | 10 +- .../AgentPaneNeedsYouTunablesTests.swift | 31 + cmux-tui/crates/acpmux/skills/acpmux/SKILL.md | 5 + .../crates/acpmux/src/cli/run/standard.rs | 8 +- plans/cmux-next/needs-you.md | 79 + .../next/highlights/needs-you-list.md | 4 + scripts/cmux-next/build-agent-pane-web.sh | 2 +- webviews/src/agent-session/acpmux/App.tsx | 41 +- .../src/agent-session/acpmux/ChiefDock.tsx | 10 +- .../acpmux/Localizable.xcstrings | 3168 +++++++++++++++++ .../agent-session/acpmux/SessionSidebar.tsx | 5 +- .../acpmux/conversation/Markdown.tsx | 13 +- webviews/src/agent-session/acpmux/dev.tsx | 1 + webviews/src/agent-session/acpmux/direct.ts | 42 +- webviews/src/agent-session/acpmux/model.ts | 4 + .../acpmux/needsYou/JumpPill.tsx | 76 + .../acpmux/needsYou/NeedsYou.gallery.ts | 170 + .../acpmux/needsYou/NeedsYou.tsx | 403 +++ .../src/agent-session/acpmux/needsYou/asks.ts | 150 + .../agent-session/acpmux/needsYou/links.ts | 36 + .../agent-session/acpmux/needsYou/model.ts | 195 + .../acpmux/needsYou/needsYou.css | 395 ++ .../acpmux/needsYou/useNeedsYou.ts | 86 + webviews/src/gallery/format.ts | 3 + webviews/src/gallery/frame/agentPane.ts | 3 + .../test/agent-pane-needs-you-asks.test.ts | 105 + webviews/test/agent-pane-needs-you.test.tsx | 332 ++ 28 files changed, 5397 insertions(+), 18 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NeedsYou.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNeedsYouTunablesTests.swift create mode 100644 plans/cmux-next/needs-you.md create mode 100644 release-notes/next/highlights/needs-you-list.md create mode 100644 webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx create mode 100644 webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts create mode 100644 webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx create mode 100644 webviews/src/agent-session/acpmux/needsYou/asks.ts create mode 100644 webviews/src/agent-session/acpmux/needsYou/links.ts create mode 100644 webviews/src/agent-session/acpmux/needsYou/model.ts create mode 100644 webviews/src/agent-session/acpmux/needsYou/needsYou.css create mode 100644 webviews/src/agent-session/acpmux/needsYou/useNeedsYou.ts create mode 100644 webviews/test/agent-pane-needs-you-asks.test.ts create mode 100644 webviews/test/agent-pane-needs-you.test.tsx diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NeedsYou.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NeedsYou.swift new file mode 100644 index 000000000000..6a64f6ebceb5 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NeedsYou.swift @@ -0,0 +1,38 @@ +public import CmuxNextDesign + +/// How the agent pane's header draws Needs you (webviews needsYou/NeedsYou.tsx): +/// `agentPane.debug.needsYou`. `pinned` sets nothing on the page. +public nonisolated enum AgentPaneNeedsYouStyle: String, Sendable, CaseIterable, TunableChoice { + /// A badge with the count of open asks; a press opens every ask with its answers (default). + case pinned + /// The same list, one line per ask, with no answers in it. + case compact + /// No badge. + case off + + public var tunableTitle: String { + switch self { + case .pinned: "Pinned list with answers (default)" + case .compact: "Compact list, one line per ask" + case .off: "Off" + } + } +} + +nonisolated extension AgentPaneTunables { + /// Needs you (Leo 2026-10-20): one pinned list of every open ask (approvals, decisions, + /// verify-this asks, held messages) from every agent chat and the Chief, behind a badge in the + /// chat header. On by default. New agent tabs read it. + public static let needsYou = Tunable.choice( + "agentPane.debug.needsYou", PageTunables.section, "Needs you list in the chat header", + help: "A badge in the agent chat header counts every open ask; a press lists them with one-click answers, Go to source and Ask about this. New agent tabs.", + default: .pinned, code: "AgentPaneTunables.needsYou") + + /// While an ask is open, Jump to latest becomes a "Needs your input" pill that goes to the ask + /// and cycles through the others (Claude Code desktop's, Leo 2026-10-20). On by default; off + /// keeps the plain button. New agent tabs read it. + public static let needsYouPill = Tunable.toggle( + "agentPane.debug.needsYouPill", PageTunables.section, "Needs your input pill", + help: "While an agent waits on you, the floating Jump to latest button reads Needs your input and goes to the ask. New agent tabs.", + default: true, code: "AgentPaneTunables.needsYouPill") +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift index 905013797244..7bb1259d49c5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift @@ -262,14 +262,18 @@ public nonisolated struct AgentPaneTunables { static var documentAttributes: [String: String] { attributes(foldLongThoughts: foldLongThoughts.value, topStripStyle: topStripStyle.value, thoughtHeader: thoughtHeader.value, liveThoughtViewport: liveThoughtViewport.value, - thoughtFoldThreshold: thoughtFoldThreshold.value) + thoughtFoldThreshold: thoughtFoldThreshold.value, needsYou: needsYou.value, + needsYouPill: needsYouPill.value) } static func attributes(foldLongThoughts: Bool, topStripStyle: AgentPaneTopStripStyle, thoughtHeader: AgentPaneThoughtHeader = .duration, liveThoughtViewport: Bool = true, - thoughtFoldThreshold: AgentPaneThoughtFoldThreshold = .viewport) -> [String: String] { + thoughtFoldThreshold: AgentPaneThoughtFoldThreshold = .viewport, + needsYou: AgentPaneNeedsYouStyle = .pinned, needsYouPill: Bool = true) -> [String: String] { var attributes: [String: String] = [:] + if needsYou != .pinned { attributes["needs-you"] = needsYou.rawValue } + if !needsYouPill { attributes["needs-you-pill"] = "off" } if foldLongThoughts { attributes["fold-long-thoughts"] = "on" } if thoughtFoldThreshold != .viewport { attributes["thought-fold-threshold"] = thoughtFoldThreshold.rawValue } if thoughtHeader != .duration { attributes["thought-header"] = thoughtHeader.rawValue } @@ -347,6 +351,8 @@ public nonisolated struct AgentPaneTunables { harnessSwitchInStartedChat.descriptor, liveThoughtViewport.descriptor, modelMenu.descriptor, + needsYou.descriptor, + needsYouPill.descriptor, newTabWidgets.descriptor, pageHost.descriptor, reasoningSlider.descriptor, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNeedsYouTunablesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNeedsYouTunablesTests.swift new file mode 100644 index 000000000000..05e262dce6a2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNeedsYouTunablesTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import CmuxNextAgentPane + +/// Debug Settings' Needs you list and pill: both on by default, so the defaults set no attribute; +/// the other choices set `data-needs-you` / `data-needs-you-pill` for the page (needsYou/NeedsYou.tsx). +struct AgentPaneNeedsYouTunablesTests { + @Test func defaultsAreOnAndSetNoAttribute() { + #expect(AgentPaneTunables.needsYou.descriptor.key == "agentPane.debug.needsYou") + #expect(AgentPaneTunables.needsYou.defaultValue == .pinned) + #expect(AgentPaneTunables.needsYouPill.descriptor.key == "agentPane.debug.needsYouPill") + #expect(AgentPaneTunables.needsYouPill.defaultValue == true) + #expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default).isEmpty) + } + + @Test(arguments: [AgentPaneNeedsYouStyle.compact, .off]) + func otherStylesSetTheirAttribute(style: AgentPaneNeedsYouStyle) { + #expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default, needsYou: style) + == ["needs-you": style.rawValue]) + } + + @Test func pillOffSetsItsAttribute() { + #expect(AgentPaneTunables.attributes(foldLongThoughts: false, topStripStyle: .default, needsYouPill: false) + == ["needs-you-pill": "off"]) + } + + @Test func listedInDebugSettings() { + #expect(AgentPaneTunables.all.contains { $0.key == "agentPane.debug.needsYou" }) + #expect(AgentPaneTunables.all.contains { $0.key == "agentPane.debug.needsYouPill" }) + #expect(AgentPaneNeedsYouStyle.allCases.map(\.rawValue) == ["pinned", "compact", "off"]) + } +} diff --git a/cmux-tui/crates/acpmux/skills/acpmux/SKILL.md b/cmux-tui/crates/acpmux/skills/acpmux/SKILL.md index 1575ffb5fb40..7e0330c89674 100644 --- a/cmux-tui/crates/acpmux/skills/acpmux/SKILL.md +++ b/cmux-tui/crates/acpmux/skills/acpmux/SKILL.md @@ -110,6 +110,11 @@ acpmux session rules NAME '{"autoDeny": ["rm -rf"], "ask": ["execute"]}' acpmux run --on-permission deny|fail … # never hang a script on a prompt (fail = exit 5) ``` +`acpmux pending --json` gives each request a `needsYou` link +(`cmux://needs-you/`). Put it in a reply to point the user at an +open ask instead of restating it; cmux draws it as a chip that opens the ask in +its Needs you list. + `--policy ask` sends every request to a human or to you; the other policies answer locally. Rules sit above the policy and match tool kind, title, and name, case-insensitively. File writes a harness delegates to acpmux (ACP diff --git a/cmux-tui/crates/acpmux/src/cli/run/standard.rs b/cmux-tui/crates/acpmux/src/cli/run/standard.rs index c064b71bb4ca..554ef6aa4482 100644 --- a/cmux-tui/crates/acpmux/src/cli/run/standard.rs +++ b/cmux-tui/crates/acpmux/src/cli/run/standard.rs @@ -192,7 +192,13 @@ pub(super) async fn run(cmd: Command, json_out: bool, suppress_reads: bool) -> R req.get("options").and_then(Value::as_array).cloned().unwrap_or_default(); let hint = crate::question_answer::pending_hint(&req, &name); let is_question = crate::question_answer::question(&req).is_some(); - out.push(json!({"session": name, "sessionId": id, "permissionId": p.get("permissionId"), "title": title, "kind": kind, "options": options, "question": is_question, "answer": hint})); + // The ask's Needs you link: an agent writes it in a reply instead of restating + // the ask, and the pane draws it as a chip that opens the list on this ask. + let needs_you = p + .get("permissionId") + .and_then(Value::as_str) + .map(|pid| format!("cmux://needs-you/{pid}")); + out.push(json!({"session": name, "sessionId": id, "permissionId": p.get("permissionId"), "title": title, "kind": kind, "options": options, "question": is_question, "answer": hint, "needsYou": needs_you})); } } if json_out { diff --git a/plans/cmux-next/needs-you.md b/plans/cmux-next/needs-you.md new file mode 100644 index 000000000000..b06ec086741b --- /dev/null +++ b/plans/cmux-next/needs-you.md @@ -0,0 +1,79 @@ +# Needs you + +One list of every open ask, so no agent has to say "still waiting on you" again. It holds +approvals, decisions, verify-this asks and held agent-pair messages. They come from the Chief, +its subagents and every other agent chat. + +## What ships + +- **Header badge.** It sits in the agent chat header and counts the open asks. +- **Pinned list.** A press on the badge opens the list. Each ask shows: + - the ask on one line; + - who asked, with the agent's mark or the Chief's orb, and the chat that started a subagent; + - its kind and age; + - one-click answers (Allow / Deny, or one of up to four choices); + - Go to source, which opens the asking chat at the ask's turn; + - Ask about this, which sends a side question (`_acpmux/ask`) to a read-only copy of the asking + chat, seeded with "What is going on here?". +- **Needs your input pill.** While any ask is open, the floating Jump to latest button becomes a + pill: the blue dot, "Needs your input" (or "Needs your input · N"), and a chevron. + - A press goes to this chat's ask first, then cycles through the asks open in other chats. + - With nothing open, it is the plain button again. +- **One mark.** The blue dot is the one "needs you" mark: the sidebar's input mark, the Chief + dock's waiting card, the badge and the pill. Its color is the window sidebar's unread dot, + `Palette.notificationBlue`. +- **Debug Settings toggles**, both on by default; new agent tabs read them: + - `agentPane.debug.needsYou`: pinned, compact or off; + - `agentPane.debug.needsYouPill`. + +## The data model + +The list has no store of its own. Every item comes from what acpmux already reports, so an ask +leaves as soon as acpmux says it was answered, wherever it was answered: its card, the Feed, +`acpmux session allow`, another pane or a phone. + +The sources, all in `webviews/src/agent-session/acpmux/needsYou/`: + +- **Pending permission requests** (`asks.ts`) in every chat the pane watches: + - each new request arrives as `_acpmux/permission_pending` (`via: watch` for chats the pane has + not attached); + - a change to a chat's `pendingPermissions`, or an answer (`permission_decision`, + `permission_resolved`), rereads `_acpmux/info` `pending`; + - a count of zero clears the chat at once; + - a read that predates a newer ask or read is dropped. + + `acpmux pending` and the Feed bridge (`AcpmuxPermissionFeedBridge`) read the same source. +- **Session indicators.** A chat whose `indicator` is `input`, or whose `pendingPermissions` is + above zero, but whose ask the page cannot read (a chat on another computer) still shows as one + `input` item. +- **Agent-pair holds** in the open chat's rows (#21975), answered with Resume. + +The item shape (`model.ts`, `NeedsYouItem`) is the contract for every consumer: + +| Field | Meaning | +| --- | --- | +| `id` | `acpmux:`: the Feed's dedupe key for the same prompt. Otherwise `held::` or `chat:`. | +| `kind` | `approval`, `decision`, `verify`, `held` or `input`. | +| `sessionId`, `turnId` | The asking chat and turn. Go to source opens `cmux://session/#turn-`. | +| `ask`, `detail`, `command` | The ask on one line; more about it; whether that is a command. | +| `who` | `{name, harness, chief, via}`: the asking chat, whether it is the Chief, and its parent chat. | +| `at` | When it was asked, as near as acpmux tells. | +| `answers` | One-click answers. Each is sent through `_acpmux/permission_respond` in the harness's shape, the path the ask's card uses. | + +## Agents + +`acpmux pending --json` gives each request a `needsYou` link, `cmux://needs-you/`. +An agent puts it in a reply instead of restating the ask. The pane draws the link as a chip with +the blue dot, and the chip opens the list on that ask. Any build's scheme works +(`cmux-://`). + +## Not yet + +- **The Feed and the pet notices.** They post the same prompts under the same `acpmux:` key, + but they don't yet read this list's order, kinds or `via`. A follow-up can move the native + consumers onto one shared model: `AcpmuxPermissionFeedBridge` and the pet's notice queue. +- **The Chief's Home page** (`pages/home-channels` and the native Home transcript) has no Jump to + latest button, so it has no pill yet. The Chief's chats in the agent pane have both the pill and + the badge. +- **Held messages in other chats.** They show only for the open chat. A hold elsewhere still shows + as that chat's `input` item when acpmux marks the chat waiting. diff --git a/release-notes/next/highlights/needs-you-list.md b/release-notes/next/highlights/needs-you-list.md new file mode 100644 index 000000000000..bc139fd44b71 --- /dev/null +++ b/release-notes/next/highlights/needs-you-list.md @@ -0,0 +1,4 @@ +title: Every ask waiting on you, in one list +category: new + +The badge in an agent chat's header counts every approval and question your agents and the Chief are waiting on. Click it to answer, jump to the ask, or ask about it first. diff --git a/scripts/cmux-next/build-agent-pane-web.sh b/scripts/cmux-next/build-agent-pane-web.sh index fde8db0cef54..2ba4e3eda130 100755 --- a/scripts/cmux-next/build-agent-pane-web.sh +++ b/scripts/cmux-next/build-agent-pane-web.sh @@ -76,7 +76,7 @@ cat "$ROOT/webviews/src/theme/messages.css" "$SRC/acpmux/styles.css" "$SRC/acpmu cat "$SRC/acpmux/changes/git.css" >> "$WORK/styles.css" grep -v '^@import ' "$SRC/acpmux/changes/changes.css" >> "$WORK/styles.css" cat "$SRC/acpmux/handoff/styles.css" "$SRC/acpmux/checkpoints/styles.css" "$SRC/acpmux/composerControls.css" "$SRC/acpmux/composerStates.css" "$SRC/acpmux/composerLocation.css" "$SRC/acpmux/bottomStack.css" "$SRC/acpmux/composerAttachments.css" "$SRC/acpmux/markdownField.css" \ - "$SRC/acpmux/modelPicker.css" "$SRC/acpmux/keys.css" "$SRC/acpmux/summary/summary.css" "$SRC/acpmux/subagents/subagents.css" "$SRC/acpmux/header/header.css" "$SRC/acpmux/newtab/screen.css" "$SRC/acpmux/newtab/history.css" "$SRC/acpmux/threadMinimap/threadMinimap.css" >> "$WORK/styles.css" + "$SRC/acpmux/modelPicker.css" "$SRC/acpmux/keys.css" "$SRC/acpmux/summary/summary.css" "$SRC/acpmux/subagents/subagents.css" "$SRC/acpmux/header/header.css" "$SRC/acpmux/needsYou/needsYou.css" "$SRC/acpmux/newtab/screen.css" "$SRC/acpmux/newtab/history.css" "$SRC/acpmux/threadMinimap/threadMinimap.css" >> "$WORK/styles.css" cat "$SRC/acpmux/turnChanges/turnChanges.css" "$SRC/acpmux/Inspector.css" >> "$WORK/styles.css" cat "$SRC/acpmux/addProject.css" "$SRC/acpmux/harnesses/harnesses.css" "$SRC/acpmux/replay/replay.css" "$SRC/acpmux/offer/suggestedTasks.css" "$SRC/acpmux/started/startedSessions.css" "$ROOT/webviews/src/markdown-task-checkbox.css" >> "$WORK/styles.css" # Every sheet the pane imports ships: a missing one renders its component unstyled in the app diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 712ec2a6d419..9f9a20ec23a5 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -208,6 +208,9 @@ import { renderCall } from "./conversation/renderCall"; import { DateLine } from "./conversation/DateLine"; import { ShortcutsContext, readShortcuts, type ShortcutLabels } from "./shortcuts"; import { FALLBACK_LINK_SCHEME, revealTurnWhenShown, setLinkScheme } from "./links"; +import { NeedsYouButton } from "./needsYou/NeedsYou"; +import { NeedsYouPill, type TranscriptNeedsYou } from "./needsYou/JumpPill"; +import { useNeedsYou } from "./needsYou/useNeedsYou"; import { copyText, copyTextResult } from "./conversation/clipboard"; import { lastCodeBlock } from "./conversation/lastCodeBlock"; import { hasTranscriptCopyText, transcriptRangeText } from "./conversation/transcriptCopy"; @@ -1365,15 +1368,19 @@ function JumpToLatest({ working, onJump, lift, + needsYou, }: { shown: boolean; working: boolean; + /// Open asks (needsYou/JumpPill.ts): the button becomes the Needs your input pill. + needsYou?: TranscriptNeedsYou; /// The bottom stack's height over the transcript's foot: the button sits above it. lift: number; /// `focused`: the button had keyboard focus, which it loses when it hides at the latest message. onJump: (focused: boolean) => void; }) { const t = useT(); + if (needsYou && needsYou.count > 0) return ; const label = t(working ? "transcript.jumpLatestWorking" : "transcript.jumpLatest"); return ( , + ); + else if (named) out.push(); else if (path) out.push(); else if (app) // A cmux app link: a plain anchor, so the click takes the host's link path (opened in the app). diff --git a/webviews/src/agent-session/acpmux/dev.tsx b/webviews/src/agent-session/acpmux/dev.tsx index 5abbeb732440..4633a9ae0eb3 100644 --- a/webviews/src/agent-session/acpmux/dev.tsx +++ b/webviews/src/agent-session/acpmux/dev.tsx @@ -14,6 +14,7 @@ import "./turnChanges/turnChanges.css"; import "./summary/summary.css"; import "./subagents/subagents.css"; import "./header/header.css"; +import "./needsYou/needsYou.css"; import "./composerControls.css"; import "./composerStates.css"; import "./composerLocation.css"; diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index e407d501753d..bf92d516796a 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -50,6 +50,7 @@ import { BOOKMARKS_KIND, bookmarksHeard } from "./threadMinimap/bookmarks"; import { type StartMode, startModeChanged } from "./startMode"; import { sameSessionStart, sessionStartOf } from "./conversation/sessionStart"; import { reduceAgentPair } from "./conversation/agentPairs"; +import { AskTracker, type PendingEntry } from "./needsYou/asks"; export type AcpmuxHostConfig = { protocolVersion: number; @@ -709,6 +710,17 @@ export class AcpmuxDirectClient { private waitingPermissions = new Map(); private pendingSecret?: AcpmuxSecretAsk; private groupedPermissions = new Map(); + /// Every chat's open permission requests, for the Needs you list (needsYou/asks.ts). + private readonly asks = new AskTracker( + (sessionId) => + this.request("_acpmux/info", { sessionId }).then((info: any) => + Array.isArray(info?.pending) ? (info.pending as PendingEntry[]) : undefined, + ), + permissionFromMessage, + () => this.emitInFrame("asks"), + ); + /// The session list the tracker last compared counts against. + private askedSessions?: readonly Session[]; /// The turn's open permission questions, and the edits that wait on one (cx-p5fgx): an edit /// is "pending" from its question, or from arriving while one is open, until it completes. private openQuestions = new Set(); @@ -1181,7 +1193,10 @@ export class AcpmuxDirectClient { msg: { method: "session/update", params: { update: notification.params?.update } }, }); } else if (isNotification(notification, "_acpmux/session_changed")) this.sessionChanged(notification.params); - else if (isNotification(notification, "_acpmux/permission_pending")) this.applyPermission(notification.params); + else if (isNotification(notification, "_acpmux/permission_pending")) { + this.asks.pending(notification.params); + this.applyPermission(notification.params); + } else if (notification.method === "_acpmux/lagged") this.resyncAfterLag(notification.params); // A profile change may pin or free a harness's mode, which changes a new chat's start mode too. else if (notification.method === "_acpmux/harnesses_changed") { @@ -1663,6 +1678,7 @@ export class AcpmuxDirectClient { // Only a purge carries the id alone; every other change carries the whole summary. const session = params?.session as SessionSummary | undefined; if (params?.kind === "purged" && session?.sessionId) { + this.asks.forget(session.sessionId); this.sessionPages.purgedSession(session.sessionId); this.sessions = this.sessions.filter((item) => item.sessionId !== session.sessionId); if (session.sessionId === this.selectedSessionId && this.handoff.state.busy !== "discarding") @@ -1671,6 +1687,7 @@ export class AcpmuxDirectClient { return; } if (!session?.sessionId) return; + this.asks.session(session, typeof params?.kind === "string" ? params.kind : undefined); this.sessionPages.changedSession(session); this.sessions = [...this.sessions.filter((item) => item.sessionId !== session.sessionId), session]; if (session.sessionId === this.selectedSessionId) { @@ -2397,6 +2414,11 @@ export class AcpmuxDirectClient { this.shownSessionId = this.selectedSessionId; // This snapshot carries every event so far, so a snapshot waiting for the frame has nothing left. this.frameSnapshot = undefined; + // A new session list (the watch's page, a reread): chats whose counts moved are read again. + if (this.askedSessions !== this.sessions) { + this.askedSessions = this.sessions; + this.asks.sessions(this.sessions); + } const summary = this.summary; const effort = (summary?.configOptions ?? []).find( (option: any) => option.category === "thought_level" || option.id === "reasoning_effort", @@ -2469,6 +2491,7 @@ export class AcpmuxDirectClient { ? { permissionsWaiting: this.waitingPermissions.size } : {}), secret: this.pendingSecret, + asks: this.asks.list(), catalog: [], commands: this.commands, canLoadOlder: !this.historyExhausted && (this.firstSeq ?? 1) > 1, @@ -3022,8 +3045,14 @@ export class AcpmuxDirectClient { /// `answers` carries a question's harness-shaped answers (question/model.ts `reply`). An option /// pick first takes a gesture ticket for this prompt and option (`transport.gesture`): the host /// signs an allow for another machine's agent daemon only for that exact pick (cx-0q7o). - async permission(permissionId: string, optionId?: string, answers?: Record): Promise { - const sessionId = this.actingSessionId; + async permission( + permissionId: string, + optionId?: string, + answers?: Record, + /// Another chat's ask (the Needs you list); the shown chat's by default. + session?: string, + ): Promise { + const sessionId = session ?? this.actingSessionId; if (!sessionId) return; const ticket = optionId === undefined || answers !== undefined @@ -3406,10 +3435,11 @@ export class AcpmuxDirectClient { } /// Asks a side question about the open session (`/btw`). acpmux answers it in a throwaway copy /// of the chat (`_acpmux/ask`), so the chat never holds it. Resolves to the answer's text. - async ask(question: string): Promise { - if (!this.actingSessionId) return ""; + async ask(question: string, session?: string): Promise { + const sessionId = session ?? this.actingSessionId; + if (!sessionId) return ""; const result = await this.request("_acpmux/ask", { - sessionId: this.actingSessionId, + sessionId, text: question, }); return typeof result?.text === "string" ? result.text : ""; diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 8332cdf7f6c0..622797691c26 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -1,6 +1,7 @@ import { editedPaths } from "./toolPaths"; import type { PermissionClientState } from "./permissions/protocol"; import type { AgentQuestion } from "./question/model"; +import type { WatchedAsk } from "./needsYou/model"; import type { PermissionDecision } from "./permissions/record"; import type { HandoffClientState } from "./handoff/client"; import type { Enforcement } from "./handoff/protocol"; @@ -365,6 +366,9 @@ export type AcpmuxSnapshot = { /// How many more asks wait behind `permission`, answered one at a time; absent when none do. permissionsWaiting?: number; secret?: AcpmuxSecretAsk; + /// Every chat's open permission requests, for the Needs you list (needsYou/asks.ts); the same + /// array until one comes or goes. + asks?: WatchedAsk[]; /** `unavailable`: why acpmux will not run that model (its backend refused it), or that harness * (its launcher check or its model probe failed). */ catalog: { diff --git a/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx b/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx new file mode 100644 index 000000000000..93da16326d39 --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx @@ -0,0 +1,76 @@ +// Jump to latest while asks are open (Leo 2026-10-20, from Claude Code desktop): the floating +// button becomes a pill, the Needs you dot, "Needs your input" (or how many asks are open) and a +// down chevron. A press goes to the chat's own ask first (it waits at the foot of the chat), then +// cycles through the asks open elsewhere, newest first. With nothing open it is the plain button +// again. It reads the Needs you list (model.ts), never a signal of its own. Debug Settings +// `agentPane.debug.needsYouPill` (data-needs-you-pill="off") keeps the plain button. +import type { CSSProperties } from "react"; +import { useT } from "../i18n"; +import type { NeedsYouItem } from "./model"; + +export type TranscriptNeedsYou = { + /// Every open ask, in this chat and elsewhere. + count: number; + /// Asks open in other chats: the pill shows even at the latest message, to go to them. + elsewhere: number; + /// After the press: `behind` is whether it also jumped to the latest message (this chat's ask). + next(behind: boolean): void; +}; + +export function needsYouPillOn(root: { dataset: DOMStringMap } | undefined = globalThis.document?.documentElement): boolean { + return root?.dataset.needsYouPill !== "off"; +} + +/// The order a press visits asks in: this chat's first, then the others, newest first. +export function pillOrder(items: readonly NeedsYouItem[], sessionId: string | undefined): NeedsYouItem[] { + const here = items.filter((item) => item.sessionId === sessionId); + return [...here, ...items.filter((item) => item.sessionId !== sessionId)]; +} + +/// The next ask a press goes to: with `behind`, the press already jumped to this chat's own ask, so +/// it stays; else the next ask elsewhere after `last` (the one the previous press went to). +export function nextAsk( + items: readonly NeedsYouItem[], + sessionId: string | undefined, + behind: boolean, + last: string | undefined, +): NeedsYouItem | undefined { + const order = pillOrder(items, sessionId); + if (behind && order[0]?.sessionId === sessionId) return undefined; + const away = order.filter((item) => item.sessionId !== sessionId); + if (away.length === 0) return undefined; + const at = away.findIndex((item) => item.id === last); + return away[(at + 1) % away.length]; +} + +export function NeedsYouPill({ + count, + shown, + lift, + onJump, +}: { + count: number; + shown: boolean; + lift: number; + onJump(focused: boolean): void; +}) { + const t = useT(); + const label = count > 1 ? t("needsYou.pillCount", { n: count }) : t("needsYou.pill"); + return ( + + ); +} diff --git a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts new file mode 100644 index 000000000000..eb620402045e --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.gallery.ts @@ -0,0 +1,170 @@ +// l10n-allow-file: gallery fixtures (sample asks, chat names and commands), not shipped UI. +// Needs you (needsYou/NeedsYou.tsx): the header badge counts every open ask, from this chat, the +// Chief, its subagents and the other agent chats; a press opens the pinned list with one-click +// answers, Go to source and Ask about this. While asks are open, Jump to latest is the Needs your +// input pill (JumpPill.tsx). Both are Debug Settings toggles, on by default; `before` is the pane +// with both off, as it drew before. Light and dark come from the gallery's appearance control. +import { agentPaneEntry } from "../../../gallery/format"; +import type { PlayContext } from "../../../gallery/play"; +import { activity, assistant, chat, session, SESSION_ID, tool, user } from "../../../gallery/fixtures/acpmux"; +import type { AcpmuxPermission, AcpmuxSnapshot } from "../model"; +import type { AgentQuestion } from "../question/model"; +import type { WatchedAsk } from "./model"; + +const ago = (minutes: number) => Date.now() - minutes * 60_000; +const yesNo: AcpmuxPermission["options"] = [ + { id: "allow_once", name: "Allow", kind: "allow_once", allow: true }, + { id: "allow_always", name: "Always allow", kind: "allow_always", allow: true }, + { id: "reject_once", name: "Deny", kind: "reject_once", allow: false }, +]; + +const here: AcpmuxPermission = { + permissionId: "ask-here", + title: "Run the checks", + kind: "execute", + command: "bun run check", + pending: true, + options: yesNo, +}; +const login: AgentQuestion = { + id: "ask-decision", + source: { harness: "codex", session: "s-codex", permission: "ask-decision", answerOption: "allow_once" }, + items: [ + { + id: "q0", + header: "Auth", + prompt: "Which sign-in should the settings page use?", + options: [ + { id: "OAuth", label: "OAuth" }, + { id: "Passkeys", label: "Passkeys" }, + { id: "Both", label: "Both" }, + ], + multiSelect: false, + allowsOther: true, + }, + ], + state: { kind: "pending" }, +}; +const asks: WatchedAsk[] = [ + { sessionId: SESSION_ID, at: ago(1), permission: here }, + { + sessionId: "s-chief", + at: ago(3), + permission: { permissionId: "ask-chief", title: "Merge #22410 into feat-cmux-next?", pending: true, options: yesNo }, + }, + { + sessionId: "s-codex", + at: ago(6), + permission: { permissionId: "ask-decision", pending: true, options: yesNo, question: login }, + }, + { + sessionId: "s-verify", + at: ago(14), + permission: { + permissionId: "ask-verify", + title: "Verify the sidebar dot reads in light mode", + description: "Open a chat that waits on you and look at its row in the sidebar.", + pending: true, + options: yesNo, + }, + }, +]; +const sessions = [ + session({ sessionId: "s-chief", title: "Chief", harness: "chief", pendingPermissions: 1, updatedAt: ago(3) }), + session({ + sessionId: "s-codex", + title: "Port settings to React Aria", + harness: "codex", + spawnedBy: "s-chief", + pendingPermissions: 1, + indicator: "input", + updatedAt: ago(6), + }), + session({ sessionId: "s-verify", title: "Sidebar dot polish", pendingPermissions: 1, indicator: "input", updatedAt: ago(14) }), + session({ + sessionId: "s-remote", + title: "Release notes on build box", + host: "build-box", + hostKind: "cloud", + indicator: "input", + preview: "Which tag should the notes start from?", + updatedAt: ago(32), + }), +]; + +const rows = [ + user("Make the retry helper back off exponentially", 4), + activity([tool("Edit src/fetch.ts", "edit", "completed"), tool("bun test fetch", "execute", "completed")], 3), + assistant("The helper now waits 200 ms, then doubles up to 5 s, with jitter. I want to run the full checks next.", 2), +]; + +function snapshot(fields: Partial = {}): AcpmuxSnapshot { + const base = chat(rows, { permission: here, ...fields }); + return { + ...base, + sessions: [{ ...base.sessions[0]!, pendingPermissions: 1, indicator: "input" }, ...sessions], + asks, + ...fields, + }; +} + +const openList = async (ctx: PlayContext) => { + await ctx.click({ selector: ".acpmux-needs-you-button" }); + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-needs-you-panel")); +}; + +export default agentPaneEntry({ + id: "agent-pane.needs-you", + title: "Needs you", + area: "Agent pane", + height: 640, + keywords: ["needs you", "asks", "approvals", "pending", "inbox", "needs your input"], + 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.", + snapshot: snapshot(), + play: openList, + }, + "badge-and-pill": { + note: "Closed: the badge counts five asks and Jump to latest reads Needs your input · 5.", + snapshot: snapshot(), + }, + "pill-one-ask": { + note: "One ask, in another chat: the pill reads Needs your input and goes there.", + snapshot: { + ...chat(rows), + sessions: [chat(rows).sessions[0]!, sessions[0]!], + asks: [asks[1]!], + }, + }, + "ask-about-this": { + note: "Ask about this opens a side question to the asking chat, seeded with What is going on here?", + snapshot: snapshot(), + play: async (ctx) => { + await openList(ctx); + await ctx.click({ selector: ".acpmux-needs-you-item:nth-child(3) .acpmux-needs-you-ask-about" }); + await ctx.waitFor(() => ctx.document.querySelector(".acpmux-needs-you-side-field")); + }, + }, + compact: { + note: "Debug Settings Needs you: Compact: one line per ask, no answers.", + snapshot: snapshot(), + documentAttributes: { "needs-you": "compact" }, + play: openList, + }, + empty: { + note: "Nothing waits: the badge has no count and the list says so.", + snapshot: chat(rows), + play: openList, + }, + before: { + note: "Both Debug toggles off: the pane as it drew before, with no badge and the plain Jump to latest.", + snapshot: snapshot(), + documentAttributes: { "needs-you": "off", "needs-you-pill": "off" }, + }, + }, +}); diff --git a/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx new file mode 100644 index 000000000000..1ecc11c02de5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/NeedsYou.tsx @@ -0,0 +1,403 @@ +// The chat header's Needs you badge and its pinned list (Leo 2026-10-20), shaped like Discord's +// pinned messages: a count in the header, and a press opens every open ask in one list. Each ask +// says who asked and how long ago, answers in one click when the answer is simple (Allow, Deny, one +// of its choices), jumps to where it was asked (Go to source) and can be asked about in a side +// question seeded with the ask (Ask about this), so nobody re-reads a thread to answer it. An ask +// leaves the list once acpmux says it was answered, wherever that was (asks.ts). +import { useEffect, useRef, useState } from "react"; +import { AgentMark } from "../../shared/AgentMark"; +import { useMinuteClock } from "../clock"; +import { RevealedMarkdown } from "../conversation/RevealedMarkdown"; +import { agentDisplayName } from "../agents"; +import { useT, type StringKey, type Translate } from "../i18n"; +import { Icon } from "../icons/Icon"; +import { shortAge } from "../sessionList"; +import { Popover } from "../../../ui/Popover"; +import { usePopoverTrigger } from "../../../ui/popoverTrigger"; +import { onOpenNeedsYou } from "./links"; +import type { NeedsYouAnswer, NeedsYouItem, NeedsYouKind } from "./model"; +import "./needsYou.css"; + +/// The Debug choice `agentPane.debug.needsYou` (data-needs-you on ): `pinned` (the default) +/// draws each ask with its answers; `compact` one line per ask; `off` no badge at all. +export type NeedsYouMode = "pinned" | "compact" | "off"; + +export function needsYouMode(root: { dataset: DOMStringMap } | undefined = globalThis.document?.documentElement): NeedsYouMode { + const value = root?.dataset.needsYou; + return value === "off" || value === "compact" ? value : "pinned"; +} + +export type NeedsYouActions = { + /// Sends a one-click answer; rejects when acpmux refused it. + answer(item: NeedsYouItem, answer: NeedsYouAnswer): Promise; + /// Opens the chat the ask is in, at the ask. + goTo(item: NeedsYouItem): void; + /// Asks the asking chat a side question (`_acpmux/ask`), seeded with the ask; resolves to the answer. + ask(item: NeedsYouItem, question: string): Promise; +}; + +const KINDS: Record = { + approval: "needsYou.kind.approval", + decision: "needsYou.kind.decision", + verify: "needsYou.kind.verify", + held: "needsYou.kind.held", + input: "needsYou.kind.input", +}; + +const CHIEF_AVATAR = new URL("../mascots/ChiefMascotOrb.png", import.meta.url).href; + +/// The header's badge: the count of open asks; a press opens the list. +export function NeedsYouButton({ + items, + mode = "pinned", + actions, + defaultOpen = false, + githubRepository, +}: { + items: readonly NeedsYouItem[]; + mode?: NeedsYouMode; + actions: NeedsYouActions; + defaultOpen?: boolean; + githubRepository?: string; +}) { + const t = useT(); + const [open, setOpen] = useState(defaultOpen); + const [focus, setFocus] = useState(); + const [button, setButton] = useState(null); + const buttonRef = useRef(null); + const trigger = usePopoverTrigger(open, setOpen); + // A `cmux://needs-you/` chip in a reply opens the list on its ask (links.ts). + useEffect( + () => + onOpenNeedsYou((itemId) => { + setFocus(itemId); + setOpen(true); + }), + [], + ); + if (mode === "off") return null; + const count = items.length; + const label = count > 0 ? t("needsYou.badgeLabel", { n: count }) : t("needsYou.title"); + return ( + <> + + { + if (!next) { + setOpen(false); + setFocus(undefined); + } + }} + anchor={button} + label={t("needsYou.title")} + className="acpmux-needs-you" + side="bottom" + finalFocus={buttonRef} + > + { + setOpen(false); + actions.goTo(item); + }, + }} + focus={focus} + githubRepository={githubRepository} + /> + + + ); +} + +/// The list itself: a title with the count, then each ask, newest first. +export function NeedsYouPanel({ + items, + mode = "pinned", + actions, + focus, + githubRepository, +}: { + items: readonly NeedsYouItem[]; + mode?: NeedsYouMode; + actions: NeedsYouActions; + /// The ask a link opened the list on: scrolled to and marked. + focus?: string; + githubRepository?: string; +}) { + const t = useT(); + const now = useMinuteClock(); + // Asks answered here, hidden until acpmux's change takes them off the list. + const [sent, setSent] = useState>(new Set()); + const shown = items.filter((item) => !sent.has(item.id)); + const missing = focus !== undefined && !items.some((item) => item.id === focus); + return ( +
+
+ + {t("needsYou.title")} + {shown.length > 0 && {shown.length}} +
+ {missing &&

{t("needsYou.gone")}

} + {shown.length === 0 ? ( +
+ + {t("needsYou.empty")} +
+ ) : ( +
    + {shown.map((item) => ( + setSent((before) => new Set(before).add(item.id))} + onRefused={() => + setSent((before) => { + const next = new Set(before); + next.delete(item.id); + return next; + }) + } + /> + ))} +
+ )} +
+ ); +} + +type Asked = { question: string; answer?: string; error?: string }; + +function NeedsYouRow({ + item, + mode, + now, + focused, + actions, + githubRepository, + onSent, + onRefused, +}: { + item: NeedsYouItem; + mode: NeedsYouMode; + now: number; + focused: boolean; + actions: NeedsYouActions; + githubRepository?: string; + onSent(): void; + onRefused(): void; +}) { + const t = useT(); + const row = useRef(null); + const [failed, setFailed] = useState(false); + const [sending, setSending] = useState(); + const [asking, setAsking] = useState(false); + const [asked, setAsked] = useState([]); + useEffect(() => { + if (focused) row.current?.scrollIntoView({ block: "nearest" }); + }, [focused]); + const answer = (choice: NeedsYouAnswer) => { + if (sending) return; + setSending(choice.id); + setFailed(false); + actions.answer(item, choice).then(onSent, () => { + setSending(undefined); + setFailed(true); + onRefused(); + }); + }; + const compact = mode === "compact"; + const age = shortAge(item.at, now, t); + return ( +
  • +
    + + {item.who.chief ? t("needsYou.chief") : item.who.name} + {item.who.via && {t("needsYou.via", { name: item.who.via })}} + {t(KINDS[item.kind])} + {age && {age}} + +
    +

    {askText(item, t)}

    + {!compact && item.detail && ( +

    + {item.detail} +

    + )} + {!compact && ( +
    + {item.answers.map((choice) => ( + + ))} + +
    + )} + {failed &&

    {t("needsYou.answerFailed")}

    } + {asking && !compact && ( + + )} +
  • + ); +} + +/// Ask about this: a side question to the asking chat, seeded with "What is going on here?". Each +/// answer stays under the ask while the list is open; a follow-up carries the earlier ones. +function SideQuestion({ + item, + asked, + onAsked, + ask, + githubRepository, +}: { + item: NeedsYouItem; + asked: readonly Asked[]; + onAsked(next: Asked[]): void; + ask: NeedsYouActions["ask"]; + githubRepository?: string; +}) { + const t = useT(); + const [draft, setDraft] = useState(() => (asked.length ? "" : t("needsYou.askSeed"))); + const busy = asked.length > 0 && asked.at(-1)!.answer === undefined && asked.at(-1)!.error === undefined; + const send = () => { + const question = draft.trim(); + if (!question || busy) return; + const before = asked.filter((entry) => entry.answer !== undefined); + const pending = [...asked, { question }]; + onAsked(pending); + setDraft(""); + ask(item, sideQuestionPrompt(item, question, before)).then( + (answer) => onAsked([...asked, { question, answer }]), + (error: unknown) => + onAsked([...asked, { question, error: error instanceof Error && error.message ? error.message : "" }]), + ); + }; + return ( +
    + {asked.map((entry, index) => ( +
    +

    {entry.question}

    + {entry.answer !== undefined ? ( +
    + +
    + ) : entry.error !== undefined ? ( +

    + {entry.error ? t("needsYou.askFailedReason", { reason: entry.error }) : t("needsYou.askFailed")} +

    + ) : ( +

    {t("needsYou.asking")}

    + )} +
    + ))} +
    { + event.preventDefault(); + send(); + }} + > + setDraft(event.target.value)} + // biome-ignore lint: the field opens on the person's own click + autoFocus + /> + +
    +
    + ); +} + +function Avatar({ item }: { item: NeedsYouItem }) { + if (item.who.chief) + return ; + return ( + + + + ); +} + +/// The ask's line: its own text, else what its kind says. +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"); +} + +/// The side question acpmux asks the chat's copy (`_acpmux/ask`): the ask, then the person's +/// question and any earlier answers, so the copy answers about this ask without the chat re-reading. +export function sideQuestionPrompt(item: NeedsYouItem, question: string, before: readonly Asked[] = []): string { + // l10n-allow: a prompt to the agent, never shown to the person + const lines = [ + "I am about to answer an open ask from this chat and want to understand it first.", + `The ask (${item.kind}): ${item.ask || "waiting for my answer"}`, + ...(item.detail ? [`Details: ${item.detail}`] : []), + ...(item.question?.items[0]?.options.length + ? [`Choices: ${item.question.items[0].options.map((option) => option.label).join(" / ")}`] + : []), + ...before.flatMap((entry) => [`Earlier I asked: ${entry.question}`, `You said: ${entry.answer ?? ""}`]), + `My question: ${question}`, + "Answer briefly from what this chat has done: what the agent is doing, why it asks, and what each answer would lead to.", + ]; + return lines.join("\n"); +} diff --git a/webviews/src/agent-session/acpmux/needsYou/asks.ts b/webviews/src/agent-session/acpmux/needsYou/asks.ts new file mode 100644 index 000000000000..321b56eeec56 --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/asks.ts @@ -0,0 +1,150 @@ +// The permission requests acpmux holds open, in every chat this pane's connection watches: the +// Needs you list's asks (model.ts). The pane already watches every session (`_acpmux/watch`), and +// acpmux tells a watcher of each new request (`_acpmux/permission_pending`, `via: watch` for a +// chat the pane is not attached to) and of each change to a chat's count (`session_changed`, its +// `pendingPermissions`). What is open now is acpmux's own list, read with `_acpmux/info` +// (`pending`), as the Feed bridge and `acpmux pending` read it: a chat whose count changed in a way +// the page cannot account for is read again, so an ask answered anywhere (its card, the Feed, the +// CLI, another pane) leaves; a count of zero clears the chat at once. +import type { AcpmuxPermission } from "../model"; +import type { WatchedAsk } from "./model"; + +/// A session as the watch reports it: its id, how many requests it holds open, when it last changed. +export type WatchedSession = { sessionId: string; pendingPermissions?: unknown; updatedAt?: unknown }; + +/// One pending entry of `_acpmux/info`: `{permissionId, request, turnId?, ...}`. +export type PendingEntry = { permissionId?: unknown; request?: unknown; turnId?: unknown; groupId?: unknown }; + +/// Reads a chat's pending requests (`_acpmux/info` `pending`); undefined when it cannot be read (a +/// chat on another computer, a closed connection): what the page holds then stays. +export type ReadPending = (sessionId: string) => Promise; + +/// Maps a `permission_pending` notification (or an info entry with its session) to the pane's +/// permission: direct.ts `permissionFromMessage`, the card's own mapping. +export type ParsePermission = (message: unknown, sessionId: string) => AcpmuxPermission | undefined; + +const count = (value: unknown) => (typeof value === "number" && value > 0 ? Math.floor(value) : 0); + +export class AskTracker { + private readonly asks = new Map(); + /// Each chat's newest read: an older read that lands after it is dropped. + private readonly reads = new Map(); + private readonly updatedAt = new Map(); + private listed: WatchedAsk[] = []; + private stale = false; + + constructor( + private readonly read: ReadPending, + private readonly parse: ParsePermission, + private readonly changed: () => void, + private readonly now: () => number = Date.now, + ) {} + + /// The open asks, oldest seen first; the same array until one comes or goes. + list(): WatchedAsk[] { + if (this.stale) { + this.listed = [...this.asks.values()]; + this.stale = false; + } + return this.listed; + } + + /// A `_acpmux/permission_pending` notification, on an attached chat or a watched one. + pending(message: unknown): void { + const sessionId = sessionOf(message); + if (!sessionId) return; + const permission = this.parse(message, sessionId); + if (!permission || this.asks.has(permission.permissionId)) return; + // A read already on its way may predate this ask, and would drop it: it no longer counts. + this.reads.set(sessionId, (this.reads.get(sessionId) ?? 0) + 1); + this.asks.set(permission.permissionId, { sessionId, permission, at: this.now() }); + this.touch(); + } + + /// The session list (the watch's first page, a reread after a lag): reads every chat whose + /// count differs from the asks held for it. + sessions(sessions: readonly WatchedSession[]): void { + for (const session of sessions) this.session(session); + } + + /// One chat's summary changed (`session_changed`). `kind` is the change's kind: an answer + /// (`permission_decision`, `permission_resolved`) rereads the chat even when its count looks + /// right, since another request may have taken the answered one's place. + session(session: WatchedSession, kind?: string): void { + if (typeof session.updatedAt === "number") this.updatedAt.set(session.sessionId, session.updatedAt); + const reported = count(session.pendingPermissions); + const held = this.heldFor(session.sessionId); + if (reported === 0) { + // Nothing open: no read needed, and a read still in flight is stale. + this.reads.set(session.sessionId, (this.reads.get(session.sessionId) ?? 0) + 1); + if (held > 0) this.forget(session.sessionId); + return; + } + const answered = kind === "permission_decision" || kind === "permission_resolved"; + if (reported !== held || answered) void this.reread(session.sessionId); + } + + /// A chat acpmux purged: its asks go. + forget(sessionId: string): void { + let removed = false; + for (const [id, ask] of this.asks) + if (ask.sessionId === sessionId) { + this.asks.delete(id); + removed = true; + } + if (removed) this.touch(); + } + + private heldFor(sessionId: string): number { + let held = 0; + for (const ask of this.asks.values()) if (ask.sessionId === sessionId) held++; + return held; + } + + private async reread(sessionId: string): Promise { + const generation = (this.reads.get(sessionId) ?? 0) + 1; + this.reads.set(sessionId, generation); + let entries: readonly PendingEntry[] | undefined; + try { + entries = await this.read(sessionId); + } catch { + entries = undefined; + } + if (entries === undefined || this.reads.get(sessionId) !== generation) return; + this.reconcile(sessionId, entries); + } + + private reconcile(sessionId: string, entries: readonly PendingEntry[]): void { + const open = new Set(); + let moved = false; + for (const entry of entries) { + const permission = this.parse({ sessionId, permissionId: entry.permissionId, request: entry.request }, sessionId); + if (!permission) continue; + if (typeof entry.turnId === "string" && !permission.turnId) permission.turnId = entry.turnId; + open.add(permission.permissionId); + if (this.asks.has(permission.permissionId)) continue; + // An ask found by a read was asked before the page saw it: the chat's last change dates it. + const at = Math.min(this.now(), this.updatedAt.get(sessionId) ?? Number.POSITIVE_INFINITY); + this.asks.set(permission.permissionId, { sessionId, permission, at }); + moved = true; + } + for (const [id, ask] of this.asks) + if (ask.sessionId === sessionId && !open.has(id)) { + this.asks.delete(id); + moved = true; + } + if (moved) this.touch(); + } + + private touch(): void { + this.stale = true; + this.changed(); + } +} + +function sessionOf(message: unknown): string | undefined { + if (!message || typeof message !== "object") return undefined; + const envelope = message as { sessionId?: unknown; request?: { sessionId?: unknown } }; + const id = envelope.sessionId ?? envelope.request?.sessionId; + return typeof id === "string" && id ? id : undefined; +} diff --git a/webviews/src/agent-session/acpmux/needsYou/links.ts b/webviews/src/agent-session/acpmux/needsYou/links.ts new file mode 100644 index 000000000000..49708525a3b5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/links.ts @@ -0,0 +1,36 @@ +// An agent points at an open ask instead of restating it: `cmux://needs-you/` (any +// build's scheme), the id `acpmux pending --json` prints as `needsYou`. In a reply it draws as a +// chip (conversation/Markdown.tsx) that opens the header's Needs you list on that ask; the page +// handles it, so the link never leaves the pane. +import { askItemId } from "./model"; + +/// What a permission id may hold in a link (acpmux ids are UUIDs; DeepLink's token rule). +const TOKEN = /^[A-Za-z0-9._-]{1,200}$/; +const LINK = /^cmux(?:-[a-z0-9-]+)?:\/\/needs-you\/([^/?#]+)$/; + +/// The Needs you item a link names, or undefined for any other text. +export function needsYouTarget(href: string): string | undefined { + const id = LINK.exec(href.trim())?.[1]; + return id && TOKEN.test(id) ? askItemId(id) : undefined; +} + +/// The link for an ask, in `scheme` (the build's, links.ts `linkScheme`). +export function needsYouLink(permissionId: string, scheme = "cmux"): string | undefined { + return TOKEN.test(permissionId) ? `${scheme}://needs-you/${permissionId}` : undefined; +} + +type Opener = (itemId?: string) => void; +const openers = new Set(); + +/// The header's list registers here while it is mounted; it opens on the item asked for. +export function onOpenNeedsYou(opener: Opener): () => void { + openers.add(opener); + return () => void openers.delete(opener); +} + +/// Opens the Needs you list, on `itemId` when given. False when no list is mounted (the toggle is +/// off, or the page has no header). +export function openNeedsYou(itemId?: string): boolean { + for (const opener of openers) opener(itemId); + return openers.size > 0; +} diff --git a/webviews/src/agent-session/acpmux/needsYou/model.ts b/webviews/src/agent-session/acpmux/needsYou/model.ts new file mode 100644 index 000000000000..08fe4f453dfa --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/model.ts @@ -0,0 +1,195 @@ +// Needs you (Leo 2026-10-20): one list of every open ask, so no agent has to restate "still waiting +// on you". It holds approvals, decisions, verify-this asks and held messages from the Chief, its +// subagents and every other agent chat. Nothing here is a store of its own: every item is derived +// from what acpmux already reports (asks.ts tracks its pending permission requests, the same ones +// `acpmux pending` lists and the Feed bridge posts; the session list's `indicator` and +// `pendingPermissions`; the open chat's agent pair holds), so an ask leaves the list as soon as +// acpmux says it was answered, wherever it was answered. +// +// The item is the shared shape (plans/cmux-next/needs-you.md): the header's pinned list draws it, +// the Feed keys the same prompt `acpmux:` (its dedupe key), and an agent points at an +// item with `cmux://needs-you/` (links.ts) instead of restating it. +import type { AcpmuxAgentPair, AcpmuxPermission, AcpmuxRow } from "../model"; +import { questionSummary, type AgentQuestion } from "../question/model"; +import type { AcpmuxSessionEntry } from "../sessionList"; + +/// What the ask wants: an allow or deny of a tool (`approval`), a pick among choices +/// (`decision`), a check of the agent's work (`verify`), messages acpmux holds between two agents +/// (`held`), or a chat that waits on the person with no ask the page can read (`input`: a chat on +/// another computer, whose prompts reach this pane only as its session's indicator). +export type NeedsYouKind = "approval" | "decision" | "verify" | "held" | "input"; + +/// A one-click answer: an allow, a deny, one of a question's choices, or a held pair's Resume. +export type NeedsYouAnswer = { id: string; label: string; tone: "allow" | "deny" | "choice" }; + +/// Who asked: the chat's title and agent, whether it is the Chief, and the chat that started it. +export type NeedsYouWho = { name: string; harness?: string; chief: boolean; via?: string }; + +export type NeedsYouItem = { + /// Stable while the ask is open: `acpmux:` (the Feed's key for the same prompt), + /// `held::` or `chat:`. + id: string; + kind: NeedsYouKind; + sessionId: string; + /// The ask on one line. + ask: string; + /// More about the ask, when it says more than its line: a description, or the command it would run. + detail?: string; + /// `detail` is a command (drawn as code). + command?: boolean; + who: NeedsYouWho; + /// When it was asked, as near as acpmux tells (epoch ms). + at?: number; + /// One-click answers; none when the ask needs more than a click (several questions, free text). + answers: NeedsYouAnswer[]; + permissionId?: string; + /// The turn the ask was made in, for Go to source (`#turn-`). + turnId?: string; + /// A held pair's other agent (its acpmux peer id), which Resume names, and how many messages + /// acpmux holds; `ask` is that agent's name. + peer?: string; + held?: number; + question?: AgentQuestion; +}; + +/// A permission request acpmux reports pending (asks.ts), with when the page first saw it. +export type WatchedAsk = { sessionId: string; permission: AcpmuxPermission; at: number }; + +export type NeedsYouInput = { + sessions: readonly AcpmuxSessionEntry[]; + asks: readonly WatchedAsk[]; + /// The open chat and its rows: its agent pair holds are read from them. + open?: { sessionId?: string; rows: readonly AcpmuxRow[] }; +}; + +/// The Feed's key for a prompt (CmuxNextApp AcpmuxPermissionFeedBridge `dedupe_key`). +export const askItemId = (permissionId: string) => `acpmux:${permissionId}`; + +/// A question's choices as one-click answers: only a single question with one pick among at most +/// four choices; anything else opens at its source. +export const QUICK_CHOICES = 4; + +/// Verify-this asks: an ask that starts by asking the person to check something. +const VERIFY = /^\s*(?:please\s+|can you\s+|could you\s+)?(?:verify|check|confirm|review|test|try|look at)\b/i; + +/// The Chief asks through its own harness (`chief`), and its chats are named for it. +const CHIEF_NAME = /^chief\b/i; + +export function needsYouItems(input: NeedsYouInput): NeedsYouItem[] { + const byId = new Map(input.sessions.map((session) => [session.sessionId, session])); + const items: NeedsYouItem[] = []; + const asked = new Set(); + for (const watched of input.asks) { + const session = byId.get(watched.sessionId); + if (session?.archived) continue; + asked.add(watched.sessionId); + items.push(askItem(watched, session, byId)); + } + if (input.open?.sessionId) { + const session = byId.get(input.open.sessionId); + for (const pair of heldPairs(input.open.rows)) { + items.push({ + id: `held:${input.open.sessionId}:${pair.peer}`, + kind: "held", + sessionId: input.open.sessionId, + ask: pair.peerName, + detail: pair.held[0]?.text, + who: who(session, input.open.sessionId, byId), + answers: [{ id: "resume", label: "resume", tone: "allow" }], + peer: pair.peer, + held: pair.held.length, + }); + } + } + // 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. + for (const session of input.sessions) { + if (asked.has(session.sessionId) || session.archived) continue; + if (session.indicator !== "input" && !(session.pendingPermissions && session.pendingPermissions > 0)) continue; + items.push({ + id: `chat:${session.sessionId}`, + kind: "input", + sessionId: session.sessionId, + ask: session.preview ?? "", + who: who(session, session.sessionId, byId), + ...(session.updatedAt ? { at: session.updatedAt } : {}), + answers: [], + }); + } + // Newest first, as every list of chats in the pane; an ask without a time last. + return items.sort((a, b) => (b.at ?? 0) - (a.at ?? 0)); +} + +function askItem( + watched: WatchedAsk, + session: AcpmuxSessionEntry | undefined, + byId: ReadonlyMap, +): NeedsYouItem { + const { permission } = watched; + const question = permission.question; + const ask = question ? questionSummary(question) : (permission.title ?? permission.description ?? permission.kind ?? ""); + const detail = question ? undefined : (permission.command ?? permission.description); + const kind: NeedsYouKind = VERIFY.test(ask) ? "verify" : question ? "decision" : "approval"; + const asker = who(session, watched.sessionId, byId); + return { + id: askItemId(permission.permissionId), + kind, + sessionId: watched.sessionId, + ask, + ...(detail && detail !== ask ? { detail, ...(permission.command ? { command: true } : {}) } : {}), + who: question?.source.harness === "chief" ? { ...asker, chief: true } : asker, + at: watched.at, + answers: question ? questionAnswers(question) : permissionAnswers(permission), + permissionId: permission.permissionId, + ...(permission.turnId ? { turnId: permission.turnId } : {}), + ...(question ? { question } : {}), + }; +} + +/// Allow once and deny once: the answers a click may give. An allow that lasts (always, for the +/// chat) is the card's to give, where its scope is spelled out. +function permissionAnswers(permission: AcpmuxPermission): NeedsYouAnswer[] { + const allow = permission.options.find((option) => option.kind === "allow_once" && option.allow); + const deny = permission.options.find((option) => option.kind === "reject_once" && !option.allow); + return [ + ...(allow ? [{ id: allow.id, label: allow.name, tone: "allow" as const }] : []), + ...(deny ? [{ id: deny.id, label: deny.name, tone: "deny" as const }] : []), + ]; +} + +function questionAnswers(question: AgentQuestion): NeedsYouAnswer[] { + const [item, ...more] = question.items; + if (!item || more.length > 0 || item.multiSelect || item.options.length > QUICK_CHOICES) return []; + return item.options.map((option) => ({ id: option.id, label: option.label, tone: "choice" as const })); +} + +/// The open chat's agent pairs that still hold messages for the person to release. +export function heldPairs(rows: readonly AcpmuxRow[]): AcpmuxAgentPair[] { + const pairs = new Map(); + for (const row of rows) { + const pair = row.kind === "agentPair" ? row.agentPair : undefined; + if (!pair) continue; + // A later row for the same peer replaces an earlier one (a re-pause takes over what it held). + if (pair.resumed || pair.held.length === 0) pairs.delete(pair.peer); + else pairs.set(pair.peer, pair); + } + return [...pairs.values()]; +} + +function who( + session: AcpmuxSessionEntry | undefined, + sessionId: string, + byId: ReadonlyMap, +): NeedsYouWho { + const name = session?.displayTitle ?? session?.name ?? sessionId; + const parent = session?.spawnedBy ? byId.get(session.spawnedBy) : undefined; + return { + name, + ...(session?.harness ? { harness: session.harness } : {}), + chief: isChief(session), + ...(parent ? { via: parent.displayTitle ?? parent.name ?? parent.sessionId } : {}), + }; +} + +const isChief = (session: AcpmuxSessionEntry | undefined) => + session?.harness === "chief" || CHIEF_NAME.test(session?.name ?? ""); diff --git a/webviews/src/agent-session/acpmux/needsYou/needsYou.css b/webviews/src/agent-session/acpmux/needsYou/needsYou.css new file mode 100644 index 000000000000..adb2b070a210 --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/needsYou.css @@ -0,0 +1,395 @@ +/* Needs you (needsYou/NeedsYou.tsx, JumpPill.tsx): the header badge, its pinned list, and the + chat's Needs your input pill. One mark says "this needs you" everywhere: the blue dot the + window sidebar draws for a workspace that waits on you (the sidebar's input mark, the badge, the + pill). */ +.acpmux-shell, +.ui-positioner { + --acpmux-needs-you-mark: rgb(57 130 245); /* theme-fixed: the sidebar's unread dot on every theme (Palette.notificationBlue) */ +} + +.acpmux-needs-you-dot { + flex: none; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--acpmux-needs-you-mark); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--acpmux-needs-you-mark) 18%, transparent); +} +.acpmux-session-mark-input, +.acpmux-session-preview-status.acpmux-session-mark-input { + color: var(--acpmux-needs-you-mark); +} +.acpmux-session-mark .acpmux-needs-you-dot { + width: 7px; + height: 7px; + box-shadow: none; +} + +/* The badge: the header's tool button, widened by the count while asks are open. */ +.acpmux-header-tool.acpmux-needs-you-button[data-count] { + width: auto; + gap: 5px; + padding: 0 7px 0 6px; +} +.acpmux-needs-you-count { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--text-detail); + font-weight: var(--weight-emphasis, 600); + font-variant-numeric: tabular-nums; +} +.acpmux-needs-you-count .acpmux-needs-you-dot { + width: 6px; + height: 6px; + box-shadow: none; +} + +/* The pinned list: the shared popup surface, as wide as a card. */ +.acpmux-needs-you { + width: 380px; + max-width: calc(100vw - 24px); + max-height: min(560px, 75vh); + overflow: auto; + box-sizing: border-box; + padding: 0; + border: 0; + border-radius: var(--ui-popup-radius); + background: var(--surface-popup); + box-shadow: var(--acpmux-popup-elevation); + transform-origin: var(--transform-origin); + color: var(--agent-text); + font-size: var(--text-control); + line-height: 18px; + outline: none; + overscroll-behavior: contain; +} +@media (prefers-reduced-motion: no-preference) { + .acpmux-needs-you { + animation: ui-popup-open var(--ui-popup-open) ease-out; + } +} +.acpmux-needs-you-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px 8px; + background: var(--surface-popup); + border-bottom: var(--cmux-hairline, 0.5px) solid var(--agent-border); + color: var(--agent-muted); +} +.acpmux-needs-you-title { + color: var(--agent-text); + font-weight: var(--weight-emphasis, 600); +} +.acpmux-needs-you-total { + margin-left: auto; + min-width: 18px; + padding: 0 6px; + border-radius: 9px; + background: color-mix(in srgb, var(--acpmux-needs-you-mark) 16%, transparent); + color: var(--acpmux-needs-you-mark); + font-size: var(--text-caption); + font-weight: var(--weight-emphasis, 600); + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.acpmux-needs-you-note { + margin: 8px 12px 0; + color: var(--agent-muted); + font-size: var(--text-detail); +} +.acpmux-needs-you-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 28px 16px 30px; + color: var(--agent-muted); +} +.acpmux-needs-you-list { + margin: 0; + padding: 4px; + list-style: none; +} +.acpmux-needs-you-item { + padding: 9px 8px 10px; + border-radius: 8px; +} +.acpmux-needs-you-item + .acpmux-needs-you-item { + margin-top: 2px; +} +.acpmux-needs-you-item:hover { + background: color-mix(in srgb, var(--agent-text) 4%, transparent); +} +.acpmux-needs-you-item[data-focused] { + background: color-mix(in srgb, var(--acpmux-needs-you-mark) 10%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--acpmux-needs-you-mark) 35%, transparent); +} +.acpmux-needs-you-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--agent-muted); + font-size: var(--text-detail); + white-space: nowrap; +} +.acpmux-needs-you-avatar { + flex: none; + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: color-mix(in srgb, var(--agent-text) 8%, transparent); + object-fit: cover; +} +.acpmux-needs-you-avatar.is-chief { + background: color-mix(in srgb, var(--agent-text) 6%, transparent); +} +.acpmux-needs-you-who { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--agent-text); + font-weight: var(--weight-emphasis, 600); +} +.acpmux-needs-you-via { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.acpmux-needs-you-kind { + flex: none; + padding: 0 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--agent-text) 7%, transparent); + font-size: var(--text-caption); + line-height: 16px; +} +.acpmux-needs-you-item[data-kind="approval"] .acpmux-needs-you-kind, +.acpmux-needs-you-item[data-kind="decision"] .acpmux-needs-you-kind, +.acpmux-needs-you-item[data-kind="verify"] .acpmux-needs-you-kind { + background: color-mix(in srgb, var(--acpmux-needs-you-mark) 14%, transparent); + color: var(--acpmux-needs-you-mark); +} +.acpmux-needs-you-age { + flex: none; + font-variant-numeric: tabular-nums; +} +.acpmux-needs-you-go { + flex: none; + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 5px; + background: none; + color: var(--agent-muted); + cursor: default; +} +.acpmux-needs-you-go:hover { + background: var(--acpmux-menu-hover, color-mix(in srgb, var(--agent-text) 10%, transparent)); + color: var(--agent-text); +} +.acpmux-needs-you-go:focus-visible, +.acpmux-needs-you-answer:focus-visible, +.acpmux-needs-you-ask-about:focus-visible, +.acpmux-needs-you-side-send:focus-visible { + outline: 1.5px solid var(--agent-text); + outline-offset: 1px; +} +.acpmux-needs-you-ask { + margin: 6px 0 0 24px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + overflow-wrap: anywhere; + color: var(--agent-text); +} +.acpmux-needs-you-panel[data-mode="compact"] .acpmux-needs-you-ask { + -webkit-line-clamp: 1; +} +.acpmux-needs-you-detail { + margin: 2px 0 0 24px; + overflow: hidden; + color: var(--agent-soft, var(--agent-muted)); + font-size: var(--text-caption); + line-height: 16px; + white-space: nowrap; + text-overflow: ellipsis; +} +/* A command: as the ask's card shows one. */ +.acpmux-needs-you-detail[data-code] { + margin-top: 4px; + padding: 4px 7px; + border-radius: 5px; + background: color-mix(in srgb, var(--agent-text) 6%, transparent); + font-family: var(--font-mono, ui-monospace, monospace); +} +.acpmux-needs-you-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 8px 0 0 24px; +} +.acpmux-needs-you-answer, +.acpmux-needs-you-ask-about, +.acpmux-needs-you-side-send { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + max-width: 100%; + padding: 0 10px; + overflow: hidden; + border: 0; + border-radius: 12px; + background: var(--acpmux-pill-fill, color-mix(in srgb, var(--agent-text) 6%, transparent)); + box-shadow: inset 0 0 0 var(--cmux-hairline, 0.5px) var(--acpmux-pill-edge, color-mix(in srgb, var(--agent-text) 24%, transparent)); + color: var(--agent-text); + font: inherit; + font-size: var(--text-detail); + white-space: nowrap; + text-overflow: ellipsis; + cursor: default; +} +.acpmux-needs-you-answer:hover:not(:disabled), +.acpmux-needs-you-ask-about:hover, +.acpmux-needs-you-side-send:hover:not(:disabled) { + background: var(--acpmux-pill-hover, color-mix(in srgb, var(--agent-text) 11%, transparent)); +} +.acpmux-needs-you-answer[data-tone="allow"] { + background: var(--agent-text); + box-shadow: none; + color: var(--acpmux-send-ink, var(--agent-page-bg)); + font-weight: var(--weight-emphasis, 600); +} +.acpmux-needs-you-answer[data-tone="allow"]:hover:not(:disabled) { + background: color-mix(in srgb, var(--agent-text) 86%, var(--agent-page-bg)); +} +.acpmux-needs-you-answer:disabled, +.acpmux-needs-you-side-send:disabled { + opacity: 0.55; +} +.acpmux-needs-you-answer[aria-busy="true"] { + opacity: 0.8; +} +.acpmux-needs-you-ask-about { + margin-left: auto; + background: none; + box-shadow: none; + color: var(--agent-muted); + padding: 0 6px; +} +.acpmux-needs-you-ask-about[aria-expanded="true"] { + color: var(--agent-text); +} +.acpmux-needs-you-error { + margin: 6px 0 0 24px; + color: var(--agent-danger, var(--agent-text)); + font-size: var(--text-detail); +} +.acpmux-needs-you-side { + margin: 8px 0 0 24px; + padding: 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--agent-text) 5%, transparent); +} +.acpmux-needs-you-side-turn + .acpmux-needs-you-side-turn, +.acpmux-needs-you-side-turn + .acpmux-needs-you-side-form { + margin-top: 8px; +} +.acpmux-needs-you-side-q { + margin: 0; + color: var(--agent-muted); + font-size: var(--text-detail); +} +.acpmux-needs-you-side-a { + margin-top: 4px; + font-size: var(--text-detail); + line-height: 17px; +} +.acpmux-needs-you-side-status { + margin: 4px 0 0; + color: var(--agent-muted); + font-size: var(--text-detail); +} +.acpmux-needs-you-side-form { + display: flex; + gap: 6px; +} +.acpmux-needs-you-side-field { + flex: 1; + min-width: 0; + height: 26px; + box-sizing: border-box; + padding: 0 9px; + border: 0; + border-radius: 13px; + outline: none; + background: var(--acpmux-composer-bg, color-mix(in srgb, var(--agent-text) 8%, transparent)); + box-shadow: inset 0 0 0 var(--cmux-hairline, 0.5px) var(--acpmux-composer-edge, var(--agent-border)); + color: var(--agent-text); + caret-color: var(--cmux-caret, var(--agent-text)); + font: inherit; + font-size: var(--text-detail); +} +.acpmux-needs-you-side-field:focus { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--agent-text) 40%, transparent); +} + +/* The Needs your input pill: Jump to latest's glass, widened to a capsule in the composer's look, + a little larger than Claude's (Leo 2026-10-20). */ +.acpmux-jump-latest.acpmux-needs-you-pill { + display: inline-flex; + gap: 8px; + width: auto; + height: 34px; + padding: 0 12px 0 13px; + border-radius: 17px; + background-color: color-mix(in srgb, var(--acpmux-composer-bg, var(--agent-page-bg)) 82%, transparent); + box-shadow: + inset 0 0 0 var(--cmux-hairline, 0.5px) var(--acpmux-composer-edge, color-mix(in srgb, var(--agent-text) 18%, transparent)), + var(--elevation-2); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + font-size: var(--text-control); + font-weight: var(--weight-emphasis, 600); + white-space: nowrap; +} +.acpmux-jump-latest.acpmux-needs-you-pill:hover { + background-color: var(--acpmux-composer-hover, color-mix(in srgb, var(--agent-page-bg) 60%, var(--agent-text) 6%)); +} +.acpmux-needs-you-pill__chevron { + flex: none; + color: var(--agent-muted); +} + +/* A reply's `cmux://needs-you/` link (conversation/Markdown.tsx): the dot, then its text. */ +.cv-link.is-needs-you { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + background: none; + font: inherit; + cursor: default; +} +.cv-link.is-needs-you .acpmux-needs-you-dot { + width: 6px; + height: 6px; + box-shadow: none; +} diff --git a/webviews/src/agent-session/acpmux/needsYou/useNeedsYou.ts b/webviews/src/agent-session/acpmux/needsYou/useNeedsYou.ts new file mode 100644 index 000000000000..d9b9ae027b8e --- /dev/null +++ b/webviews/src/agent-session/acpmux/needsYou/useNeedsYou.ts @@ -0,0 +1,86 @@ +// The pane's Needs you wiring (App.tsx): the list from the snapshot, the header badge's actions, +// and the transcript's Needs your input pill, which share one list and one way to answer. +import { useMemo, useRef } from "react"; +import { revealTurnWhenShown } from "../links"; +import type { AcpmuxSnapshot } from "../model"; +import { reply, replyParams, selection } from "../question/model"; +import { nextAsk, needsYouPillOn, type TranscriptNeedsYou } from "./JumpPill"; +import { needsYouItems, type NeedsYouAnswer, type NeedsYouItem } from "./model"; +import { needsYouMode, type NeedsYouActions, type NeedsYouMode } from "./NeedsYou"; + +type CallNative = (method: string, params?: Record) => Promise; + +/// What an answer sends: a held pair's Resume, a question's choice in its harness's shape, or a +/// permission's option. Every answer goes through the page's own `chat.permission` action, the +/// path the ask's card uses (its gesture ticket included), with the ask's chat named. +export function answerParams( + item: NeedsYouItem, + choice: NeedsYouAnswer, +): { method: string; params: Record } | undefined { + if (item.kind === "held") return item.peer ? { method: "chat.agentPair.resume", params: { peer: item.peer } } : undefined; + if (!item.permissionId) return undefined; + if (item.question) { + const first = item.question.items[0]; + if (!first) return undefined; + const sent = reply(item.question, { selections: { [first.id]: selection([choice.id]) } }); + return { method: "chat.permission", params: replyParams(sent) }; + } + return { + method: "chat.permission", + params: { sessionId: item.sessionId, permissionId: item.permissionId, optionId: choice.id }, + }; +} + +export function useNeedsYou({ + snapshot, + callNative, + ask, +}: { + snapshot: Pick; + callNative: CallNative; + ask: (question: string, sessionId: string) => Promise; +}): { mode: NeedsYouMode; items: NeedsYouItem[]; actions: NeedsYouActions; pill?: TranscriptNeedsYou } { + const mode = needsYouMode(); + const pillOn = needsYouPillOn(); + const { sessions, asks, sessionId, rows } = snapshot; + const items = useMemo( + () => + mode === "off" && !pillOn + ? [] + : needsYouItems({ sessions, asks: asks ?? [], open: { ...(sessionId ? { sessionId } : {}), rows } }), + [mode, pillOn, sessions, asks, sessionId, rows], + ); + const goTo = (item: NeedsYouItem) => { + const reveal = () => { + if (item.turnId) void revealTurnWhenShown(item.turnId); + else document.querySelector(item.kind === "held" ? ".cv-agent-pair" : ".acpmux-permission")?.scrollIntoView({ block: "nearest" }); + }; + if (item.sessionId === sessionId) return reveal(); + void callNative("chat.select", { sessionId: item.sessionId }).then(reveal, () => undefined); + }; + const actions: NeedsYouActions = { + answer: (item, choice) => { + const call = answerParams(item, choice); + return call ? callNative(call.method, call.params) : Promise.reject(new Error("unanswerable")); + }, + goTo, + ask: (item, question) => ask(question, item.sessionId), + }; + // The last ask the pill went to, so the next press goes to the one after it. + const last = useRef(undefined); + const elsewhere = items.filter((item) => item.sessionId !== sessionId).length; + const pill: TranscriptNeedsYou | undefined = + pillOn && items.length > 0 + ? { + count: items.length, + elsewhere, + next: (behind) => { + const item = nextAsk(items, sessionId, behind, last.current); + if (!item) return; + last.current = item.id; + goTo(item); + }, + } + : undefined; + return { mode, items, actions, ...(pill ? { pill } : {}) }; +} diff --git a/webviews/src/gallery/format.ts b/webviews/src/gallery/format.ts index 898ec3032261..8992d0d14ff8 100644 --- a/webviews/src/gallery/format.ts +++ b/webviews/src/gallery/format.ts @@ -159,6 +159,9 @@ export type AgentPaneVariant = VariantBase & { /** The in-session status toggle (Debug Settings `agent.sessionStatus`, off by default) on, as the * app's `sessionStatus` push sends it: the live plan card and the recap card. */ sessionStatus?: boolean; + /** `` attributes the app sets at document start for Debug Settings choices + * (AgentPaneTunables `documentAttributes`), by name without `data-`: `{ "needs-you": "off" }`. */ + documentAttributes?: Record; }; /** The markdown editor page (src/pages/markdown) on an in-page cmuxPage host. */ diff --git a/webviews/src/gallery/frame/agentPane.ts b/webviews/src/gallery/frame/agentPane.ts index 55925add596b..94ed27543a1c 100644 --- a/webviews/src/gallery/frame/agentPane.ts +++ b/webviews/src/gallery/frame/agentPane.ts @@ -80,6 +80,9 @@ export async function mountAgentPane(state: AgentPaneVariant, context: StageCont }, }; document.documentElement.dataset.cmuxWebviewKind = "acpmux-agent-session"; + // Before the pane's scripts run, as AgentPaneTunables.documentScript sets them in the app. + for (const [name, value] of Object.entries(state.documentAttributes ?? {})) + document.documentElement.setAttribute(`data-${name}`, value); await import("virtual:cmux-gallery/agent-pane.css"); await import("katex/dist/katex.min.css"); await import("../../agent-session/acpmux/main"); diff --git a/webviews/test/agent-pane-needs-you-asks.test.ts b/webviews/test/agent-pane-needs-you-asks.test.ts new file mode 100644 index 000000000000..b17e1f6395a3 --- /dev/null +++ b/webviews/test/agent-pane-needs-you-asks.test.ts @@ -0,0 +1,105 @@ +// Needs you's asks (needsYou/asks.ts) are acpmux's pending permission requests in every watched +// chat. An ask answered anywhere (its card, the Feed, `acpmux session allow`, another pane) must +// leave the list: a count of zero clears the chat at once, a count the page cannot account for or +// an answer rereads `_acpmux/info`, and a read that predates a newer ask or a newer read is dropped, +// so a slow read never brings back an answered ask or drops a new one. +import { expect, test } from "bun:test"; +import type { AcpmuxPermission } from "../src/agent-session/acpmux/model"; +import { AskTracker, type PendingEntry } from "../src/agent-session/acpmux/needsYou/asks"; + +const parse = (message: unknown, sessionId: string): AcpmuxPermission | undefined => { + const id = (message as { permissionId?: unknown }).permissionId; + return typeof id === "string" ? { permissionId: id, pending: true, options: [], title: `${sessionId}:${id}` } : undefined; +}; + +type Read = { sessionId: string; resolve(entries: readonly PendingEntry[] | undefined): void }; + +function tracker(now = () => 1000) { + const reads: Read[] = []; + let changes = 0; + const asks = new AskTracker( + (sessionId) => new Promise((resolve) => reads.push({ sessionId, resolve })), + parse, + () => void changes++, + now, + ); + return { asks, reads, changes: () => changes }; +} +const ids = (asks: AskTracker) => asks.list().map((ask) => ask.permission.permissionId); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test("a pending notification adds an ask once, and the list keeps its identity until a change", () => { + const { asks, changes } = tracker(); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + expect(ids(asks)).toEqual(["p1"]); + expect(changes()).toBe(1); + expect(asks.list()).toBe(asks.list()); + expect(asks.list()[0]!.at).toBe(1000); +}); + +test("a count of zero clears the chat's asks with no read", () => { + const { asks, reads } = tracker(); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + asks.pending({ sessionId: "s2", permissionId: "p2" }); + asks.session({ sessionId: "s1", pendingPermissions: 0 }); + expect(ids(asks)).toEqual(["p2"]); + expect(reads).toHaveLength(0); +}); + +test("an answer elsewhere rereads the chat, and the read's list wins", async () => { + const { asks, reads } = tracker(); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + // The count still reads 1 (a new ask took the answered one's place), but the change is an answer. + asks.session({ sessionId: "s1", pendingPermissions: 1 }, "permission_decision"); + expect(reads).toHaveLength(1); + reads[0]!.resolve([{ permissionId: "p3", request: {}, turnId: "t3" }]); + await settle(); + expect(ids(asks)).toEqual(["p3"]); + expect(asks.list()[0]!.permission.turnId).toBe("t3"); +}); + +test("a count that matches what the page holds needs no read", () => { + const { asks, reads } = tracker(); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + asks.session({ sessionId: "s1", pendingPermissions: 1 }, "status"); + expect(reads).toHaveLength(0); +}); + +test("asks found by a read (opened before the page saw them) are dated by the chat's last change", async () => { + const { asks, reads } = tracker(() => 5000); + asks.sessions([{ sessionId: "s1", pendingPermissions: 2, updatedAt: 1200 }]); + reads[0]!.resolve([{ permissionId: "a" }, { permissionId: "b" }]); + await settle(); + expect(ids(asks)).toEqual(["a", "b"]); + expect(asks.list().map((ask) => ask.at)).toEqual([1200, 1200]); +}); + +test("a read that predates a newer ask or a newer read is dropped", async () => { + const { asks, reads } = tracker(); + asks.session({ sessionId: "s1", pendingPermissions: 1 }); + // The ask arrives while the first read is out: that read may not include it. + asks.pending({ sessionId: "s1", permissionId: "new" }); + reads[0]!.resolve([]); + await settle(); + expect(ids(asks)).toEqual(["new"]); + // Two reads out at once: only the newer counts, however they land. + asks.session({ sessionId: "s1", pendingPermissions: 2 }); + asks.session({ sessionId: "s1", pendingPermissions: 2 }, "permission_resolved"); + reads[2]!.resolve([{ permissionId: "new" }, { permissionId: "late" }]); + await settle(); + reads[1]!.resolve([]); + await settle(); + expect(ids(asks)).toEqual(["new", "late"]); +}); + +test("a read that fails, or a chat that cannot be read, keeps what the page holds; a purge drops it", async () => { + const { asks, reads } = tracker(); + asks.pending({ sessionId: "s1", permissionId: "p1" }); + asks.session({ sessionId: "s1", pendingPermissions: 2 }); + reads[0]!.resolve(undefined); + await settle(); + expect(ids(asks)).toEqual(["p1"]); + asks.forget("s1"); + expect(ids(asks)).toEqual([]); +}); diff --git a/webviews/test/agent-pane-needs-you.test.tsx b/webviews/test/agent-pane-needs-you.test.tsx new file mode 100644 index 000000000000..f63de449c2cf --- /dev/null +++ b/webviews/test/agent-pane-needs-you.test.tsx @@ -0,0 +1,332 @@ +// l10n-allow-file: test fixture asks, chat names and commands, not shipped UI. +// Needs you (Leo 2026-10-20): one list of every open ask from every agent chat and the Chief, so no +// agent has to restate "still waiting on you". These tests hold the list to what acpmux reports: +// each kind of ask reads as its kind with its one-click answers, a chat waiting with no readable ask +// is never missing, held agent pair messages offer Resume, an answer goes out the way the ask's card +// sends it (to the ask's own chat), links name an ask, the Needs your input pill visits this chat's +// ask first and cycles the others, and the panel draws, answers and hides what was answered. +import { afterEach, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { JSDOM } from "jsdom"; +import { translate } from "../src/agent-session/acpmux/i18n"; +import type { AcpmuxPermission, AcpmuxRow } from "../src/agent-session/acpmux/model"; +import type { AgentQuestion } from "../src/agent-session/acpmux/question/model"; +import type { AcpmuxSessionEntry } from "../src/agent-session/acpmux/sessionList"; +import { needsYouItems, type NeedsYouItem, type WatchedAsk } from "../src/agent-session/acpmux/needsYou/model"; +import { needsYouLink, needsYouTarget, onOpenNeedsYou, openNeedsYou } from "../src/agent-session/acpmux/needsYou/links"; +import { nextAsk, NeedsYouPill } from "../src/agent-session/acpmux/needsYou/JumpPill"; +import { answerParams } from "../src/agent-session/acpmux/needsYou/useNeedsYou"; +import { NeedsYouPanel, needsYouMode, sideQuestionPrompt, type NeedsYouActions } from "../src/agent-session/acpmux/needsYou/NeedsYou"; + +const options: AcpmuxPermission["options"] = [ + { id: "allow", name: "Allow", kind: "allow_once", allow: true }, + { id: "always", name: "Always allow", kind: "allow_always", allow: true }, + { id: "deny", name: "Deny", kind: "reject_once", allow: false }, +]; +const permission = (fields: Partial): AcpmuxPermission => ({ + permissionId: "p1", + pending: true, + options, + ...fields, +}); +const question = (fields: Partial = {}): AgentQuestion => ({ + id: "q-perm", + source: { harness: "claude", session: "s-q", permission: "q-perm", answerOption: "allow_once" }, + items: [ + { + id: "q0", + header: "Auth", + prompt: "Which login should the app use?", + options: [ + { id: "OAuth", label: "OAuth" }, + { id: "Passkeys", label: "Passkeys" }, + ], + multiSelect: false, + allowsOther: true, + }, + ], + state: { kind: "pending" }, + ...fields, +}); +const session = (sessionId: string, fields: Partial = {}): AcpmuxSessionEntry => ({ + sessionId, + name: sessionId, + harness: "claude", + ...fields, +}); +const ask = (sessionId: string, p: AcpmuxPermission, at: number): WatchedAsk => ({ sessionId, permission: p, at }); + +test("an approval reads its title, offers allow once and deny once, and keys on the Feed's id", () => { + const items = needsYouItems({ + sessions: [session("s1", { displayTitle: "Fix login" })], + asks: [ask("s1", permission({ title: "Run npm test", command: "npm test", turnId: "t9" }), 10)], + }); + expect(items).toHaveLength(1); + const [item] = items as [NeedsYouItem]; + expect(item.id).toBe("acpmux:p1"); + expect(item.kind).toBe("approval"); + expect(item.ask).toBe("Run npm test"); + expect(item.detail).toBe("npm test"); + expect(item.command).toBe(true); + expect(item.who.name).toBe("Fix login"); + expect(item.turnId).toBe("t9"); + // Always allow is the card's to give, where its scope is spelled out. + expect(item.answers.map((answer) => answer.id)).toEqual(["allow", "deny"]); +}); + +test("a question is a decision with its choices; a Chief question is the Chief's; verify asks read as verify", () => { + const items = needsYouItems({ + sessions: [session("s-q"), session("s-v"), session("s-c", { harness: "chief", name: "Chief" })], + asks: [ + ask("s-q", permission({ permissionId: "q-perm", question: question() }), 3), + ask("s-v", permission({ permissionId: "v-perm", title: "Verify the sidebar dot in dark mode" }), 2), + ask("s-c", permission({ permissionId: "c-perm", title: "Merge #22410?" }), 1), + ], + }); + expect(items.map((item) => item.kind)).toEqual(["decision", "verify", "approval"]); + expect(items[0]!.ask).toBe("Which login should the app use?"); + expect(items[0]!.answers.map((answer) => answer.label)).toEqual(["OAuth", "Passkeys"]); + expect(items[2]!.who.chief).toBe(true); + expect(items[0]!.who.chief).toBe(false); +}); + +test("a question with several items or many choices gets no one-click answers", () => { + const big = question(); + big.items[0]!.options = ["a", "b", "c", "d", "e"].map((id) => ({ id, label: id })); + const multi = question(); + multi.items[0]!.multiSelect = true; + const items = needsYouItems({ + sessions: [session("s1")], + asks: [ + ask("s1", permission({ permissionId: "big", question: big }), 2), + ask("s1", permission({ permissionId: "multi", question: multi }), 1), + ], + }); + expect(items.map((item) => item.answers.length)).toEqual([0, 0]); +}); + +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("counted", { pendingPermissions: 2 }), + session("asked", { pendingPermissions: 1 }), + session("gone", { indicator: "input", archived: true }), + session("idle"), + ], + asks: [ask("asked", permission({}), 9), ask("gone", permission({ permissionId: "p-gone" }), 8)], + }); + 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"); +}); + +test("the open chat's held agent pair messages are one item with Resume; a resumed pair is not", () => { + const pair = (peer: string, resumed: boolean) => + ({ + kind: "agentPair", + agentPair: { + peer, + peerName: `Agent ${peer}`, + reason: "alternating", + count: 10, + held: [{ promptId: `${peer}-1`, text: "LGTM?" }], + released: 0, + dropped: 0, + ...(resumed ? { resumed: true } : {}), + }, + }) as unknown as AcpmuxRow; + const items = needsYouItems({ + sessions: [session("s1")], + asks: [], + open: { sessionId: "s1", rows: [pair("a", false), pair("b", false), pair("b", true)] }, + }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ id: "held:s1:a", kind: "held", peer: "a", held: 1, ask: "Agent a" }); + expect(answerParams(items[0]!, items[0]!.answers[0]!)).toEqual({ method: "chat.agentPair.resume", params: { peer: "a" } }); +}); + +test("a subagent's ask names the chat that started it", () => { + const [item] = needsYouItems({ + sessions: [session("parent", { displayTitle: "Chief plan" }), session("child", { spawnedBy: "parent" })], + asks: [ask("child", permission({}), 1)], + }); + expect(item!.who.via).toBe("Chief plan"); +}); + +test("an answer goes to the ask's own chat: a permission by option, a question in its harness's shape", () => { + const [approval, decision] = [ + needsYouItems({ sessions: [session("s1")], asks: [ask("s1", permission({}), 1)] })[0]!, + needsYouItems({ sessions: [session("s-q")], asks: [ask("s-q", permission({ permissionId: "q-perm", question: question() }), 1)] })[0]!, + ]; + expect(answerParams(approval, approval.answers[1]!)).toEqual({ + method: "chat.permission", + params: { sessionId: "s1", permissionId: "p1", optionId: "deny" }, + }); + expect(answerParams(decision, decision.answers[1]!)).toEqual({ + method: "chat.permission", + params: { + sessionId: "s-q", + permissionId: "q-perm", + optionId: "allow_once", + answers: { "Which login should the app use?": "Passkeys" }, + }, + }); + // A waiting chat with no ask read is answered at its source. + const waiting = needsYouItems({ sessions: [session("r", { indicator: "input" })], asks: [] })[0]!; + expect(answerParams(waiting, { id: "x", label: "x", tone: "choice" })).toBeUndefined(); +}); + +test("a needs-you link names its ask in any build's scheme and nothing else", () => { + expect(needsYouTarget("cmux://needs-you/0f4e-11")).toBe("acpmux:0f4e-11"); + expect(needsYouTarget("cmux-dev-tag://needs-you/abc")).toBe("acpmux:abc"); + expect(needsYouTarget("https://needs-you/abc")).toBeUndefined(); + expect(needsYouTarget("cmux://needs-you/a/b")).toBeUndefined(); + expect(needsYouTarget("cmux://needs-you/a%20b")).toBeUndefined(); + expect(needsYouLink("abc")).toBe("cmux://needs-you/abc"); + expect(needsYouLink("../x/y")).toBeUndefined(); + const opened: (string | undefined)[] = []; + expect(openNeedsYou("acpmux:abc")).toBe(false); + const off = onOpenNeedsYou((id) => opened.push(id)); + expect(openNeedsYou("acpmux:abc")).toBe(true); + off(); + expect(opened).toEqual(["acpmux:abc"]); +}); + +test("the pill goes to this chat's ask first, then cycles the asks open elsewhere", () => { + const item = (id: string, sessionId: string) => + ({ id, sessionId, kind: "approval", ask: id, who: { name: sessionId, chief: false }, answers: [] }) as NeedsYouItem; + const items = [item("x1", "other"), item("h1", "here"), item("y1", "third")]; + // Behind: the press jumped to the latest message, where this chat's ask card waits. + expect(nextAsk(items, "here", true, undefined)).toBeUndefined(); + // At the latest message already: on to the others, in turn, and around again. + expect(nextAsk(items, "here", false, undefined)?.id).toBe("x1"); + expect(nextAsk(items, "here", false, "x1")?.id).toBe("y1"); + expect(nextAsk(items, "here", false, "y1")?.id).toBe("x1"); + // No ask here: even a press from behind moves on to the others. + expect(nextAsk([item("x1", "other")], "here", true, undefined)?.id).toBe("x1"); + expect(nextAsk([item("h1", "here")], "here", false, undefined)).toBeUndefined(); +}); + +test("the pill reads Needs your input, and how many when several are open", () => { + const one = renderToStaticMarkup( undefined} />); + expect(one).toContain(translate("needsYou.pill")); + expect(one).toContain("acpmux-needs-you-dot"); + expect(one).toContain("acpmux-jump-latest"); + const three = renderToStaticMarkup( undefined} />); + expect(three).toContain(translate("needsYou.pillCount", { n: 3 })); +}); + +test("the Debug Settings attributes pick the list's mode", () => { + const root = (value?: string) => ({ dataset: value ? { needsYou: value } : {} }) as { dataset: DOMStringMap }; + expect(needsYouMode(root())).toBe("pinned"); + expect(needsYouMode(root("compact"))).toBe("compact"); + expect(needsYouMode(root("off"))).toBe("off"); + expect(needsYouMode(root("bogus"))).toBe("pinned"); +}); + +test("the side question carries the ask, its choices and earlier answers", () => { + const [item] = needsYouItems({ + sessions: [session("s-q")], + asks: [ask("s-q", permission({ permissionId: "q-perm", question: question() }), 1)], + }); + const prompt = sideQuestionPrompt(item!, "Which is safer?", [{ question: "What is this?", answer: "A login choice." }]); + expect(prompt).toContain("Which login should the app use?"); + expect(prompt).toContain("Choices: OAuth / Passkeys"); + expect(prompt).toContain("You said: A login choice."); + expect(prompt).toContain("My question: Which is safer?"); +}); + +// ---------- The panel ---------- + +let dom: JSDOM | undefined; +let root: Root | undefined; +const scope = globalThis as Record; +const saved = new Map(); + +function installDom(): HTMLElement { + dom = new JSDOM("
    ", { url: "http://localhost/" }); + for (const key of ["window", "document", "navigator", "Node", "HTMLElement", "Element", "IS_REACT_ACT_ENVIRONMENT"]) + saved.set(key, scope[key]); + Object.assign(scope, { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + Node: dom.window.Node, + HTMLElement: dom.window.HTMLElement, + Element: dom.window.Element, + IS_REACT_ACT_ENVIRONMENT: true, + }); + dom.window.HTMLElement.prototype.scrollIntoView = () => undefined; + return dom.window.document.getElementById("root")!; +} + +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = undefined; + dom?.window.close(); + dom = undefined; + for (const [key, value] of saved) { + if (value === undefined) delete scope[key]; + else scope[key] = value; + } + saved.clear(); +}); + +const click = (element: Element) => element.dispatchEvent(new dom!.window.MouseEvent("click", { bubbles: true })); +const buttonNamed = (host: HTMLElement, name: string) => + [...host.querySelectorAll("button")].find((button) => button.textContent === name); + +test("the panel lists each ask with who asked, answers it, and hides it once sent", async () => { + const host = installDom(); + const items = needsYouItems({ + sessions: [session("s1", { displayTitle: "Fix login" }), session("c", { harness: "chief", name: "Chief" })], + asks: [ask("s1", permission({ title: "Run npm test" }), Date.now()), ask("c", permission({ permissionId: "p2", title: "Merge?" }), 1)], + }); + const answered: string[] = []; + const went: string[] = []; + const actions: NeedsYouActions = { + answer: async (item, choice) => void answered.push(`${item.id}:${choice.id}`), + goTo: (item) => void went.push(item.id), + ask: async () => "", + }; + root = createRoot(host); + await act(async () => root!.render()); + expect(host.querySelectorAll(".acpmux-needs-you-item")).toHaveLength(2); + expect(host.textContent).toContain("Fix login"); + expect(host.textContent).toContain(translate("needsYou.chief")); + expect(host.querySelector(".acpmux-needs-you-total")!.textContent).toBe("2"); + await act(async () => click(host.querySelector(".acpmux-needs-you-go")!)); + expect(went).toEqual(["acpmux:p1"]); + await act(async () => click(buttonNamed(host, "Allow")!)); + expect(answered).toEqual(["acpmux:p1:allow"]); + expect(host.querySelectorAll(".acpmux-needs-you-item")).toHaveLength(1); +}); + +test("a refused answer stays in the list and says so; the empty list says nothing waits", async () => { + const host = installDom(); + const items = needsYouItems({ sessions: [session("s1")], asks: [ask("s1", permission({ title: "Run it" }), 1)] }); + const actions: NeedsYouActions = { + answer: async () => Promise.reject(new Error("gone")), + goTo: () => undefined, + ask: async () => "", + }; + root = createRoot(host); + await act(async () => root!.render()); + await act(async () => click(buttonNamed(host, "Deny")!)); + expect(host.querySelectorAll(".acpmux-needs-you-item")).toHaveLength(1); + expect(host.textContent).toContain(translate("needsYou.answerFailed")); + await act(async () => root!.render()); + expect(host.textContent).toContain(translate("needsYou.empty")); +}); + +test("compact mode lists asks without answers", () => { + const items = needsYouItems({ sessions: [session("s1")], asks: [ask("s1", permission({ title: "Run it" }), 1)] }); + const html = renderToStaticMarkup( + undefined, goTo: () => undefined, ask: async () => "" }} />, + ); + expect(html).toContain("Run it"); + expect(html).not.toContain("acpmux-needs-you-answer"); +}); From 84fdd42c61147e5db3f9cfe4899b061d8b7c524e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 15:43:45 -0700 Subject: [PATCH 02/23] Needs you: format, layer token, autofocus lint note Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=e8f63fd9-94e8-4878-bcd4-704659aba1f2&host=cosmic-gray --- .../acpmux/conversation/Markdown.tsx | 3 +- webviews/src/agent-session/acpmux/direct.ts | 3 +- .../acpmux/needsYou/JumpPill.tsx | 13 ++++++-- .../acpmux/needsYou/NeedsYou.gallery.ts | 20 ++++++++---- .../acpmux/needsYou/NeedsYou.tsx | 25 ++++++++++++--- .../agent-session/acpmux/needsYou/model.ts | 4 ++- .../acpmux/needsYou/needsYou.css | 2 +- .../acpmux/needsYou/useNeedsYou.ts | 8 +++-- .../test/agent-pane-needs-you-asks.test.ts | 4 ++- webviews/test/agent-pane-needs-you.test.tsx | 32 +++++++++++++++---- 10 files changed, 88 insertions(+), 26 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/Markdown.tsx b/webviews/src/agent-session/acpmux/conversation/Markdown.tsx index 8a2c1cab115d..bfcf5b6886f2 100644 --- a/webviews/src/agent-session/acpmux/conversation/Markdown.tsx +++ b/webviews/src/agent-session/acpmux/conversation/Markdown.tsx @@ -465,7 +465,8 @@ export function renderInline(source: string, outer: InlineOptions = {}): ReactNo {renderInline(lm[1], labelOpts)} , ); - else if (named) out.push(); + else if (named) + out.push(); else if (path) out.push(); else if (app) // A cmux app link: a plain anchor, so the click takes the host's link path (opened in the app). diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index bf92d516796a..f23ab09b8b3c 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1196,8 +1196,7 @@ export class AcpmuxDirectClient { else if (isNotification(notification, "_acpmux/permission_pending")) { this.asks.pending(notification.params); this.applyPermission(notification.params); - } - else if (notification.method === "_acpmux/lagged") this.resyncAfterLag(notification.params); + } else if (notification.method === "_acpmux/lagged") this.resyncAfterLag(notification.params); // A profile change may pin or free a harness's mode, which changes a new chat's start mode too. else if (notification.method === "_acpmux/harnesses_changed") { this.onHarnessesChanged?.(); diff --git a/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx b/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx index 93da16326d39..765c1d461114 100644 --- a/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx +++ b/webviews/src/agent-session/acpmux/needsYou/JumpPill.tsx @@ -17,7 +17,9 @@ export type TranscriptNeedsYou = { next(behind: boolean): void; }; -export function needsYouPillOn(root: { dataset: DOMStringMap } | undefined = globalThis.document?.documentElement): boolean { +export function needsYouPillOn( + root: { dataset: DOMStringMap } | undefined = globalThis.document?.documentElement, +): boolean { return root?.dataset.needsYouPill !== "off"; } @@ -69,7 +71,14 @@ export function NeedsYouPill({