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
213 changes: 213 additions & 0 deletions src/browser/features/Settings/Sections/RemoteConnectionSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { useEffect, useState, type FormEvent } from "react";
import { Button } from "@/browser/components/Button/Button";
import { Input } from "@/browser/components/Input/Input";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { isMac } from "@/browser/utils/ui/keybinds";
import { REMOTE_CONNECTION_RETURN_ACCELERATOR } from "@/common/constants/remoteConnection";
import {
getRemoteConnectionServerUrl,
parseRemoteConnectionUrl,
type RemoteConnectionState,
} from "@/common/types/remoteConnection";
import { getErrorMessage } from "@/common/utils/errors";

export const REMOTE_CONNECTION_URL_KEY = "remoteConnectionUrl";

const STATUS_LABELS: Record<RemoteConnectionState["status"], string> = {
disconnected: "Disconnected",
connecting: "Connecting…",
connected: "Connected",
};

export function RemoteConnectionSection() {
const bridge = window.api?.remoteConnection;
const [savedUrl, setSavedUrl] = usePersistedState(REMOTE_CONNECTION_URL_KEY, "");
// Keep pasted tokens transient. Save the server pathname for app-proxy connections.
const [url, setUrl] = useState(() => {
try {
return getRemoteConnectionServerUrl(savedUrl);
} catch {
return "";
}
});
const [connection, setConnection] = useState<RemoteConnectionState | null>(null);
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);

useEffect(() => {
if (!bridge) return;
let disposed = false;
let receivedUpdate = false;
// Subscribe first. A later snapshot must not overwrite a newer bridge event.
const unsubscribe = bridge.onStateChanged((state) => {
if (disposed) return;
receivedUpdate = true;
setConnection(state);
setError(state.error ?? null);
});
bridge.getState().then(
(state) => {
if (disposed || receivedUpdate) return;
setConnection(state);
setError(state.error ?? null);
},
(cause: unknown) => {
if (disposed || receivedUpdate) return;
setError(`Cannot read the remote connection state: ${getErrorMessage(cause)}`);
}
);
return () => {
disposed = true;
unsubscribe();
};
}, [bridge]);

if (!bridge) return null;

const isConnecting = connecting || connection?.status === "connecting";
const canConnect = !isConnecting && !disconnecting && connection?.status !== "connected";
const canDisconnect =
connection != null && connection.status !== "disconnected" && !disconnecting;
const returnShortcut = REMOTE_CONNECTION_RETURN_ACCELERATOR.replace(
"CommandOrControl",
isMac() ? "Cmd" : "Ctrl"
);

// Keep HTTP available for encrypted tunnels without assuming the tunnel makes a secure browser context.
let showHttpWarning = false;
try {
showHttpWarning = parseRemoteConnectionUrl(url).protocol === "http:";
} catch {
// Incomplete addresses use the existing validation when the user connects.
}

async function handleConnect(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!bridge || !canConnect) return;
setError(null);
try {
const serverUrl = getRemoteConnectionServerUrl(url);
setSavedUrl(serverUrl);
const enteredUrl = url;
setUrl(serverUrl);
setConnecting(true);
await bridge.connect(enteredUrl);
} finally {
setConnecting(false);
}
}

async function handleDisconnect() {
if (!bridge || !canDisconnect) return;
setError(null);
setDisconnecting(true);
try {
await bridge.disconnect();
} finally {
setDisconnecting(false);
}
}

return (
<section aria-label="Remote connection" className="min-w-0 space-y-4">
<div>
<h3 className="text-foreground text-sm font-medium">Connect to a remote server</h3>
<p className="text-muted mt-1 text-xs">
Open a remote Xum server in a separate window. Your local workspaces and tasks keep
running.
</p>
</div>

<form
onSubmit={(event) => {
handleConnect(event).catch((cause: unknown) => setError(getErrorMessage(cause)));
}}
className="space-y-3"
>
<div className="space-y-1.5">
<label htmlFor="remote-connection-url" className="text-foreground text-sm font-medium">
Server URL
</label>
<Input
id="remote-connection-url"
value={url}
onChange={(event) => {
setUrl(event.target.value);
setError(null);
}}
placeholder="https://xum.example.com"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
inputMode="url"
aria-describedby={
showHttpWarning
? "remote-connection-help remote-connection-http-warning"
: "remote-connection-help"
}
disabled={isConnecting || disconnecting || connection?.status === "connected"}
/>
<p id="remote-connection-help" className="text-muted text-xs">
Enter an HTTP or HTTPS URL. You can include a token link. Sign in through the remote web
UI. The server address and path are saved without tokens. Xum does not connect
automatically.
</p>
</div>
{showHttpWarning && (
<div
id="remote-connection-http-warning"
role="note"
aria-label="HTTP connection warning"
className="bg-warning/10 border-warning/30 text-warning space-y-2 rounded-md border px-3 py-2 text-xs"
>
<p>
HTTP does not encrypt your authentication token or data. Use HTTPS or a trusted
encrypted tunnel, such as Tailscale.
</p>
<p>
Voice input and other secure-context features require HTTPS for remote addresses, even
over Tailscale. Browsers treat localhost and loopback addresses as exceptions.
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button type="submit" size="sm" disabled={!canConnect || !url.trim()}>
{isConnecting ? "Connecting…" : "Connect"}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
handleDisconnect().catch((cause: unknown) =>
setError(`Cannot disconnect: ${getErrorMessage(cause)}`)
);
}}
disabled={!canDisconnect}
>
{disconnecting ? "Disconnecting…" : "Disconnect"}
</Button>
</div>
</form>

<div role="status" className="text-muted text-xs [overflow-wrap:anywhere]">
{connection
? STATUS_LABELS[connection.status]
: error
? "Connection state unavailable"
: "Reading connection state…"}
{connection?.serverUrl && <span> · {connection.serverUrl}</span>}
</div>
{error && (
<p role="alert" className="text-destructive text-xs [overflow-wrap:anywhere]">
{error}
</p>
)}
<p className="text-muted text-xs">
Close the remote window to return here.
<span className="hidden md:inline"> You can also disconnect with {returnShortcut}.</span>
</p>
</section>
);
}
16 changes: 16 additions & 0 deletions src/browser/features/Settings/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ describe("SettingsPage", () => {
expect(getSettingsSectionRedirect("plugins", false, false, true)).toBeNull();
});

test("shows Remote Connection only when the desktop bridge is available", () => {
expect(getSettingsSections(false, false, false, true).map((section) => section.id)).toContain(
"remote-connection"
);
expect(getSettingsSections(true, true, true, false).map((section) => section.id)).not.toContain(
"remote-connection"
);
});

test("redirects an unavailable Remote Connection deep link to General", () => {
expect(getSettingsSectionRedirect("remote-connection", true, true, true, false)).toEqual({
section: "general",
});
expect(getSettingsSectionRedirect("remote-connection", false, false, false, true)).toBeNull();
});

test("always shows the Backup section", () => {
expect(getSettingsSections(false, false, false).map((section) => section.id)).toContain(
"backup"
Expand Down
43 changes: 37 additions & 6 deletions src/browser/features/Settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Shield,
ShieldCheck,
Server,
Monitor,
Lock,
ArchiveRestore,
ScrollText,
Expand All @@ -40,6 +41,7 @@ import { LayoutsSection } from "./Sections/LayoutsSection";
import { RuntimesSection } from "./Sections/RuntimesSection";
import { ExperimentsSection } from "./Sections/ExperimentsSection";
import { ServerAccessSection } from "./Sections/ServerAccessSection";
import { RemoteConnectionSection } from "./Sections/RemoteConnectionSection";
import { KeybindsSection } from "./Sections/KeybindsSection";
import { SecuritySection } from "./Sections/SecuritySection";
import { BackupSection } from "./Sections/BackupSection";
Expand Down Expand Up @@ -136,9 +138,19 @@ interface SettingsSectionRedirect {
export function getSettingsSections(
governorEnabled: boolean,
memoryEnabled: boolean,
agentPluginsEnabled: boolean
agentPluginsEnabled: boolean,
remoteConnectionAvailable = false
): SettingsSection[] {
const sections = [...BASE_SECTIONS];
if (remoteConnectionAvailable) {
const serverAccessIndex = sections.findIndex((section) => section.id === "server-access");
sections.splice(serverAccessIndex + 1, 0, {
id: "remote-connection",
label: "Remote Connection",
icon: <Monitor className="h-4 w-4 shrink-0" />,
component: RemoteConnectionSection,
});
}
if (agentPluginsEnabled) {
// Next to MCP: plugins contribute skills + MCP servers.
const mcpIndex = sections.findIndex((section) => section.id === "mcp");
Expand Down Expand Up @@ -180,7 +192,8 @@ export function getSettingsSectionRedirect(
activeSection: string,
governorEnabled: boolean,
memoryEnabled: boolean,
agentPluginsEnabled: boolean
agentPluginsEnabled: boolean,
remoteConnectionAvailable = false
): SettingsSectionRedirect | null {
if (LEGACY_EXPERIMENT_SETTINGS_SECTION_IDS.has(activeSection)) {
return { section: "experiments", replace: true };
Expand All @@ -198,6 +211,10 @@ export function getSettingsSectionRedirect(
return { section: BASE_SECTIONS[0]?.id ?? "general" };
}

if (!remoteConnectionAvailable && activeSection === "remote-connection") {
return { section: BASE_SECTIONS[0]?.id ?? "general" };
}

return null;
}

Expand All @@ -212,14 +229,16 @@ export function SettingsPage(props: SettingsPageProps) {
const governorEnabled = useExperimentValue(EXPERIMENT_IDS.MUX_GOVERNOR);
const memoryEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY);
const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS);
const remoteConnectionAvailable = window.api?.remoteConnection != null;

// Keep routing on a valid section when experiment-owned settings move or disappear.
// Redirect restored links when an experiment or desktop bridge is unavailable.
useEffect(() => {
const redirect = getSettingsSectionRedirect(
activeSection,
governorEnabled,
memoryEnabled,
agentPluginsEnabled
agentPluginsEnabled,
remoteConnectionAvailable
);
if (!redirect) {
return;
Expand All @@ -231,7 +250,14 @@ export function SettingsPage(props: SettingsPageProps) {
}

setActiveSection(redirect.section);
}, [activeSection, setActiveSection, governorEnabled, memoryEnabled, agentPluginsEnabled]);
}, [
activeSection,
setActiveSection,
governorEnabled,
memoryEnabled,
agentPluginsEnabled,
remoteConnectionAvailable,
]);

// Close settings on Escape. Uses bubble phase so inner surfaces (Select dropdowns,
// Popover, Dialog) that call stopPropagation/preventDefault on Escape get first
Expand All @@ -250,7 +276,12 @@ export function SettingsPage(props: SettingsPageProps) {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [close]);
const sections = getSettingsSections(governorEnabled, memoryEnabled, agentPluginsEnabled);
const sections = getSettingsSections(
governorEnabled,
memoryEnabled,
agentPluginsEnabled,
remoteConnectionAvailable
);
const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0];
const SectionComponent = currentSection.component;

Expand Down
Loading
Loading