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
@@ -1,8 +1,11 @@
extension AgentPaneModel {
/// The app actions any agent tab may run: the location row's SSH…, New Cloud Machine and Open
/// Cloud…, which opens the Cloud page at its storefront (`#/get`: the plan's sizes; the pane
/// knows no plan). webviews environments.ts CONNECT_ACTIONS names the same three.
static let connectActions: Set<String> = ["remote.connect", "newCloudMachine", "palette.openCloudPane"]
/// knows no plan), and a started chat's Move to Cloud and Bring Back Local (the tab menu's same
/// actions, on this tab). webviews environments.ts CONNECT_ACTIONS names the same five.
static let connectActions: Set<String> = [
"remote.connect", "newCloudMachine", "palette.openCloudPane", "cloudMove", "cloudBringBack",
]
/// The arguments a connect action runs with.
static let connectArguments: [String: [String: String]] = ["palette.openCloudPane": ["route": "/get"]]
/// What runs when a connect is refused: signed out, cmux Cloud… starts sign-in.
Expand All @@ -14,14 +17,17 @@ extension AgentPaneModel {
/// Cloud SSH"). Integrate a harness is the page's own Harnesses page (cx-uj5g8).
/// A connect flow opens app UI, so it uses the user's fresh gesture in the pane (the grant
/// credit, as Choose Folder…); page script cannot make one. Its sign-in fallback runs only
/// inside that same gesture.
/// inside that same gesture. An action the app did not run (turned off, or not for this tab), with
/// no fallback that ran, is an `unavailable` failure, so the page can say so; one that ran and
/// then stopped reports itself in the app (its refusal or toast).
func runAction(_ id: String) -> [String: Any] {
guard id == "newTab.page" || id == "agentPane.searchChats" || Self.connectActions.contains(id),
let onRunAction else {
return Self.unsupported("action.run")
}
if Self.connectActions.contains(id), !transport.gestures.consume() { return Self.transportFailure(.gestureRequired) }
if !onRunAction(id, Self.connectArguments[id] ?? [:]), let fallback = Self.connectFallbacks[id] { _ = onRunAction(fallback, [:]) }
return AgentPaneReply.success()
if onRunAction(id, Self.connectArguments[id] ?? [:]) { return AgentPaneReply.success() }
if let fallback = Self.connectFallbacks[id], onRunAction(fallback, [:]) { return AgentPaneReply.success() }
return AgentPaneReply.failure(code: "unavailable", message: "The app did not run \(id).")
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import AppKit
import CmuxNextActions
import Foundation

/// The app side of the chat header (`pane.action`, `pane.tabState`): runs a
Expand Down Expand Up @@ -138,7 +140,9 @@ extension AgentPaneModel {
guard let header else {
return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: pane.tabState")
}
return AgentPaneReply.success(header.state(refresh: refresh).reply)
var state = header.state(refresh: refresh).reply
state["moves"] = offeredMoves()
return AgentPaneReply.success(state)
case .sideChat(let fork):
return await AgentPaneSideChat.open(fork, in: self)
case .subagentPane(let agent, let focus):
Expand Down Expand Up @@ -191,3 +195,18 @@ enum AgentPaneSideChat {
return AgentPaneReply.success()
}
}

extension AgentPaneModel {
/// The Cloud moves (`cloudMove`, `cloudBringBack`) the chat's right-click menu offers now, so the
/// composer's picker offers the same: the App decides which apply (it hides Bring Back Local on a
/// chat no move from this Mac put on its machine, and Move to Cloud on a chat of another machine).
func offeredMoves() -> [String] {
func ids(_ items: [NSMenuItem]) -> [String] {
items.flatMap { item in
(ActionRegistry.menuRun(of: item).map { [$0.id.rawValue] } ?? []) + ids(item.submenu?.items ?? [])
}
}
let moves: Set<String> = ["cloudMove", "cloudBringBack"]
return ids(chatMenuItems?() ?? []).filter(moves.contains)
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import AppKit
import CmuxNextActions
import Testing
@testable import CmuxNextAgentPane

Expand Down Expand Up @@ -31,4 +33,55 @@ struct AgentPaneConnectActionTests {
#expect(await model.respond(to: .runAction("newCloudMachine"))["ok"] as? Bool == true)
#expect(arguments == [[:]])
}

/// A started chat's picker: Move to Cloud on a chat of this Mac, Bring Back Local on a Cloud
/// chat, the same actions as the tab's right-click menu, on the person's gesture.
@Test func moveAndBringBackRunOnAGesture() async {
let model = AgentPaneModel(host: NoHost())
var ran: [String] = []
model.onRunAction = { id, args in ran.append(id); #expect(args.isEmpty); return true }
#expect(await model.respond(to: .runAction("cloudMove"))["ok"] as? Bool != true)
model.transport.gestures.record()
#expect(await model.respond(to: .runAction("cloudMove"))["ok"] as? Bool == true)
model.transport.gestures.record()
#expect(await model.respond(to: .runAction("cloudBringBack"))["ok"] as? Bool == true)
#expect(ran == ["cloudMove", "cloudBringBack"])
}

/// An action the app did not run (turned off, or not available for this chat) is a failure, so
/// the page can say so instead of nothing happening.
@Test func anActionTheAppDidNotRunIsAFailure() async {
let model = AgentPaneModel(host: NoHost())
model.onRunAction = { _, _ in false }
model.transport.gestures.record()
let reply = await model.respond(to: .runAction("cloudBringBack"))
#expect(reply["ok"] as? Bool != true)
#expect((reply["error"] as? [String: Any])?["code"] as? String == "unavailable")
}

/// Which move a started chat's picker offers is the app's call: `pane.tabState` reports the moves
/// the chat's own right-click menu offers now (the App hides Bring Back Local on a chat no move
/// from this Mac put on its machine, and Move to Cloud on a chat of another machine).
@Test func tabStateReportsTheMovesTheChatMenuOffers() async {
let registry = ActionRegistry.standard()
for id: ActionID in ["cloudMove", "cloudBringBack"] { registry.bind(id, invoke: { _ in }) }
registry.context = ActionContext(rawValue: .max)
let model = AgentPaneModel(host: NoHost())
model.header = AgentPaneHeaderHooks(run: { _, _ in }, tabState: { AgentPaneHeaderState() })
var hidden: Set<ActionID> = ["cloudBringBack"]
model.chatMenuItems = {
let entries = ContextMenuCatalog.shared.entries(for: .agentChat, removing: hidden)
let menu = registry.makeContextMenu(for: .agentChat, target: ActionTargetRef(kind: .tab, id: "tab_chat"),
entries: entries, implied: [])
let items = menu.items
menu.removeAllItems()
return items
}
let moves = { (await model.respond(to: .tabState(refresh: false))["value"] as? [String: Any])?["moves"] as? [String] }
#expect(await moves() == ["cloudMove"])
hidden = ["cloudMove"]
#expect(await moves() == ["cloudBringBack"])
hidden = ["cloudMove", "cloudBringBack"]
#expect(await moves() == [])
}
}
27 changes: 24 additions & 3 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import React, {
useSyncExternalStore,
} from "react";
import { flushSync } from "react-dom";
import { CONNECT_ACTIONS, type ConnectKind } from "./environments";
import { CONNECT_ACTIONS, type ConnectKind, type OffloadKind } from "./environments";
import { chiefEnvelope } from "./conversation/chiefEnvelope";
import { QueryClientProvider, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { applyAgentTheme } from "../shared/theme";
Expand Down Expand Up @@ -4316,6 +4316,16 @@ function AcpmuxPane() {
// SSH… opens Connect to Machine; New Cloud Machine opens the host's create flow (Lawrence 2026-10-06).
const connectMachine = (kind: ConnectKind) =>
void callNative("action.run", { id: CONNECT_ACTIONS[kind] }).catch(() => undefined);
// Move to Cloud and Bring Back Local report their own progress and failures as the app's toasts;
// the notice says only that the app did not start the move (turned off, or not for this chat).
const offloadChat = (kind: OffloadKind) =>
void callNative("action.run", { id: CONNECT_ACTIONS[kind] }).catch(() =>
directClient.current?.notice(
translate("composer.offloadNotRun", {
action: translate(kind === "move" ? "composer.moveToCloud" : "composer.bringBackLocal"),
}),
),
);
/// The Quick Composer panel (`"surface": "quick"` in the host's ready reply) or a tab's pane.
const [surface, setSurface] = useState<PaneSurface>("pane");
const quick = surface === "quick";
Expand Down Expand Up @@ -5813,7 +5823,12 @@ function AcpmuxPane() {
// The header's state from the chat's tab (header/headerState.ts): read when a chat opens, then pushed
// by the App on every change. Whether the panes beside the chat show ([+] New tab reads Hide tabs) also
// follows the App's answer to the click.
const { sideTabs, pullRequest: headerPullRequest } = useHeaderState();
const { sideTabs, pullRequest: headerPullRequest, moves } = useHeaderState();
// The moves the chat's own right-click menu offers now: the composer's picker offers the same.
const offloads = useMemo(
() => (["move", "bringBack"] as const).filter((kind) => moves.includes(CONNECT_ACTIONS[kind])),
[moves],
);
const toggleSideTabs = () =>
ignoreFailure(
callNative<{ shown?: boolean }>("pane.action", { id: "sideTabs" }).then((result) =>
Expand Down Expand Up @@ -6820,7 +6835,13 @@ function AcpmuxPane() {
onProject={chooseProject}
onNewWorktree={freshChat && !quick && !remoteChat ? startInWorktree : undefined}
onConnect={connectMachine}
onRefreshMachines={() => void directClient.current?.refreshPeers()}
// The floating chat has no tab to move.
{...(quick ? {} : { onOffload: offloadChat, offloads })}
// The list opened: the machines' status and the chat's moves are read again.
onRefreshMachines={() => {
void directClient.current?.refreshPeers();
refreshTabState();
}}
// A started local chat lists the same folders to move to (dogfood 09); Start Agent's
// panel lists them before its first prompt (cx-hkat).
projectChoices={freshChat || (!quick && canMove) ? newTabProjects : undefined}
Expand Down
9 changes: 8 additions & 1 deletion webviews/src/agent-session/acpmux/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, us
import { createPortal } from "react-dom";
// Send's iMessage colors (--cmux-messages-blue); build-agent-pane-web.sh inlines the sheet.
import "../../theme/messages.css";
import type { ConnectKind } from "./environments";
import type { ConnectKind, OffloadKind } from "./environments";
import { useEscapingList } from "../../ui/popupHost";
import type { AcpmuxSnapshot, QueueEdit } from "./model";
import {
Expand Down Expand Up @@ -232,6 +232,9 @@ type Props = {
onBrowseFolder?(): Promise<string | undefined>;
/// The location row's SSH… and New Cloud Machine rows open the host's connect flows.
onConnect?(kind: ConnectKind): void;
/// A started chat's Move to Cloud or Bring Back Local (see ComposerContext), as `offloads` lists them.
onOffload?(kind: OffloadKind): void;
offloads?: readonly OffloadKind[];
/// The environment picker opened: read the machines and their status again.
onRefreshMachines?(): void;
/// This Mac's name for the location row.
Expand Down Expand Up @@ -298,6 +301,8 @@ export function Composer({
onNewWorktree,
onBrowseFolder,
onConnect,
onOffload,
offloads,
onRefreshMachines,
localName,
environments,
Expand Down Expand Up @@ -1299,6 +1304,8 @@ export function Composer({
onNewWorktree={onNewWorktree}
onBrowseFolder={onBrowseFolder}
onConnect={onConnect}
{...(onOffload ? { onOffload } : {})}
{...(offloads ? { offloads } : {})}
summary={snapshot.summary}
sessions={snapshot.sessions}
peers={snapshot.peers}
Expand Down
42 changes: 42 additions & 0 deletions webviews/src/agent-session/acpmux/ComposerContext.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,48 @@ export default componentEntry<ComponentProps<typeof ComposerContext>>({
},
},
},
"started-move-to-cloud": {
note: "Preview, a started chat on this Mac: the picker shows where it runs and Move to Cloud, the tab menu's same action, saying it reuses a Cloud machine or makes one.",
props: {
...baseProps,
environments: true,
started: true,
onOffload: () => undefined,
offloads: ["move"],
summary: {
sessionId: "gallery-started-local",
harness: "claude",
cwd: projects[0]!.cwd,
host: "This Mac",
hostKind: "local",
},
},
play: async (ctx) => {
await ctx.click({ role: "button", name: /^Environment/ });
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-env-popover .acpmux-env-new"));
},
},
"started-bring-back": {
note: "Preview, a started Cloud chat a move from this Mac put there: the picker shows its machine and Bring Back Local, which merges the machine's changes into the folder here.",
props: {
...baseProps,
environments: true,
started: true,
onOffload: () => undefined,
offloads: ["bringBack"],
summary: {
sessionId: "gallery-started-cloud",
harness: "claude",
cwd: "/home/you/project",
host: "Cloud machine",
hostKind: "cloud",
},
},
play: async (ctx) => {
await ctx.click({ role: "button", name: /^Environment/ });
await ctx.waitFor(() => ctx.document.querySelector(".acpmux-env-popover .acpmux-env-new"));
},
},
"started-branch": {
note: "After a turn starts, location labels lock while the branch remains visible at the right.",
props: {
Expand Down
19 changes: 16 additions & 3 deletions webviews/src/agent-session/acpmux/ComposerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Popover } from "../../ui/Popover";
import type { AcpmuxSnapshot } from "./model";
import { ChevronIcon } from "./ComposerPickers";
import { EnvironmentIcon, EnvironmentPicker, type SavedEnvironmentRows } from "./EnvironmentPicker";
import type { ConnectKind, EnvironmentKind } from "./environments";
import type { ConnectKind, EnvironmentKind, OffloadKind } from "./environments";
import { FolderPopover } from "./FolderPopover";
import type { Project } from "./ProjectChooser";
import { projectSuffixes } from "./projectSuffix";
Expand Down Expand Up @@ -38,6 +38,7 @@ type Location = { id: string; label: string; detail?: string; repo?: ProjectRepo
type Computer = Location & { kind: EnvironmentKind; connected?: boolean };
const NO_PEER_KINDS: Record<string, "ssh" | "cloud"> = {};
const NO_PEER_CONNECTED: Record<string, boolean> = {};
const NO_OFFLOADS: readonly OffloadKind[] = [];

/// The location strip on the composer card's top edge (hq#1957 composer location strip): folder ·
/// computer, and the branch at the end. New chats can choose a local or Cloud computer and one of
Expand All @@ -62,6 +63,8 @@ export function ComposerContext({
onNewChat,
onBrowseFolder,
onConnect,
onOffload,
offloads = NO_OFFLOADS,
saved,
onRefreshMachines,
}: {
Expand Down Expand Up @@ -94,6 +97,13 @@ export function ComposerContext({
/// A new chat's Computer menu ends with SSH… and New Cloud Machine, which open the host's
/// connect flows (Lawrence 2026-10-06: "I cannot click on cmux Cloud SSH").
onConnect?(kind: ConnectKind): void;
/// A started chat's one move, in the environment picker: Move to Cloud for a chat on this Mac,
/// Bring Back Local for one a move put on a Cloud machine (the tab menu's same actions), as
/// `offloads` lists them. None when the host passes none (the floating chat).
onOffload?(kind: OffloadKind): void;
/// The moves the app offers this chat now (its right-click menu's, `pane.tabState` `moves`): the
/// picker offers only these, whatever the machine's kind.
offloads?: readonly OffloadKind[];
/// The New Tab page's saved environments, in the environment picker. While one is picked it
/// alone names where the chat starts: the folder and New Worktree controls step aside.
saved?: SavedEnvironmentRows;
Expand Down Expand Up @@ -189,7 +199,9 @@ export function ComposerContext({
if (folder) onProject?.(folder, id === "local" ? undefined : id);
}
};
const computerDisabled = readOnly && (started || !onConnect);
// A started chat's picker lists only where it runs and its move (EnvironmentPicker `offload`).
const offload: OffloadKind | undefined = started && environments && onOffload ? offloads[0] : undefined;
const computerDisabled = readOnly && !offload && (started || !onConnect);
// Preview (`labs.previewFeatures`): the computer control is the environment picker.
const environmentsShown = environments && !computerDisabled;
const savedRows = environmentsShown && !readOnly ? saved : undefined;
Expand Down Expand Up @@ -283,9 +295,10 @@ export function ComposerContext({
chevron
/>
}
environments={computers}
environments={offload && currentComputer ? [currentComputer] : computers}
current={selectedComputer}
onPick={pickEnvironment}
{...(offload && onOffload ? { offload, onOffload } : {})}
{...(!started && onConnect ? { onConnect } : {})}
{...(savedRows ? { saved: savedRows } : {})}
{...(onRefreshMachines ? { onOpen: onRefreshMachines } : {})}
Expand Down
Loading