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
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11752,6 +11752,9 @@ function AcpmuxPane() {
<SwitchNotice
switching={snapshot.switching}
onRetry={() => void callNative("chat.harness.retry")}
// The refused prompt is back in the composer; a chat on another machine starts only
// with a send, so the share's retry sends it again (as Trust's answer does).
onShared={() => void callNative("chat.harness.retry").then(() => composerHandle.current?.send())}
onAction={(action) => {
// A fresh chat on another machine has no summary yet: its connection's origin, or a
// folder list its host marked as that machine's, says where it runs.
Expand Down
12 changes: 8 additions & 4 deletions webviews/src/agent-session/acpmux/MachineShareAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ type Phase =
/// The card's one share button and its one status line, for a teammate's machine only
/// (machineShare.ts). One button at a time keeps the card compact: Share with Me, and once that is
/// refused (only the creator or an admin shares), Ask the Owner in its place. A share that went
/// through retries the start (`onShared`); once the owner was asked, Retry looks for their share
/// first (`retry`). The state belongs to one refusal: a new one starts over.
/// through starts the chat again and resends the refused prompt (`onShared`: the refusal handed it
/// back to the composer, and a chat on another machine starts only with a send). Once the owner was
/// asked, Retry looks for their share first (`retry`): shared resends, else a plain retry. The state
/// belongs to one refusal: a new one starts over.
export function useMachineShare(
share: MachineShareProps | undefined,
refusal: { code?: string; error?: string } | undefined,
onShared: () => void,
onRetry: () => void,
onShared: () => void = onRetry,
): { button?: ReactNode; line?: string; retry?: () => void } {
const t = useT();
const key = `${refusal?.code ?? ""}\n${refusal?.error ?? ""}`;
Expand All @@ -48,7 +51,8 @@ export function useMachineShare(
// when the person is on its grants), then starts again either way.
const recheck = async () => {
const outcome = await shareMachineWithMe(share.call);
if (outcome.kind === "shared") set({ kind: "idle" });
if (outcome.kind !== "shared") return onRetry();
set({ kind: "idle" });
onShared();
};
const button = asking ? (
Expand Down
43 changes: 38 additions & 5 deletions webviews/src/agent-session/acpmux/SwitchNotice.share.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// A teammate's machine refused the chat start: the card offers Share with Me beside New Cloud
// Machine and Retry, one share button at a time (Leo rejects bloated cards). A share retries the
// start; a refusal because only the creator or an admin shares turns the button into Ask the
// Machine and Retry, one share button at a time (Leo rejects bloated cards). A share starts the
// chat again and resends the refused prompt (#29563); a refusal because only the creator or an admin shares turns the button into Ask the
// Owner, which copies a request naming the machine.
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
Expand Down Expand Up @@ -57,26 +57,41 @@ const teamRefusal = {
code: "agent_session.team_vm_blocked",
};

async function render(container: HTMLElement, call: () => Promise<unknown>, ran: string[], switching = teamRefusal) {
async function render(
container: HTMLElement,
call: () => Promise<unknown>,
ran: string[],
switching = teamRefusal,
resends = true,
) {
await act(async () => {
root ??= createRoot(container);
root.render(
<SwitchNotice
switching={switching}
onRetry={() => ran.push("retry")}
{...(resends && { onShared: () => ran.push("shared") })}
onAction={(action) => ran.push(action)}
share={{ machine: "build-box", call }}
/>,
);
});
}

test("a teammate's machine offers Share with Me, and a share retries the start", async () => {
test("a teammate's machine offers Share with Me, and a share resends the refused start", async () => {
const container = installDom();
const ran: string[] = [];
await render(container, async () => ({ shared: true }), ran);
expect(buttons(container)).toEqual(["Share with Me", "New Cloud Machine", "Retry"]);
await press(container, "Share with Me");
expect(ran).toEqual(["shared"]);
});

test("without a resend handler, a share falls back to a plain retry", async () => {
const container = installDom();
const ran: string[] = [];
await render(container, async () => ({ shared: true }), ran, teamRefusal, false);
await press(container, "Share with Me");
expect(ran).toEqual(["retry"]);
});

Expand Down Expand Up @@ -123,10 +138,28 @@ test("once the owner was asked, Retry looks for their share before it starts aga
shared = true;
await press(container, "Retry");
expect(calls).toBe(2);
expect(ran).toEqual(["retry"]);
expect(ran).toEqual(["shared"]);
expect(buttons(container)).toEqual(["Share with Me", "New Cloud Machine", "Retry"]);
});

test("once the owner was asked, Retry with no share yet is a plain retry", async () => {
const container = installDom();
const ran: string[] = [];
let calls = 0;
await render(
container,
async () => {
calls += 1;
throw new NativeError({ code: "vm_chat_share_forbidden" });
},
ran,
);
await press(container, "Share with Me");
await press(container, "Retry");
expect(calls).toBe(2);
expect(ran).toEqual(["retry"]);
});

test("other refusals offer no share, and a new refusal starts over", async () => {
const container = installDom();
const ran: string[] = [];
Expand Down
5 changes: 4 additions & 1 deletion webviews/src/agent-session/acpmux/SwitchNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ import { type StartRefusalAction, startRefusalAction } from "./startRefusal";
export function SwitchNotice({
switching,
onRetry,
onShared,
onAction,
share,
}: {
switching: AcpmuxSnapshot["switching"];
onRetry(): void;
/// Share with Me went through: start again and resend the prompt the refusal handed back.
onShared?(): void;
onAction?(action: StartRefusalAction): void;
share?: MachineShareProps;
}) {
const t = useT();
const machineShare = useMachineShare(share, switching?.phase === "failed" ? switching : undefined, onRetry);
const machineShare = useMachineShare(share, switching?.phase === "failed" ? switching : undefined, onRetry, onShared);
if (!switching || switching.phase === "starting") return null;
if (switching.phase === "deferred")
return <output className="acpmux-switch-notice">{t("switch.deferred", { agent: switching.name })}</output>;
Expand Down
Loading