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: 37 additions & 8 deletions apps/desktop/src/components/runners/add-runner-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { api, errorMessage } from "@/lib/api";
import { useRunners } from "@/lib/hooks";
import { onServerEvent, upsertRunner } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import { NetworkPicker, pickRoute } from "./network-picker";
import { RunnerHealthPanel, useRunnerHealth } from "./runner-health";
import { CommandBlock, THIS_COMPUTER, THIS_COMPUTER_INLINE, blockingChecks } from "./runner-parts";
import { useRunnerActions } from "./use-runner-actions";
Expand All @@ -38,6 +39,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
const [paired, setPaired] = useState<RemoteRunner | null>(null);
const [byCode, setByCode] = useState(false);
const [source, setSource] = useState<Source>("local");
const [address, setAddress] = useState<string | null>(null);
const now = useNow(1000);

const create = useMutation({ mutationFn: api.runners.pairing, onSuccess: setOffer });
Expand All @@ -48,6 +50,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
setOffer(null);
setByCode(false);
setSource("local");
setAddress(null);
create.mutate();
const off = onServerEvent((e) => {
if (e.type === "runner.paired") setPaired(e.runner);
Expand All @@ -68,6 +71,10 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, expired, paired]);

const route = offer ? pickRoute(offer.routes, address) : null;
const command = route?.command ?? null;
const waitingOn = route?.url ?? offer?.urls[0] ?? null;

// The list is kept live: the paired runner's connection and health follow it.
const runner = paired ? (runners?.find((r) => r.id === paired.id) ?? paired) : null;

Expand Down Expand Up @@ -99,20 +106,41 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
<motion.div key="command" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-6">
<ol className="space-y-5">
<Step n={1} title="Pick a Mac that stays on">
A Mac mini, or a laptop that stays plugged in. It has to reach this computer: on the same network, or through Tailscale.
A Mac mini, or a laptop that stays plugged in.
</Step>
<Step n={2} title={`How it reaches ${THIS_COMPUTER_INLINE}`}>
{offer && !create.isError ? (
<div className={cn("mt-2 transition-opacity", create.isPending && "opacity-40")}>
<NetworkPicker
routes={offer.routes}
tailscale={offer.tailscale}
value={route}
onChange={(r) => setAddress(r.address)}
onRefresh={() => create.mutate()}
refreshing={create.isPending}
/>
</div>
) : create.isError ? (
"On the same network, or from anywhere through Tailscale."
) : (
<Skeleton className="mt-2 h-28 rounded-lg" aria-label="Looking up this computer's networks" />
)}
</Step>
<Step n={2} title="Run this in Terminal on the other Mac">
<Step n={3} title="Run this in Terminal on the other Mac">
{offer && !create.isError ? (
<div className={cn("space-y-2.5 transition-opacity", create.isPending && "opacity-40")}>
{offer.command ? (
{command ? (
<Tabs value={source} onValueChange={(v) => setSource(v as Source)} className="gap-2.5">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="local">Install from {THIS_COMPUTER_INLINE}</TabsTrigger>
<TabsTrigger value="website">Install from godmode.codext.de</TabsTrigger>
</TabsList>
<TabsContent value="local" className="space-y-2.5">
<CommandBlock text={offer.command} title="Terminal on the other Mac" />
<p>It downloads Godmode from this computer, installs it as a runner and pairs it. The command works once.</p>
<CommandBlock text={command} title="Terminal on the other Mac" />
<p>
It downloads Godmode from this computer
{route?.network === "tailscale" ? " through Tailscale" : ""}, installs it as a runner and pairs it. The command works once.
</p>
</TabsContent>
<TabsContent value="website" className="space-y-2.5">
<WebsiteCommand command={offer.websiteCommand} />
Expand Down Expand Up @@ -141,7 +169,7 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
<Skeleton className="h-36 rounded-lg" aria-label="Making the install command" />
)}
</Step>
<Step n={3} title="It pairs by itself">
<Step n={4} title="It pairs by itself">
The runner appears here when the command finishes — usually under a minute. The first time, it also installs Claude Code and a browser.
</Step>
</ol>
Expand All @@ -151,12 +179,13 @@ export function AddRunnerDialog({ open, onOpenChange }: { open: boolean; onOpenC
<MonitorSmartphone className="size-4 shrink-0 text-foreground" />
<span className="min-w-0">
Waiting for the runner
{offer.urls[0] ? (
{waitingOn ? (
<>
{" "}
on <span className="font-mono text-[11px] break-all text-foreground">{offer.urls[0].replace(/^https?:\/\//, "")}</span>
on <span className="font-mono text-[11px] break-all text-foreground">{waitingOn.replace(/^https?:\/\//, "")}</span>
</>
) : null}
{route?.network === "tailscale" ? " through Tailscale" : null}
</span>
<span className="ml-auto flex shrink-0 gap-1" aria-hidden>
{[0, 1, 2].map((i) => (
Expand Down
190 changes: 190 additions & 0 deletions apps/desktop/src/components/runners/network-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useId } from "react";
import { Box, ExternalLink, RefreshCw, Wifi } from "lucide-react";
import type { RunnerNetwork, RunnerOfferRoute, TailscaleStatus } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { TAILSCALE_DOWNLOAD } from "@/components/settings/pair-phone-dialog";
import { openExternal } from "@/lib/desktop";
import { cn } from "@/lib/utils";

const STORAGE_KEY = "godmode-runner-network";

const TITLE: Record<RunnerNetwork, string> = { lan: "Same network", vm: "Virtual machine", tailscale: "Tailscale" };

/** The network the human picked last time; the next offer starts there. */
export function preferredNetwork(): RunnerNetwork | null {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === "lan" || v === "vm" || v === "tailscale" ? v : null;
} catch {
return null;
}
}

function rememberNetwork(network: RunnerNetwork) {
try {
localStorage.setItem(STORAGE_KEY, network);
} catch {
/* private mode */
}
}

/** The route to show: the one picked, else one on the network picked last time, else the best. */
export function pickRoute(routes: RunnerOfferRoute[], address: string | null): RunnerOfferRoute | null {
const network = preferredNetwork();
return routes.find((r) => r.address === address) ?? routes.find((r) => r.network === network) ?? routes[0] ?? null;
}

/** A Tailscale address (100.64.0.0/10) or MagicDNS name. */
export function isTailscaleHost(host: string | null | undefined): boolean {
if (!host) return false;
if (/\.ts\.net\.?$/i.test(host)) return true;
const [a, b] = host.split(".").map(Number);
return a === 100 && b! >= 64 && b! <= 127;
}

/** Tailscale's mark: nine dots, the bottom-right ones faded. */
export function TailscaleMark({ className }: { className?: string }) {
const dots = [
[0, 0, 0.35],
[1, 0, 0.35],
[2, 0, 0.35],
[0, 1, 1],
[1, 1, 1],
[2, 1, 1],
[0, 2, 0.35],
[1, 2, 1],
[2, 2, 0.35],
] as const;
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
{dots.map(([x, y, o]) => (
<circle key={`${x}${y}`} cx={4 + x * 8} cy={4 + y * 8} r={2.6} opacity={o} />
))}
</svg>
);
}

function NetworkIcon({ network, className }: { network: RunnerNetwork; className?: string }) {
if (network === "tailscale") return <TailscaleMark className={className} />;
const Icon = network === "vm" ? Box : Wifi;
return <Icon className={className} />;
}

function hint(route: RunnerOfferRoute): string {
if (route.network === "tailscale") return route.detail ?? "From anywhere, through your tailnet";
if (route.network === "vm") return `A Mac running in a VM on this one${route.detail ? ` · ${route.detail}` : ""}`;
return `Wi-Fi or Ethernet${route.detail ? ` · ${route.detail}` : ""}`;
}

/**
* How the other Mac reaches this one: each of this computer's addresses, Tailscale with its MagicDNS name. When
* Tailscale isn't connected here, it says why and how to get it.
*/
export function NetworkPicker({
routes,
tailscale,
value,
onChange,
onRefresh,
refreshing,
}: {
routes: RunnerOfferRoute[];
tailscale: TailscaleStatus;
value: RunnerOfferRoute | null;
onChange: (route: RunnerOfferRoute) => void;
onRefresh: () => void;
refreshing: boolean;
}) {
const hasTailscale = routes.some((r) => r.network === "tailscale");
return (
<div className="space-y-2">
{routes.length > 0 && (
<RadioGroup
value={value?.address ?? ""}
onValueChange={(address) => {
const route = routes.find((r) => r.address === address);
if (!route) return;
rememberNetwork(route.network);
onChange(route);
}}
aria-label="Network"
className="gap-1.5"
>
{routes.map((route) => (
<RouteOption key={route.address} route={route} checked={value?.address === route.address} />
))}
</RadioGroup>
)}

{!hasTailscale && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-dashed px-3 py-2.5">
<OptionTile network="tailscale" muted />
<div className="min-w-0 flex-1 basis-56">
<p className="text-sm font-medium text-foreground">Tailscale</p>
<p className="text-xs text-muted-foreground">{tailscale.running ? "Tailscale has no address on this Mac yet." : tailscale.detail}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!tailscale.installed && (
<Button variant="outline" size="xs" className="text-foreground" onClick={() => void openExternal(TAILSCALE_DOWNLOAD)}>
<ExternalLink /> Get Tailscale
</Button>
)}
<Button variant="ghost" size="xs" className="text-muted-foreground" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn(refreshing && "animate-spin")} /> Check again
</Button>
</div>
</div>
)}

{value?.network === "tailscale" && (
<p className="pt-0.5 text-xs leading-relaxed text-muted-foreground">
The other Mac needs Tailscale too, signed in to the same account
{tailscale.tailnet ? (
<>
{" "}
(<span className="text-foreground">{tailscale.tailnet}</span>)
</>
) : null}
. Then it reaches this Mac from anywhere, not only at home.
</p>
)}
</div>
);
}

function OptionTile({ network, muted, checked }: { network: RunnerNetwork; muted?: boolean; checked?: boolean }) {
return (
<span
className={cn(
"grid size-8 shrink-0 place-items-center rounded-md bg-secondary ring-1 ring-border ring-inset transition-colors",
muted ? "text-muted-foreground/60" : "text-foreground",
checked && "bg-card",
)}
>
<NetworkIcon network={network} className="size-4" />
</span>
);
}

function RouteOption({ route, checked }: { route: RunnerOfferRoute; checked: boolean }) {
const id = useId();
return (
<Label
htmlFor={id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border bg-card px-3 py-2.5 font-normal transition hover:border-foreground/15",
checked && "border-foreground/35 bg-paper-2 ring-1 ring-foreground/10 hover:border-foreground/35",
)}
>
<OptionTile network={route.network} checked={checked} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{TITLE[route.network]}</span>
<span className="block truncate text-xs text-muted-foreground">{hint(route)}</span>
</span>
<span className="shrink-0 font-mono text-[11px] text-foreground/80 tabular-nums">{route.address}</span>
<RadioGroupItem id={id} value={route.address} />
</Label>
);
}
6 changes: 6 additions & 0 deletions apps/desktop/src/components/runners/runner-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { LiveDot } from "@/components/aicss/Motion";
import { CopyButton } from "@/components/chat/copy-button";
import { cn } from "@/lib/utils";
import { TailscaleMark, isTailscaleHost } from "./network-picker";
import { RunnerHealthPanel, useRunnerHealth } from "./runner-health";
import {
CHECK_COLOR,
Expand Down Expand Up @@ -120,6 +121,11 @@ export function RunnerCard({
<span className={cn("truncate font-mono", runner.address ? "text-foreground/80" : "text-muted-foreground")} title={runner.address ? `Connected through ${endpoint}` : `Godmode tries ${runner.addresses.join(", ")}`}>
{endpoint}
</span>
{isTailscaleHost(runner.address ?? runner.addresses[0]) && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-[5px] border px-1.5 py-px text-[10px] font-medium text-muted-foreground">
<TailscaleMark className="size-2.5" /> Tailscale
</span>
)}
<CopyButton text={endpoint} label="Copy address" className="size-5" />
</p>
)}
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/components/runners/runner-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { InlineCode } from "@/components/onboarding/doctor-checklist";
import { TailscaleMark, isTailscaleHost } from "./network-picker";
import type { RunnerActions } from "./use-runner-actions";

const MAX_NAME = 60;
Expand Down Expand Up @@ -87,6 +88,8 @@ export function RunnerAddressesDialog({ runner, onClose, actions }: { runner: Re
if (portValid && portNumber !== runner.port) patch.port = portNumber;
const changed = Object.keys(patch).length > 0;
const saving = actions.isBusy("update", runner.id);
const tailnet = list.filter(isTailscaleHost);
const canPreferTailscale = tailnet.length > 0 && !isTailscaleHost(list[0]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!changed || list.length === 0 || !portValid || saving) return;
Expand Down Expand Up @@ -128,6 +131,23 @@ export function RunnerAddressesDialog({ runner, onClose, actions }: { runner: Re
"One per line: an IP address or a name."
)}
</p>
{canPreferTailscale && (
<div className="flex items-center gap-2.5 rounded-lg border bg-paper-2 px-3 py-2 text-xs text-muted-foreground">
<TailscaleMark className="size-3.5 shrink-0 text-foreground" />
<span className="min-w-0 flex-1">
Away from home? Its Tailscale address <span className="font-mono text-foreground/85">{tailnet[0]}</span> works from anywhere.
</span>
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0 text-foreground"
onClick={() => setAddresses([...tailnet, ...list.filter((a) => !isTailscaleHost(a))].join("\n"))}
>
Try it first
</Button>
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="runner-port">Port</Label>
Expand Down
Loading
Loading