Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ed122b8
Device groups: shared types for stand-in, browser sign-ins and phone …
danielehrhardt Oct 20, 2026
777053e
Browser sign-ins: never copy sessions that bind to one computer
danielehrhardt Oct 20, 2026
1e59585
Device groups: shared data on every computer, pulled both ways
danielehrhardt Oct 20, 2026
b28854b
Merge branch 'fix/dg-browser-signins' into fix/device-groups-v2
danielehrhardt Oct 20, 2026
ba592bb
Device groups UI: calm device popover, stand-in and sign-in settings,…
danielehrhardt Oct 20, 2026
fef4fcd
Full Disk Access: a cached answer that never waits, and sent over the…
danielehrhardt Oct 20, 2026
71bed20
macOS prompts: tell the human, keep the watchdog off, warn agents
danielehrhardt Oct 20, 2026
730346c
Phone app: continue on another computer of the device group
danielehrhardt Oct 20, 2026
fcfbaf6
Phone app: only the paired computer's refusal unpairs the phone
danielehrhardt Oct 20, 2026
c603e85
Device groups: secondaries keep every feature and stand in while the …
danielehrhardt Oct 20, 2026
c89ef62
Merge branch 'fix/dg-devices-ui' into fix/device-groups-v2
danielehrhardt Oct 20, 2026
d66125b
Merge branch 'fix/dg-phone-peers' into fix/device-groups-v2
danielehrhardt Oct 20, 2026
e6543cf
Device groups: hold a starting primary until the group settled, docs
danielehrhardt Oct 20, 2026
f6d7258
Device groups: unsafe folder names never land from another computer
danielehrhardt Oct 20, 2026
a27af3f
macOS prompts: hold only silent runs in a step, keep reading after er…
danielehrhardt Oct 20, 2026
8d1d41b
Device groups: hand back from the primary's side, quiet hand-off butt…
danielehrhardt Oct 20, 2026
c325556
Device groups: a copy from another computer of the group never delete…
danielehrhardt Oct 20, 2026
4f2e687
Merge branch 'fix/dg-mac-prompts' into fix/device-groups-v2
danielehrhardt Oct 20, 2026
b816f1b
Device groups: classify the new routes for the cloud, watchdog test o…
danielehrhardt Oct 20, 2026
0358ade
Device groups review: keys from the schema, no replica in backups, du…
danielehrhardt Oct 20, 2026
15d9d74
Device groups review: hand-back and split-brain safety, one ticket pe…
danielehrhardt Oct 20, 2026
8a68822
Device groups review round 2: deferred losers, take-over after a retu…
danielehrhardt Oct 20, 2026
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
126 changes: 103 additions & 23 deletions apps/desktop/src/components/devices/device-parts.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format, isToday } from "date-fns";
import { ArrowRightFromLine, ChevronDown, Laptop, Monitor } from "lucide-react";
import { toast } from "sonner";
import type { DeviceGroup, GroupDevice } from "@godmode/shared";
import type { DeviceGroup, GroupDevice, RunnerState } from "@godmode/shared";
import { RUNNER_STATE_LABEL, RunnerStateDot, THIS_COMPUTER, THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts";
import { timeAgo } from "@/components/ssh/ssh-parts";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand All @@ -13,10 +13,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { useNow } from "@/components/vault/use-now";
import { toastApiError } from "@/components/vault/vault-utils";
import { api } from "@/lib/api";
import { useDeviceGroup } from "@/lib/hooks";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";

/** Laptops by their name, everything else (a Mac mini, an iMac, a Studio) as a desktop. */
export function DeviceIcon({ device, className }: { device: Pick<GroupDevice, "name" | "role">; className?: string }) {
Expand All @@ -35,16 +37,62 @@ export function deviceWorkLine(device: Pick<GroupDevice, "activeRuns" | "convers
return device.activeRuns > 0 ? `${device.activeRuns} working · ${chats}` : chats;
}

/** "Online · 12 ms", "Offline · seen 3 h ago" */
export function deviceStateLine(device: GroupDevice): string {
if (device.self) return "Online";
if (device.state === "online") return device.latencyMs !== null ? `Online · ${device.latencyMs} ms` : "Online";
if (device.state === "offline" && device.lastSeenAt) return `Offline · seen ${timeAgo(device.lastSeenAt)}`;
return RUNNER_STATE_LABEL[device.state];
/** A link slower than this is worth a word; below it the number means nothing to anyone. */
const SLOW_MS = 300;

/** "14:02" today, "12 Oct, 14:02" before. */
export function clockTime(iso: string): string {
const d = new Date(iso);
return format(d, isToday(d) ? "HH:mm" : "d MMM, HH:mm");
}

/** "1:40" */
export function countdown(ms: number): string {
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}

/** Ticks every second while a stand-in counts down, rarely otherwise ("Away since" doesn't change). */
export function useGroupClock(group: DeviceGroup | undefined): number {
return useNow(group?.role === "secondary" && group.standIn.state === "waiting" ? 1000 : 30_000);
}

export function DeviceDot({ device, className }: { device: GroupDevice; className?: string }) {
return <RunnerStateDot state={device.self ? "online" : device.state} className={className} />;
/** Whether it stands in for the primary right now. */
export function isStandingIn(device: GroupDevice, group: DeviceGroup | undefined): boolean {
return device.standingIn || (device.self && group?.role === "secondary" && group.standIn.state === "active");
}

/** The connection as this computer sees it: on a secondary the primary is what `primaryOnline` says. */
export function deviceState(device: GroupDevice, group: DeviceGroup | undefined): RunnerState {
if (device.self) return "online";
if (group?.role === "secondary" && device.role === "primary") return group.primaryOnline ? "online" : device.state === "connecting" ? "connecting" : "offline";
return device.state;
}

/** "Online", "Online · slow connection", "Away since 14:02", "Takes over in 1:40", "Stands in since 14:04". */
export function deviceStateLine(device: GroupDevice, group?: DeviceGroup, now = Date.now()): string {
if (isStandingIn(device, group)) return device.self && group?.standIn.since ? `Stands in since ${clockTime(group.standIn.since)}` : "Stands in";
if (device.self) {
const standIn = group?.role === "secondary" ? group.standIn : null;
if (standIn?.state === "waiting" && standIn.takesOverAt) {
const left = new Date(standIn.takesOverAt).getTime() - now;
return left > 0 ? `Takes over in ${countdown(left)}` : "Taking over…";
}
return "Online";
}
const state = deviceState(device, group);
if (state === "online") return device.latencyMs !== null && device.latencyMs > SLOW_MS ? "Online · slow connection" : "Online";
if (state === "offline") return device.lastSeenAt ? `Away since ${clockTime(device.lastSeenAt)}` : "Away";
return RUNNER_STATE_LABEL[state];
}

export function DeviceDot({ device, group, className }: { device: GroupDevice; group?: DeviceGroup; className?: string }) {
return <RunnerStateDot state={deviceState(device, group)} className={className} />;
}

/** On a Mac without Full Disk Access a command that opens Desktop, Documents or Downloads waits for a click there. */
export function needsFullDiskAccess(device: GroupDevice): boolean {
return device.fullDiskAccess === false;
}

/** Where this computer's running work can go: the primary from a secondary, the online secondaries from the primary. */
Expand Down Expand Up @@ -80,45 +128,77 @@ export function useHandoff() {
return { group, self, targets, working: self?.activeRuns ?? 0, handoff: run.mutate, pending: run.isPending };
}

/** "Hand off to Mac mini", or a menu of computers when more than one could take it. */
function targetLine(d: GroupDevice): string {
if (d.activeRuns > 0) return `${d.activeRuns} working`;
return d.latencyMs !== null && d.latencyMs > SLOW_MS ? "Slow connection" : "";
}

/**
* "Hand off to Mac mini", or a menu of computers when more than one could take it. `stacked`: the action on the first
* line and where the work goes on the second, so a long computer name never squeezes the label.
*/
export function HandoffButton({
size = "default",
variant = "default",
className,
label = "Hand off to",
stacked = false,
}: {
size?: "default" | "sm";
variant?: "default" | "outline";
className?: string;
label?: string;
stacked?: boolean;
}) {
const { group, targets, handoff, pending } = useHandoff();
const { group, targets, working, handoff, pending } = useHandoff();
const icon = pending ? <Spinner /> : <ArrowRightFromLine />;
if (targets.length <= 1) {
const to = targets[0];
const to = targets.length === 1 ? targets[0] : null;
const unavailable = group?.role === "secondary" ? `${group.primaryName ?? "The primary"} is away` : "No other computer is online";
const menu = targets.length > 1;
// Nowhere to go: never the loud style, whatever is running.
const look = targets.length ? variant : "outline";

const content = stacked ? (
<>
{icon}
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] leading-5 font-medium">
{pending ? "Handing off…" : working > 0 ? `Hand off ${working === 1 ? "the running chat" : `${working} running chats`}` : "Hand off running work"}
</span>
<span className="block truncate text-[11.5px] leading-4 font-normal opacity-70">{to ? `to ${to.name}` : menu ? "Pick a computer" : unavailable}</span>
</span>
{menu && !pending && <ChevronDown className="opacity-60" />}
</>
) : (
<>
{icon}
<span className="truncate">{pending ? "Handing off…" : to ? `${label} ${to.name}` : menu ? `${label}…` : unavailable}</span>
{menu && !pending && <ChevronDown className="opacity-60" />}
</>
);
const cls = cn(stacked && "h-auto min-h-10 w-full min-w-0 justify-start gap-2.5 px-3 py-1 has-[>svg]:px-3", className);

if (!menu) {
return (
<Button size={size} variant={variant} className={className} disabled={!to || pending} onClick={() => to && handoff(to)}>
{icon}
{pending ? "Handing off…" : to ? `${label} ${to.name}` : group?.role === "secondary" ? `${group.primaryName ?? "The primary"} is offline` : "No other computer online"}
<Button size={size} variant={look} className={cls} disabled={!to || pending} onClick={() => to && handoff(to)}>
{content}
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={size} variant={variant} className={className} disabled={pending}>
{icon}
{pending ? "Handing off…" : `${label}…`}
{!pending && <ChevronDown className="opacity-60" />}
<Button size={size} variant={look} className={cls} disabled={pending}>
{content}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuContent align="start" className={cn("min-w-56", stacked && "w-(--radix-dropdown-menu-trigger-width)")}>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Continue the running work on</DropdownMenuLabel>
{targets.map((d) => (
<DropdownMenuItem key={d.id ?? "primary"} onSelect={() => handoff(d)}>
<DeviceIcon device={d} />
<span className="min-w-0 flex-1 truncate">{d.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">{d.activeRuns > 0 ? `${d.activeRuns} working` : d.latencyMs !== null ? `${d.latencyMs} ms` : ""}</span>
<span className="text-xs text-muted-foreground tabular-nums">{targetLine(d)}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
Expand Down
Loading
Loading