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
45 changes: 32 additions & 13 deletions app/components/ChatInput/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "@/app/hooks/useOnlineStatus";
import { WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { isFreeDesktopSandboxAvailable } from "@/lib/activation/free-desktop-sandbox";

interface ChatInputProps {
onSubmit: (e: React.FormEvent) => void | boolean | Promise<void | boolean>;
Expand Down Expand Up @@ -257,6 +258,7 @@ export const ChatInput = ({
subscription,
isCheckingProPlan,
hasLocalSandbox,
localConnections,
freeDesktopAgentOnlyActive,
desktopBridgeStatus,
defaultLocalSandboxPreference,
Expand Down Expand Up @@ -598,14 +600,18 @@ export const ChatInput = ({
}, [draftId, restoreDraftAttachments, uploadedFiles]);

// Free agent mode constraints:
// 1. Requires local sandbox — web users fall back to Ask if disconnected,
// while Desktop stays Agent-only and waits for its bridge to reconnect
// 1. Requires a connected local sandbox — Desktop may use either its
// built-in bridge or a separately connected local runner
// 2. Force local sandbox preference (not e2b)
// 3. Force auto model selection
const isFreeAgent =
!isCheckingProPlan && subscription === "free" && isAgentMode(chatMode);
const freeAgentSandboxAvailable = freeDesktopAgentOnlyActive
? desktopBridgeStatus === "connected"
? isFreeDesktopSandboxAvailable({
sandboxPreference,
desktopBridgeActive: desktopBridgeStatus === "connected",
localConnections,
})
: hasLocalSandbox;

const prevFreeAgentSandboxAvailableRef = useRef(freeAgentSandboxAvailable);
Expand All @@ -619,10 +625,18 @@ export const ChatInput = ({
if (!freeAgentSandboxAvailable) {
if (freeDesktopAgentOnlyActive) {
if (wasConnected) {
toast.info("Desktop sandbox disconnected.", {
description: "Reconnect the Desktop sandbox to keep using Agent.",
duration: 5000,
});
const selectedDesktop = sandboxPreference === "desktop";
toast.info(
selectedDesktop
? "Desktop sandbox disconnected."
: "Local sandbox disconnected.",
{
description: selectedDesktop
? "Reconnect the Desktop sandbox to keep using Agent."
: "Reconnect the selected local runner to keep using Agent.",
duration: 5000,
},
);
}
return;
}
Expand All @@ -639,6 +653,7 @@ export const ChatInput = ({
freeAgentSandboxAvailable,
freeDesktopAgentOnlyActive,
isFreeAgent,
sandboxPreference,
setChatMode,
]);

Expand All @@ -656,14 +671,18 @@ export const ChatInput = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFreeAgent]);

const desktopSandboxUnavailableReason =
freeDesktopAgentOnlyActive && desktopBridgeStatus !== "connected"
? desktopBridgeStatus === "connecting"
? "Desktop sandbox is connecting"
: "Reconnect the Desktop sandbox to use Agent"
const freeDesktopSandboxUnavailableReason =
freeDesktopAgentOnlyActive && !freeAgentSandboxAvailable
? sandboxPreference === "desktop"
? desktopBridgeStatus === "connecting"
? "Desktop sandbox is reconnecting"
: "Reconnect the Desktop sandbox to use Agent"
: sandboxPreference === "e2b"
? "Select a local sandbox to use Agent"
: "Reconnect the selected local sandbox to use Agent"
: undefined;
const effectiveSendDisabledReason =
sendDisabledReason ?? desktopSandboxUnavailableReason;
sendDisabledReason ?? freeDesktopSandboxUnavailableReason;

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down
38 changes: 34 additions & 4 deletions app/components/SandboxSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export function SandboxSelector({
const [open, setOpen] = useState(false);
const [connectHovered, setConnectHovered] = useState(false);
const { isTauri } = useTauri();
const { subscription, localConnections: connections } = useGlobalState();
const {
subscription,
localConnections: connections,
desktopBridgeStatus,
} = useGlobalState();
const isFreeUser = subscription === "free";

const detectedPlatform = useMemo(() => {
Expand All @@ -59,13 +63,19 @@ export function SandboxSelector({
shortLabel: "Cloud",
icon: Cloud,
};
const desktopLabel =
isTauri && desktopBridgeStatus !== "connected"
? desktopBridgeStatus === "connecting"
? "Local reconnecting"
: "Local unavailable"
: "Local";
const desktopOptions: ConnectionOption[] =
connections
?.filter((conn) => conn.isDesktop)
.map(() => ({
id: "desktop" as string,
label: "Local",
shortLabel: "Local",
label: desktopLabel,
shortLabel: desktopLabel,
icon: Monitor,
})) || [];
const remoteOptions: ConnectionOption[] =
Expand Down Expand Up @@ -113,7 +123,27 @@ export function SandboxSelector({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFreeUser, value, connections]);

const selectedOption = options.find((opt) => opt.id === value) || options[0];
const unavailableLocalOption: ConnectionOption | null =
value !== "e2b" && !valueMatchesOption
? {
id: value,
label:
value === "desktop" && desktopBridgeStatus === "connecting"
? "Local reconnecting"
: "Local unavailable",
shortLabel:
value === "desktop" && desktopBridgeStatus === "connecting"
? "Local reconnecting"
: value === "desktop" && desktopBridgeStatus === "connected"
? "Local"
: "Local unavailable",
icon: value === "desktop" ? Monitor : Laptop,
}
: null;
const selectedOption =
options.find((option) => option.id === value) ??
unavailableLocalOption ??
cloudOption;
const Icon = selectedOption?.icon || Cloud;

const buttonClassName =
Expand Down
77 changes: 77 additions & 0 deletions app/components/__tests__/SandboxSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, jest } from "@jest/globals";

const mockGlobalState = {
subscription: "free",
localConnections: [] as Array<{
connectionId: string;
isDesktop: boolean;
name?: string;
osInfo?: { hostname?: string };
}>,
desktopBridgeStatus: "connecting",
};

jest.mock("@/app/contexts/GlobalState", () => ({
useGlobalState: () => mockGlobalState,
}));

jest.mock("@/app/hooks/useTauri", () => ({
useTauri: () => ({ isTauri: true }),
}));

jest.mock("@/app/download/DownloadSection", () => ({
detectPlatform: () => ({ platform: "linux", downloadUrl: "/download" }),
}));

jest.mock("sonner", () => ({
toast: { info: jest.fn() },
}));

const { SandboxSelector } =
require("../SandboxSelector") as typeof import("../SandboxSelector");

describe("SandboxSelector", () => {
beforeEach(() => {
mockGlobalState.subscription = "free";
mockGlobalState.localConnections = [];
mockGlobalState.desktopBridgeStatus = "connecting";
});

it("shows Local reconnecting instead of Cloud while Desktop reconnects", () => {
render(<SandboxSelector value="desktop" />);

expect(
screen.getByRole("button", { name: /Local reconnecting/i }),
).toBeInTheDocument();
});

it("shows Local unavailable instead of Cloud after Desktop recovery fails", () => {
mockGlobalState.desktopBridgeStatus = "failed";
mockGlobalState.localConnections = [
{ connectionId: "stale-desktop", isDesktop: true },
];

render(<SandboxSelector value="desktop" />);

expect(
screen.getByRole("button", { name: /Local unavailable/i }),
).toBeInTheDocument();
});

it("shows the selected remote runner when it is connected", () => {
mockGlobalState.localConnections = [
{
connectionId: "remote-kali",
isDesktop: false,
name: "Kali VM",
osInfo: { hostname: "4p3x" },
},
];

render(<SandboxSelector value="remote-kali" />);

expect(screen.getByRole("button", { name: /4p3x/i })).toBeInTheDocument();
});
});
25 changes: 23 additions & 2 deletions app/contexts/GlobalState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
getAgentFirstDefaultDecision,
normalizeAgentFirstSandboxType,
} from "@/lib/activation/agent-first-default";
import { resolveFreeDesktopSandboxPreference } from "@/lib/activation/free-desktop-sandbox";
import {
ComposerStateProvider,
useComposerActions,
Expand Down Expand Up @@ -675,6 +676,22 @@ const GlobalStateProviderInner: React.FC<GlobalStateProviderProps> = ({
isTauriEnvironment();
const agentOnlyActive = paidAgentOnlyActive || freeDesktopAgentOnlyActive;
const accessibleChatMode: ChatMode = agentOnlyActive ? "agent" : chatMode;
const freeDesktopSandboxPreference = useMemo(
() =>
freeDesktopAgentOnlyActive
? resolveFreeDesktopSandboxPreference({
sandboxPreference,
desktopBridgeActive,
localConnections,
})
: null,
[
desktopBridgeActive,
freeDesktopAgentOnlyActive,
localConnections,
sandboxPreference,
],
);

const setChatMode = useCallback(
(mode: ChatMode) => {
Expand All @@ -687,14 +704,18 @@ const GlobalStateProviderInner: React.FC<GlobalStateProviderProps> = ({

useEffect(() => {
if (!agentOnlyActive) return;
if (freeDesktopAgentOnlyActive && sandboxPreference !== "desktop") {
setSandboxPreference("desktop");
if (
freeDesktopSandboxPreference &&
sandboxPreference !== freeDesktopSandboxPreference
) {
setSandboxPreference(freeDesktopSandboxPreference);
}
if (freeDesktopAgentOnlyActive && selectedModel !== "auto") {
setSelectedModelRaw("auto");
}
}, [
agentOnlyActive,
freeDesktopSandboxPreference,
freeDesktopAgentOnlyActive,
sandboxPreference,
selectedModel,
Expand Down
70 changes: 70 additions & 0 deletions app/hooks/__tests__/useSandboxPreference.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ jest.mock("@/app/services/desktop-sandbox-bridge", () => ({
DesktopSandboxBridge: jest.fn(),
}));

const mockCaptureAuthenticatedEvent = jest.fn();
jest.mock("@/lib/analytics/client", () => ({
captureAuthenticatedEvent: mockCaptureAuthenticatedEvent,
}));

jest.mock("sonner", () => ({
toast: { error: jest.fn() },
}));
Expand Down Expand Up @@ -55,6 +60,71 @@ describe("useSandboxPreference", () => {
expect(DesktopSandboxBridge).not.toHaveBeenCalled();
});

it("automatically retries a bridge that fails during startup readiness", async () => {
const bridgeInstances: Array<{
start: jest.Mock;
stop: jest.Mock;
getConnectionId: jest.Mock;
}> = [];
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});

(DesktopSandboxBridge as jest.Mock).mockImplementation(() => {
const index = bridgeInstances.length;
const instance = {
start: jest
.fn()
.mockImplementation(() =>
index === 0
? Promise.reject(new Error("transport closed"))
: Promise.resolve("connection-2"),
),
stop: jest.fn().mockResolvedValue(undefined),
getConnectionId: jest.fn().mockReturnValue("connection-2"),
};
bridgeInstances.push(instance);
return instance;
});

jest.useFakeTimers();
try {
const { result, rerender } = renderHook(
({ isAuthenticated }) => useSandboxPreference(isAuthenticated),
{ initialProps: { isAuthenticated: true } },
);

await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});
expect(bridgeInstances).toHaveLength(1);
expect(result.current.desktopBridgeStatus).toBe("connecting");
expect(mockCaptureAuthenticatedEvent).toHaveBeenCalledWith(
"desktop_bridge_recovery_scheduled",
{
clientSurface: "desktop_bridge",
reason: "startup_failed",
attempt: 1,
delayMs: 1_000,
},
);

await act(async () => {
await jest.advanceTimersByTimeAsync(1_000);
});
expect(bridgeInstances).toHaveLength(2);
expect(result.current.desktopBridgeStatus).toBe("connected");
expect(result.current.desktopBridgeActive).toBe(true);

rerender({ isAuthenticated: false });
await act(async () => {
await jest.advanceTimersByTimeAsync(0);
});
expect(result.current.desktopBridgeStatus).toBe("idle");
} finally {
jest.useRealTimers();
warnSpy.mockRestore();
}
});

it("invalidates on auth loss and automatically recovers a stale connection", async () => {
let resolveFirstStart: ((connectionId: string) => void) | undefined;
const firstStart = new Promise<string>((resolve) => {
Expand Down
Loading
Loading