Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps {
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "nowPlaying.command", "importCards.act", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending",
"shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart",
"git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture",
"dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "dictation.audio",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/// `chat.asks.open {sessionId}`: the person went to another chat's ask from Needs you (its list, a
/// row's answer or side question, the Needs your input pill). The tab that shows that chat already
/// is shown and focused; else the chat opens in a new tab after this one, focused. The chat on
/// screen is never replaced: Needs you used to switch this pane to the other chat in place
/// (`chat.select`), and the person's own chat left its tab (Leo, 2026-10-21, his coordinator).
///
/// Allowed for a chat on this pane's own acpmux on this Mac (``AgentPaneModel/pendingAsksSocket``,
/// whose chats Needs you lists, and a bare or `local/` id, ``AgentPanePendingAsks/localSession(_:)``):
/// the page's own client can already show any of them in place, so a tab of one grants nothing
/// more. Never the chat itself, a chat on another machine, or from a New Tab page; a refusal opens
/// nothing, and the page leaves the chat on screen as it is. Kept off ``AgentPaneModel``, whose
/// type is at its size limit.
enum AgentPaneAskOpen {
@MainActor static func respond(_ session: String, in model: AgentPaneModel) -> [String: Any] {
guard let chat = model.sessionId, model.newTab == nil, let header = model.header,
model.pendingAsksSocket != nil, let local = AgentPanePendingAsks.localSession(session),
local != AgentPanePendingAsks.localSession(chat) else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.asks.open")
}
let bare = String(local.dropFirst(AgentPanePendingAsks.localHost.count))
guard header.showSession(bare) || header.openTab(bare, .tab) else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.asks.open")
}
return AgentPaneReply.success()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ extension AgentPaneModel {
return AgentPaneReply.success()
case .offerOpen(let session):
return AgentPaneOfferOpen.respond(session, in: self)
case .asksOpen(let session):
return AgentPaneAskOpen.respond(session, in: self)
case .newChatHere(let cwd):
guard sessionId != nil, newTab == nil, let header, header.newChat(cwd) else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.newHere")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ public final class AgentPaneModel {
case .watchChat(let key): if let onWatchChat { onWatchChat(key); return AgentPaneReply.success() } else { return Self.unsupported("chats.watch") }
case .chatsPage(let query): return await respondToChatsPage(query)
case .environment(let request): return await request.reply(store: startFolders.environments, newTabPage: newTab != nil && sessionId == nil)
case .paneAction, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .newChatHere: return await respondToHeader(request)
case .paneAction, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .newChatHere: return await respondToHeader(request)
case .appAction(let id):
guard newTab?.omnibar.actions.contains(where: { $0.id == id }) == true, let onAppAction else { return Self.unsupported("app.action") }
onAppAction(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ extension AgentPaneRequest {
if let cwd = (params?["cwd"] as? String).flatMap(Self.headerFolder) { self = .newChatHere(cwd) } else { self = .unsupported(method) }
case "chat.openTab":
if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .chatTab(id, AgentForkDestination(placement: params?["placement"] as? String)) } else { self = .unsupported(method) }
case "chat.asks.open":
if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .asksOpen(id) } else { self = .unsupported(method) }
case "chat.offer.open":
if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .offerOpen(id) } else { self = .unsupported(method) }
case "chat.upload":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .sideChat: "sideChat"
case .chatTab: "chatTab"
case .offerOpen: "offerOpen"
case .asksOpen: "asksOpen"
case .subagentPane: "subagentPane"
case .processTerminal: "processTerminal"
case .newChatHere: "newChatHere"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// agent started, the session that started it): its tab, else one beside this chat, focused
/// (``AgentPaneOfferOpen``).
case offerOpen(String)
/// `chat.asks.open {sessionId}`: Needs you went to another chat's ask: that chat's tab, else a
/// new tab beside this chat; never this chat replaced in place (``AgentPaneAskOpen``).
case asksOpen(String)
/// `chat.subagentPane {subagent, focus}`: this chat's subagent in an agent pane of its own,
/// right of the chat (``AgentSubagentPanes``); `focus` moves keyboard focus to it.
case subagentPane(String, focus: Bool)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Testing
@testable import CmuxNextAgentPane

/// Needs you's jump to another chat's ask (`chat.asks.open`) shows that chat's tab or opens one; it
/// never replaces the chat on screen. Leo hit the old path live (2026-10-21): the pane switched his
/// coordinator's chat to the waiting chat in place, and the sidebar still named the coordinator.
@MainActor
struct AgentPaneAskOpenTests {
private actor NoHost: AgentPaneHostProviding {
func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped }
}

/// What the host did: the sessions it showed in their tab, and those it opened in a new tab.
@MainActor private final class Host {
var shown: [String] = []
var opened: [String] = []
var tabs: Set<String> = []
}

/// The chat `coordinator` on this Mac's acpmux.
private static func pane(_ host: Host) -> AgentPaneModel {
let model = AgentPaneModel(host: NoHost(), sessionId: "coordinator")
model.transport.acpmuxSocket = "/tmp/acpmux-main.sock"
model.header = AgentPaneHeaderHooks(run: { _, _ in }, tabState: { AgentPaneHeaderState() }, openTab: { session, destination in
#expect(destination == .tab)
host.opened.append(session)
return true
}, showSession: { session in
guard host.tabs.contains(session) else { return false }
host.shown.append(session)
return true
})
return model
}

private static func open(_ session: String) -> AgentPaneRequest {
AgentPaneRequest(body: ["method": "chat.asks.open", "params": ["sessionId": session]])
}

@Test func theWaitingChatOpensInItsOwnTabOrTheTabThatShowsIt() async {
#expect(AgentPageOps.all.contains("cmux.agent.chat.asks.open"))
let host = Host()
let model = Self.pane(host)
#expect(await model.respond(to: Self.open("directional-motion"))["ok"] as? Bool == true)
#expect(host.opened == ["directional-motion"])
host.tabs.insert("directional-motion")
#expect(await model.respond(to: Self.open("local/directional-motion"))["ok"] as? Bool == true)
#expect(host.shown == ["directional-motion"])
#expect(host.opened == ["directional-motion"], "a second jump opened a second tab on the same chat")
#expect(model.sessionId == "coordinator", "the chat on screen stays")
}

/// Never the chat itself, a chat another computer's daemon holds, a chat on another machine's
/// pane, or a New Tab page.
@Test func onlyAnotherChatOnThisPanesOwnAcpmuxOpens() async {
let host = Host()
let model = Self.pane(host)
#expect(await model.respond(to: Self.open("coordinator"))["ok"] as? Bool != true, "the chat itself")
#expect(await model.respond(to: Self.open("local/coordinator"))["ok"] as? Bool != true, "the chat itself")
#expect(await model.respond(to: Self.open("build-host/other"))["ok"] as? Bool != true, "a peer's chat")
model.remoteChat = true
#expect(await model.respond(to: Self.open("other"))["ok"] as? Bool != true, "a chat on another machine")
let fresh = AgentPaneModel(host: NoHost())
fresh.transport.acpmuxSocket = "/tmp/acpmux-main.sock"
fresh.header = model.header
#expect(await fresh.respond(to: Self.open("other"))["ok"] as? Bool != true, "a pane with no chat")
#expect(host.opened.isEmpty && host.shown.isEmpty)
}
}
24 changes: 17 additions & 7 deletions webviews/src/agent-session/acpmux/needsYou/useNeedsYou.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export function useNeedsYou({
: needsYouItems({ sessions, asks: asks ?? [], open: { ...(sessionId ? { sessionId } : {}), rows } }),
[mode, pillOn, sessions, asks, sessionId, rows],
);
/// Another chat's ask: the app shows that chat's tab or opens one (`chat.asks.open`,
/// AgentPaneAskOpen). Never `chat.select`, which replaced the chat on screen in its own tab
/// (Leo 2026-10-21). A host that opens none leaves this chat as it is.
const openElsewhere = (item: NeedsYouItem) =>
callNative("chat.asks.open", { sessionId: item.sessionId }).catch(() => undefined);
const goTo = (item: NeedsYouItem) => {
const reveal = () => {
if (item.turnId) void revealTurnWhenShown(item.turnId);
Expand All @@ -62,24 +67,29 @@ export function useNeedsYou({
?.scrollIntoView({ block: "nearest" });
};
if (item.sessionId === sessionId) return reveal();
void callNative("chat.select", { sessionId: item.sessionId }).then(reveal, () => undefined);
void openElsewhere(item);
};
const actions: NeedsYouActions = {
answer: async (item, choice) => {
// `reply` throws on an answer its question cannot take: a refusal, never a stuck row.
const call = answerParams(item, choice);
if (!call) throw new Error("unanswerable");
// The pane may answer only a chat it shows (the relay's session scope and answers rule), so
// an ask of another chat opens that chat first; the same click's gesture covers both.
if (item.sessionId !== sessionId && item.kind !== "held")
await callNative("chat.select", { sessionId: item.sessionId });
// another chat's ask opens in that chat's tab, answered there; the row stays here.
if (item.sessionId !== sessionId && item.kind !== "held") {
await openElsewhere(item);
throw new Error();
}
return callNative(call.method, call.params);
},
goTo,
ask: async (item, question) => {
// `_acpmux/btw` is session scoped like an answer: another chat's ask opens that chat first,
// and its side session opens beside it. The same click's gesture covers both.
if (item.sessionId !== sessionId) await callNative("chat.select", { sessionId: item.sessionId });
// `_acpmux/btw` is session scoped like an answer: another chat's ask opens in that chat's
// tab, asked about there.
if (item.sessionId !== sessionId) {
await openElsewhere(item);
throw new Error();
}
return sideSession(item.sessionId, question);
},
};
Expand Down
44 changes: 36 additions & 8 deletions webviews/test/agent-pane-needs-you.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,11 @@ test("compact mode lists asks without answers", () => {
expect(html).not.toContain("acpmux-needs-you-answer");
});

test("answering another chat's ask opens that chat first, so the relay lets the answer through", async () => {
test("another chat's ask opens in that chat's own tab; the chat on screen is never replaced", async () => {
// Leo 2026-10-21: a Needs you jump switched the coordinator's pane to another chat in place
// (`chat.select`), so the coordinator's chat was gone from its own tab. A jump, an answer or a
// side question for another chat asks the app to show that chat's tab or open one
// (`chat.asks.open`, AgentPaneAskOpen); only this chat's asks are answered here.
const host = installDom();
const calls: string[] = [];
const callNative = (async (method: string, params?: Record<string, unknown>) => {
Expand Down Expand Up @@ -444,16 +448,40 @@ test("answering another chat's ask opens that chat first, so the relay lets the
await actions!.answer(here, here.answers[0]!);
expect(calls).toEqual(["chat.permission:here"]);
calls.length = 0;
await actions!.answer(other, other.answers[0]!);
expect(calls).toEqual(["chat.select:other", "chat.permission:other"]);
// A refused question answer rejects instead of throwing out of the click.
await expect(actions!.answer({ ...other, permissionId: undefined }, other.answers[0]!)).rejects.toThrow();
// The relay answers only the chat this pane shows: the answer is not sent from here, and the
// row stays (a rejection) while the ask's own tab comes up for the person to answer there.
await expect(actions!.answer(other, other.answers[0]!)).rejects.toThrow();
expect(calls).toEqual(["chat.asks.open:other"]);
calls.length = 0;
actions!.goTo(other);
await Promise.resolve();
expect(calls).toEqual(["chat.asks.open:other"]);
// Ask about this starts a side session (`_acpmux/btw`), which the relay also scopes to a chat the
// pane shows: another chat's ask opens that chat first; this chat's goes out at once.
// pane shows: this chat's goes out at once; another chat's opens that chat's tab instead.
calls.length = 0;
await actions!.ask(here, "Why?");
expect(calls).toEqual(["btw:here:Why?"]);
calls.length = 0;
await actions!.ask(other, "Why?");
expect(calls).toEqual(["chat.select:other", "btw:other:Why?"]);
await expect(actions!.ask(other, "Why?")).rejects.toThrow();
expect(calls).toEqual(["chat.asks.open:other"]);
// A host that opens no tab (another machine's chat) leaves the chat on screen as it is.
const refusing = (async (method: string, params?: Record<string, unknown>) => {
calls.push(`${method}:${String(params?.sessionId ?? "")}`);
throw new Error("unsupported");
}) as <T>(method: string, params?: Record<string, unknown>) => Promise<T>;
let refused: NeedsYouActions | undefined;
function Refused() {
refused = useNeedsYou({
snapshot: { sessionId: "here", rows: [], sessions: [session("here"), session("other")], asks: [] },
callNative: refusing,
sideSession: async () => undefined,
}).actions;
return null;
}
await act(async () => root!.render(<Refused />));
calls.length = 0;
refused!.goTo(other);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(calls).toEqual(["chat.asks.open:other"]);
expect(calls.some((call) => call.startsWith("chat.select"))).toBe(false);
});