diff --git a/apps/desktop/src/components/devices/device-parts.tsx b/apps/desktop/src/components/devices/device-parts.tsx index 4574c9a5..1ba6fb1c 100644 --- a/apps/desktop/src/components/devices/device-parts.tsx +++ b/apps/desktop/src/components/devices/device-parts.tsx @@ -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, @@ -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; className?: string }) { @@ -35,16 +37,62 @@ export function deviceWorkLine(device: Pick 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 ; +/** 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 ; +} + +/** 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. */ @@ -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 ? : ; - 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} + + + {pending ? "Handing off…" : working > 0 ? `Hand off ${working === 1 ? "the running chat" : `${working} running chats`}` : "Hand off running work"} + + {to ? `to ${to.name}` : menu ? "Pick a computer" : unavailable} + + {menu && !pending && } + + ) : ( + <> + {icon} + {pending ? "Handing off…" : to ? `${label} ${to.name}` : menu ? `${label}…` : unavailable} + {menu && !pending && } + + ); + 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 ( - ); } return ( - - + Continue the running work on {targets.map((d) => ( handoff(d)}> {d.name} - {d.activeRuns > 0 ? `${d.activeRuns} working` : d.latencyMs !== null ? `${d.latencyMs} ms` : ""} + {targetLine(d)} ))} diff --git a/apps/desktop/src/components/layout/device-status.tsx b/apps/desktop/src/components/layout/device-status.tsx index 27e20f43..08d63584 100644 --- a/apps/desktop/src/components/layout/device-status.tsx +++ b/apps/desktop/src/components/layout/device-status.tsx @@ -1,10 +1,23 @@ import { useEffect, useState } from "react"; -import { Link, useLocation } from "react-router"; +import { Link, useLocation, useNavigate } from "react-router"; import { AnimatePresence, motion } from "motion/react"; -import { ArrowRight, Laptop, WifiOff } from "lucide-react"; -import type { DeviceGroup, RunnerState } from "@godmode/shared"; -import { DeviceDot, DeviceIcon, deviceStateLine, HandoffButton, useHandoff } from "@/components/devices/device-parts"; -import { THIS_COMPUTER, THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts"; +import { ArrowRight, Laptop, LaptopMinimalCheck, Settings2, Timer, TriangleAlert, WifiOff } from "lucide-react"; +import type { DeviceGroup, GroupDevice } from "@godmode/shared"; +import { + clockTime, + countdown, + DeviceDot, + DeviceIcon, + deviceState, + deviceStateLine, + HandoffButton, + isStandingIn, + needsFullDiskAccess, + useGroupClock, + useHandoff, +} from "@/components/devices/device-parts"; +import { THIS_COMPUTER } from "@/components/runners/runner-parts"; +import { useNow } from "@/components/vault/use-now"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useSidebar } from "@/components/ui/sidebar"; @@ -24,12 +37,30 @@ function useSteady(on: boolean, delay: number): boolean { return on && steady; } -/** On a secondary while its primary can't be reached: what the app shows now, and what comes back with it. */ +/** Ticks for the eye; a screen reader hears the time once instead of every second (the banner is a live region). */ +function TakesOverIn({ at }: { at: string }) { + const now = useNow(1000); + const left = new Date(at).getTime() - now; + return ( + <> + + {left > 0 ? `in ${countdown(left)}` : "in a moment"} + + at {clockTime(at)} + + ); +} + +/** On a secondary while its primary is away: this computer keeps everything working, and soon stands in for it. */ export function PrimaryOfflineBanner() { const { data: group } = useDeviceGroup(); const { pathname } = useLocation(); const shown = useSteady(group?.role === "secondary" && !group.primaryOnline, 2500); const name = group?.primaryName ?? "Your primary"; + const standIn = group?.standIn; + const waiting = standIn?.state === "waiting" && !!standIn.takesOverAt; + const active = standIn?.state === "active"; + const Icon = active ? LaptopMinimalCheck : waiting ? Timer : WifiOff; return ( {shown && ( @@ -43,13 +74,29 @@ export function PrimaryOfflineBanner() { >
- +

- {name} is offline - - {" "} - — you see {THIS_COMPUTER_INLINE}'s own chats. Agents, the board and automations come back when it's online. - + {active ? ( + <> + + {THIS_COMPUTER} stands in for {name}. + + Automations, the board and your phone run here; changes go back when it's back. + + ) : waiting ? ( + <> + {name} is away. + + {" "} + {THIS_COMPUTER} takes over — everything keeps working. + + + ) : ( + <> + {name} is away. + Everything keeps working here; automations and messages wait until it's back. + + )}

{!pathname.startsWith("/settings/devices") && ( @@ -121,43 +174,92 @@ export function DeviceIndicator() { {railed ? ( {trigger} - {label} + {hint} ) : ( trigger )} - -
-

Device group

-

{primaryName} is the primary.

-
-
    - {group.devices.map((d) => ( -
  • - -
    -

    - {d.name} - {d.self && {THIS_COMPUTER}} -

    -

    - - {deviceStateLine(d)} -

    -
    - {d.activeRuns > 0 && {d.activeRuns} working} -
  • - ))} -
-
- 0 ? "default" : "outline"} className="w-full" /> - -
+ + 0} onClose={() => setOpen(false)} /> ); } + +function GroupPopover({ group, working, canHandOff, onClose }: { group: DeviceGroup; working: number; canHandOff: boolean; onClose: () => void }) { + const now = useGroupClock(group); + const standingIn = group.role === "secondary" && group.standIn.state === "active"; + const subtitle = + group.role === "primary" ? `${THIS_COMPUTER} is the primary.` : standingIn ? `${THIS_COMPUTER} stands in for ${group.primaryName}.` : `${group.primaryName} is the primary.`; + return ( + <> +
+

Device group

+

{subtitle}

+
+
    + {group.devices.map((d) => ( + + ))} +
+ {canHandOff && ( +
+ 0 ? "[&>svg]:text-brand" : undefined} /> +
+ )} + + + Manage devices + + + + ); +} + +function PopoverDevice({ device: d, group, now, onClose }: { device: GroupDevice; group: DeviceGroup; now: number; onClose: () => void }) { + const navigate = useNavigate(); + const online = deviceState(d, group) === "online"; + const standing = isStandingIn(d, group); + const roles = [d.self && THIS_COMPUTER, d.role === "primary" && "Primary"].filter(Boolean).join(" · "); + const work = d.activeRuns > 0 ? `${d.activeRuns} working` : null; + return ( +
  • + +
    +

    + {d.name} + {roles && {roles}} +

    +

    + + + {deviceStateLine(d, group, now)} + {work && ` · ${work}`} + +

    + {(needsFullDiskAccess(d) || d.runnerTwin) && ( +
    + {needsFullDiskAccess(d) && ( + + )} + {d.runnerTwin && Also paired as a runner} +
    + )} +
    +
  • + ); +} diff --git a/apps/desktop/src/components/runners/move-chat.tsx b/apps/desktop/src/components/runners/move-chat.tsx index 51db4ae4..a6b149ae 100644 --- a/apps/desktop/src/components/runners/move-chat.tsx +++ b/apps/desktop/src/components/runners/move-chat.tsx @@ -17,10 +17,10 @@ import { Spinner } from "@/components/ui/spinner"; import { followupWhen } from "@/components/chat/followup"; import { toastApiError } from "@/components/vault/vault-utils"; import { api } from "@/lib/api"; -import type { Place } from "@/lib/places"; +import { placeName, type Place } from "@/lib/places"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; -import { RunnerStateDot } from "./runner-parts"; +import { RunnerStateDot, RunnerTag, THIS_COMPUTER_INLINE } from "./runner-parts"; /** Why a runner's chat can't move right now (its run works or stands still there), or null. */ export function moveBlocker(chat: { running: boolean; paused: boolean }): string | null { @@ -40,6 +40,7 @@ function PlaceTag({ place, active }: { place: Place | null; active?: boolean }) > {place?.name ?? "Another computer"} + {place?.twinOf && } {place && !active && } ); @@ -78,9 +79,9 @@ export function MoveChatDialog({ const shown = useRef(from); if (open) shown.current = from; const origin = shown.current; - const name = origin?.name ?? "The other computer"; + const name = origin ? placeName(origin) : "The other computer"; const away = !!origin && origin.state !== "online"; - const dest = to?.here ? "this Mac" : (to?.name ?? "the other computer"); + const dest = to?.here ? THIS_COMPUTER_INLINE : to ? placeName(to) : "the other computer"; const move = useMutation({ mutationFn: () => api.conversations.move(conversation.id, { to: to!.runnerId, force: working || undefined }), onSuccess: (moved) => { @@ -129,7 +130,7 @@ export function MoveChatDialog({ )} > {t.here ? : } - {t.name} + {placeName(t)} ))}
    diff --git a/apps/desktop/src/components/runners/runner-chip.tsx b/apps/desktop/src/components/runners/runner-chip.tsx index b2277f51..4d0b3bbc 100644 --- a/apps/desktop/src/components/runners/runner-chip.tsx +++ b/apps/desktop/src/components/runners/runner-chip.tsx @@ -11,10 +11,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { timeAgo } from "@/components/ssh/ssh-parts"; import { toastApiError } from "@/components/vault/vault-utils"; import { api } from "@/lib/api"; -import { usePlaces, type Place } from "@/lib/places"; +import { isRunnerPlace, placeName, usePlaces, type Place } from "@/lib/places"; import { upsertRunner } from "@/lib/realtime"; import { cn } from "@/lib/utils"; -import { RUNNER_STATE_LABEL, RunnerStateDot, THIS_COMPUTER, THIS_COMPUTER_INLINE } from "./runner-parts"; +import { RUNNER_STATE_LABEL, RunnerStateDot, RunnerTag, THIS_COMPUTER, THIS_COMPUTER_INLINE } from "./runner-parts"; /** Why a runner can or can't take a chat right now, in a few words. */ function runnerLine(runner: RemoteRunner): string { @@ -35,7 +35,7 @@ function runnerLine(runner: RemoteRunner): string { function placeLine(place: Place): string { if (place.here) return "Works here, with your folders, screen and VMs"; - if (place.runner) return runnerLine(place.runner); + if (place.runner) return place.twinOf ? `${runnerLine(place.runner)} · older setup` : runnerLine(place.runner); return place.state === "online" ? "Online · the primary of your group" : "Offline"; } @@ -74,11 +74,15 @@ export function RunnerChip({ value, onChange }: { value: string | null; onChange {current ? ( @@ -98,7 +102,7 @@ export function RunnerChip({ value, onChange }: { value: string | null; onChange )} - {current ? `This chat will work on ${current.name}` : "Work on another computer"} + {current ? `This chat will work on ${placeName(current)}` : "Work on another computer"}
    @@ -193,7 +197,7 @@ function PlaceItem({ place, selected, onSelect }: { place: Place; selected: bool /** The line under a new chat's composer once another computer is picked: what happens before the work starts there. */ export function RunnerNote({ place }: { place: Place | null }) { - const name = place?.name ?? "a runner"; + const name = place ? placeName(place) : "a runner"; if (place && place.state !== "online") { return (

    @@ -233,17 +237,19 @@ export function RunnerPill({ place, targets, onMove }: { place: Place | null; ta const { inGroup } = usePlaces(); const [open, setOpen] = useState(false); const name = place?.name ?? "a runner"; + const full = place ? placeName(place) : "a runner"; + const tagged = isRunnerPlace(place, inGroup); const tooltip = !place ? "This chat works on a runner" : place.here - ? "This chat works on this Mac" + ? `This chat works on ${THIS_COMPUTER_INLINE}` : place.state === "online" - ? `This chat works on ${place.name} — it keeps going while ${THIS_COMPUTER_INLINE} sleeps` + ? `This chat works on ${full} — it keeps going while ${THIS_COMPUTER_INLINE} sleeps` : place.state === "connecting" - ? `This chat works on ${place.name} — connecting…` + ? `This chat works on ${full} — connecting…` : place.state === "offline" - ? `This chat works on ${place.name}, which is offline` - : `This chat works on ${place.name}, which needs an update`; + ? `This chat works on ${full}, which is offline` + : `This chat works on ${full}, which needs an update`; const act = (fn: () => void) => { setOpen(false); fn(); @@ -255,7 +261,7 @@ export function RunnerPill({ place, targets, onMove }: { place: Place | null; ta @@ -278,7 +285,10 @@ export function RunnerPill({ place, targets, onMove }: { place: Place | null; ta {place?.here ? : } - {place?.name ?? "A runner"} + + {place?.name ?? "A runner"} + {tagged && } + {place && } {place ? placeLine(place) : "Removed"} @@ -295,7 +305,10 @@ export function RunnerPill({ place, targets, onMove }: { place: Place | null; ta - {t.name} + + {t.name} + {isRunnerPlace(t, inGroup) && } + Continue there with the whole conversation diff --git a/apps/desktop/src/components/runners/runner-parts.tsx b/apps/desktop/src/components/runners/runner-parts.tsx index 465af84f..fdeba8ad 100644 --- a/apps/desktop/src/components/runners/runner-parts.tsx +++ b/apps/desktop/src/components/runners/runner-parts.tsx @@ -43,6 +43,15 @@ export function RunnerStateDot({ state, className }: { state: RunnerState; class ); } +/** "Runner" next to a name, where a runner and a computer of the device group may be called the same. */ +export function RunnerTag({ className }: { className?: string }) { + return ( + + Runner + + ); +} + const STATE_BADGE: Record = { online: "border-brand/25 bg-brand-soft text-brand-strong", connecting: "border-border bg-secondary text-foreground", diff --git a/apps/desktop/src/components/settings/devices-section.tsx b/apps/desktop/src/components/settings/devices-section.tsx index 697c3737..04a8ecd2 100644 --- a/apps/desktop/src/components/settings/devices-section.tsx +++ b/apps/desktop/src/components/settings/devices-section.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Check, ClipboardPaste, Copy, LogOut, MessagesSquare, MoonStar, Network, Plus, RefreshCw, TriangleAlert, Unlink, X } from "lucide-react"; +import { Cable, Check, ClipboardPaste, Copy, KeyRound, LaptopMinimalCheck, LogOut, MessagesSquare, MonitorPause, MoonStar, Network, Plus, RefreshCw, Timer, TriangleAlert, Unlink, X } from "lucide-react"; import { toast } from "sonner"; -import { DEVICE_INVITE_PREFIX, parseDeviceInvite, RUNNER_CODE_PREFIX, type DeviceGroup, type GroupDevice } from "@godmode/shared"; +import { DEVICE_INVITE_PREFIX, parseDeviceInvite, RUNNER_CODE_PREFIX, type BrowserSignIns, type DeviceGroup, type DeviceGroupPatch, type GroupDevice } from "@godmode/shared"; import { AlertDialog, AlertDialogAction, @@ -15,7 +15,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; @@ -23,12 +23,26 @@ import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { copyText } from "@/components/chat/copy-button"; -import { DeviceDot, DeviceIcon, deviceStateLine, deviceWorkLine, HandoffButton, useHandoff } from "@/components/devices/device-parts"; +import { + clockTime, + countdown, + DeviceDot, + DeviceIcon, + deviceState, + deviceStateLine, + deviceWorkLine, + HandoffButton, + isStandingIn, + needsFullDiskAccess, + useGroupClock, + useHandoff, +} from "@/components/devices/device-parts"; +import { useMacPermissionsGuide } from "@/components/permissions/mac-permissions"; import { RunnerStateBadge, RunnerStateDot, THIS_COMPUTER, THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts"; import { useNow } from "@/components/vault/use-now"; import { toastApiError } from "@/components/vault/vault-utils"; import { api, errorMessage } from "@/lib/api"; -import { useDeviceGroup } from "@/lib/hooks"; +import { useDeviceGroup, useRunners } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; import { ExpiryRing, Step } from "./pair-phone-dialog"; @@ -100,8 +114,8 @@ function AloneView() {

    Device group

    One Godmode, on every Mac.

    - One Mac is the primary — keep it on (a Mac mini or a desktop). The others show everything it has, and chats can run on any of them and - move between them, also while they work. + Every Mac has your agents, logins and chats, and chats can move between them, also while they work. One is the primary — keep it on + (a Mac mini or a desktop); while it's away, another Mac stands in.

    -

    {secondary ? `Synced with ${group.primaryName}` : `${THIS_COMPUTER} is the primary`}

    - +

    {title}

    +

    - {secondary - ? `Agents, logins, settings, the board and automations come from ${group.primaryName}, which also runs the schedules. Chats can run here or on any other computer of the group.` - : `It keeps the agents, logins, settings, the board and automations for ${count === 1 ? "the group" : `all ${count} computers`}, and runs the schedules. Keep it on and awake.`} + {standing + ? `${group.primaryName} is away, so ${THIS_COMPUTER_INLINE} runs the automations, the task board and the phone app until it's back. Everything that changes here goes back to it then.` + : secondary + ? `${group.primaryName} runs the schedules. ${THIS_COMPUTER} has a copy of everything and keeps working while it's away. Chats can run here or on any other computer of the group.` + : `It runs the schedules for ${count === 1 ? "the group" : `all ${count} computers`}. Keep it on and awake${group.standInEnabled && count > 1 ? " — while it's away, another computer stands in" : ""}.`}

    - {secondary && primaryDevice && ( + {secondary && primaryDevice && group.primaryOnline && ( )}
    - {secondary && !group.primaryOnline && ( -
    - - {group.primaryError &&

    {group.primaryError}

    } -

    You see this Mac's own chats until it's back. Agents, the board and automations return with it.

    -
    + + ); +} + +/** The live side of standing in, on a secondary while its primary is away. */ +function StandInNow({ group, now }: { group: DeviceGroup; now: number }) { + const name = group.primaryName ?? "The primary"; + const { state, since, takesOverAt } = group.standIn; + const error = group.primaryError &&

    {group.primaryError}

    ; + if (state === "active") { + const n = group.pendingChanges; + return ( + } title={`${THIS_COMPUTER} stands in for ${name}`} className="border-brand/20 bg-brand-soft/50"> +

    + {since ? `Since ${clockTime(since)} the` : "The"} automations, the task board, messaging and the phone app run here.{" "} + {n > 0 ? `${n === 1 ? "1 change goes" : `${n} changes go`} back when ${name} is back.` : `What changes here goes back when ${name} is back.`} +

    +
    + ); + } + if (state === "waiting" && takesOverAt) { + const left = new Date(takesOverAt).getTime() - now; + return ( + } title={`${name} is away`}> + {error} +

    + {THIS_COMPUTER} takes over {left > 0 ? `in ${countdown(left)}` : "in a moment"}. Everything keeps working. +

    +
    + ); + } + const other = group.devices.find((d) => !d.self && d.standingIn); + return ( + + {error} +

    + {other + ? `${other.name} stands in for it. Everything keeps working here too.` + : "Everything keeps working here. Automations, messaging and the phone app wait until it's back."} +

    +
    + ); +} + +/** One PATCH /api/devices, shown at once and rolled back when it fails. */ +function useGroupPatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: DeviceGroupPatch) => api.devices.update(input), + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: qk.devices }); + const prev = qc.getQueryData(qk.devices); + if (prev) qc.setQueryData(qk.devices, { ...prev, ...input }); + return { prev }; + }, + onError: (e, _v, ctx) => { + if (ctx?.prev) qc.setQueryData(qk.devices, ctx.prev); + toastApiError(e, "Could not save", qc); + }, + onSuccess: (next) => { + qc.setQueryData(qk.devices, next); + toast.success("Saved", { id: "settings-saved", duration: 1200 }); + }, + }); +} + +const STAND_IN_MINUTES = [1, 2, 3, 5, 10, 15]; + +function StandInGroup({ group, now }: { group: DeviceGroup; now: number }) { + const patch = useGroupPatch(); + const primary = group.role === "primary"; + const subject = primary ? THIS_COMPUTER_INLINE : (group.primaryName ?? "the primary"); + const m = group.standInMinutes; + const minutes = STAND_IN_MINUTES.includes(m) ? STAND_IN_MINUTES : [...STAND_IN_MINUTES, m].sort((a, b) => a - b); + const elsewhere = primary ? "" : ` Change this on ${group.primaryName}.`; + + return ( + } description="Every computer has a copy of everything, so nothing stops when the primary sleeps or its lid closes. With Tailscale on each computer they also find each other on different networks, so only one of them does the work."> + {group.role === "secondary" && !group.primaryOnline && ( +
    +
    )} - + +
    + {group.standInEnabled && ( + + )} + patch.mutate({ standInEnabled: on })} /> +
    +
    +
    + ); +} + +const SIGN_INS: Record = { + own: { label: "Each computer signs in", line: "With your saved logins, 2FA codes and passkeys." }, + seed: { label: "Start from the primary's", line: "A computer new to a site copies the primary's sign-in." }, +}; + +function BrowserSignInsGroup({ group }: { group: DeviceGroup }) { + const patch = useGroupPatch(); + const primary = group.role === "primary"; + const value = group.browserSignIns; + const from = primary ? `${THIS_COMPUTER_INLINE}'s` : `${group.primaryName}'s`; + return ( + } description="How the other computers get signed in to websites."> + + + + ); } @@ -502,13 +679,129 @@ function HandoffCard({ group }: { group: DeviceGroup }) {

    - 0 ? "default" : "outline"} /> + 0 ? "default" : "outline"} /> ); } -function DeviceRow({ device, removable }: { device: GroupDevice; removable: boolean }) { +/** A note under a computer of the list: what is wrong or odd there, and the one thing to do about it. */ +function DeviceNote({ tone, icon, title, action, children }: { tone?: "warning"; icon: ReactNode; title: string; action: ReactNode; children: ReactNode }) { + return ( +
    + {icon} +
    +

    {title}

    +

    {children}

    +
    +
    {action}
    +
    + ); +} + +function FullDiskAccessNote({ device }: { device: GroupDevice }) { + const showGuide = useMacPermissionsGuide((s) => s.show); + const [open, setOpen] = useState(false); + return ( + <> + } + title="Needs Full Disk Access" + action={ + + } + > + {device.self + ? "Agents here stop at a macOS “allow access to Desktop?” question until you click Allow." + : `Agents on ${device.name} stop at a macOS “allow access to Desktop?” question until someone clicks Allow there.`} + + {!device.self && ( + + + + Allow Full Disk Access on {device.name} + Then agents there can open the Desktop, Documents and Downloads without macOS asking first. Do this on {device.name}: + +
      + + Apple menu → System Settings → Privacy & Security → Full Disk Access. + + + Not in the list? Click + and pick Godmode from Applications. macOS asks for the Mac's password. + + + The warning here goes away by itself. + +
    + + + +
    +
    + )} + + ); +} + +function RunnerTwinNote({ device, runnerId }: { device: GroupDevice; runnerId: string }) { + const qc = useQueryClient(); + const { data: runners } = useRunners(); + const [confirm, setConfirm] = useState(false); + const runner = runners?.find((r) => r.id === runnerId); + const name = device.self ? THIS_COMPUTER : device.name; + const there = device.self ? THIS_COMPUTER_INLINE : device.name; + const retire = useMutation({ + mutationFn: () => api.devices.retireRunner(runnerId), + onSuccess: (result) => { + const chats = result.moved === 0 ? "The runner had no chats." : `${result.moved === 1 ? "1 chat" : `${result.moved} chats`} moved to the app.`; + toast.success(`${name} uses only the app now`, { + description: result.uninstalled ? `${chats} The runner is switched off there.` : `${chats} Its runner still starts there: run “godmode runner uninstall” on ${there} to switch it off.`, + }); + void qc.invalidateQueries({ queryKey: qk.devices }); + void qc.invalidateQueries({ queryKey: qk.runners }); + void qc.invalidateQueries({ queryKey: qk.conversationsAll }); + }, + onError: (e) => toastApiError(e, `Couldn't switch off the runner on ${there}`, qc), + }); + const n = runner?.conversations ?? 0; + return ( + <> + } + title="Also paired as a runner" + action={ + + } + > + {name} also runs as a runner from an earlier setup. + + + + + Use only the app on {there}? + + The old runner on {there} is switched off and removed.{" "} + {n > 0 ? `Its ${n === 1 ? "chat moves" : `${n} chats move`} to the app there, with everything ${n === 1 ? "it" : "they"} did so far.` : "Its chats move to the app there."}{" "} + The app keeps working as it does now. + + + + Cancel + retire.mutate()}>Use only the app + + + + + ); +} + +function DeviceRow({ device, group, now, removable }: { device: GroupDevice; group: DeviceGroup; now: number; removable: boolean }) { const qc = useQueryClient(); const [confirm, setConfirm] = useState(false); const remove = useMutation({ @@ -520,7 +813,8 @@ function DeviceRow({ device, removable }: { device: GroupDevice; removable: bool }, onError: (e) => toastApiError(e, `Couldn't remove ${device.name}`, qc), }); - const state = device.self ? "online" : device.state; + const state = deviceState(device, group); + const fda = needsFullDiskAccess(device); return ( -
    -
    - -
    -
    -
    -

    {device.name}

    - {device.self && ( - {THIS_COMPUTER} - )} - +
    +
    +
    +
    -

    - - - {[deviceStateLine(device), state === "update_required" && device.version ? `v${device.version}` : null, deviceWorkLine(device)].filter(Boolean).join(" · ")} - -

    +
    +
    +

    {device.name}

    + {device.self && ( + {THIS_COMPUTER} + )} + +
    +

    + + + {deviceStateLine(device, group, now)} + {[state === "update_required" && device.version ? `v${device.version}` : null, deviceWorkLine(device)].filter(Boolean).map((part) => ` · ${part}`).join("")} + +

    +
    + {removable && ( + + + + + Remove from group + + )}
    - {removable && ( - - - - - Remove from group - + {(fda || device.runnerTwin) && ( +
    + {fda && } + {device.runnerTwin && } +
    )}
    @@ -593,42 +896,7 @@ function DeviceRow({ device, removable }: { device: GroupDevice; removable: bool const HERE = "here"; function RunOnGroup({ group }: { group: DeviceGroup }) { - const qc = useQueryClient(); - const update = useMutation({ - mutationFn: (defaultRunOn: string | null) => api.devices.update({ defaultRunOn }), - onMutate: async (defaultRunOn) => { - await qc.cancelQueries({ queryKey: qk.devices }); - const prev = qc.getQueryData(qk.devices); - if (prev) qc.setQueryData(qk.devices, { ...prev, defaultRunOn }); - return { prev }; - }, - onError: (e, _v, ctx) => { - if (ctx?.prev) qc.setQueryData(qk.devices, ctx.prev); - toastApiError(e, "Could not save", qc); - }, - onSuccess: (next) => { - qc.setQueryData(qk.devices, next); - toast.success("Saved", { id: "settings-saved", duration: 1200 }); - }, - }); - - const failover = useMutation({ - mutationFn: (input: { failover?: boolean; failoverMinutes?: number }) => api.devices.update(input), - onMutate: async (input) => { - await qc.cancelQueries({ queryKey: qk.devices }); - const prev = qc.getQueryData(qk.devices); - if (prev) qc.setQueryData(qk.devices, { ...prev, ...input }); - return { prev }; - }, - onError: (e, _v, ctx) => { - if (ctx?.prev) qc.setQueryData(qk.devices, ctx.prev); - toastApiError(e, "Could not save", qc); - }, - onSuccess: (next) => { - qc.setQueryData(qk.devices, next); - toast.success("Saved", { id: "settings-saved", duration: 1200 }); - }, - }); + const patch = useGroupPatch(); const value = group.defaultRunOn ?? HERE; const secondary = group.role === "secondary"; @@ -648,7 +916,7 @@ function RunOnGroup({ group }: { group: DeviceGroup }) { htmlFor="devices-run-on" description={options.length === 1 ? "Other computers show up here while they're online." : "You can pick another computer for each chat as you start it."} > - patch.mutate({ defaultRunOn: v === HERE ? null : v })}> @@ -679,7 +947,7 @@ function RunOnGroup({ group }: { group: DeviceGroup }) { >
    {group.failover && ( - patch.mutate({ failoverMinutes: Number(v) })}> @@ -692,7 +960,7 @@ function RunOnGroup({ group }: { group: DeviceGroup }) { )} - failover.mutate({ failover: on })} /> + patch.mutate({ failover: on })} />
    @@ -720,7 +988,7 @@ function LeaveGroup({ group }: { group: DeviceGroup }) { description={ primary ? "The other computers leave and keep the setup they have now. Chats that live on them stay there." - : `It stops showing ${group.primaryName}'s Godmode and keeps a copy of the setup it has now, and its own chats.` + : `It stops sharing with ${group.primaryName} and keeps a copy of the setup it has now, and its own chats.` } >