307 lines · 10.6 KB · TypeScript
RawDownload
import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";import { Check, CircleAlert } from "lucide-react";import { useState } from "react"; import { Button } from "~/components/ui/button";import { DialogClose } from "~/components/ui/dialog";import { WizardHeader, WizardPanel, WizardSteps, WizardFooter } from "~/components/ui/wizard";import { Spinner } from "~/components/ui/spinner";import { Switch } from "~/components/ui/switch";import { deviceEnvironment } from "~/state/device";import { useAtomCommand } from "~/state/use-atom-command";import { cn } from "~/lib/utils"; const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android"); export const deviceHubDescription =  "Enable this environment to open simulators and emulators, whether they run here or on a remote device host.";export const agentDeviceDescription =  "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically."; export function platformSetupStatus(state: DeviceServiceState, platform: DevicePlatform) {  const availability = state.hosts    .flatMap((host) => host.platforms)    .find((candidate) => candidate.platform === platform);  if (!availability?.available) {    return {      ready: false,      message: availability?.reason ?? `${platformName(platform)} support was not detected.`,    };  }  if (    state.hostStatus === "ready" &&    !state.devices.some((device) => device.platform === platform)  ) {    return {      ready: false,      message:        platform === "ios"          ? "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components."          : "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.",    };  }  return {    ready: true,    message:      platform === "ios"        ? "Xcode and iOS Simulator are available."        : "The Android SDK and Emulator are available.",  };} export function DeviceSetup(props: {  readonly environmentId: EnvironmentId;  readonly state: DeviceServiceState;  readonly onComplete?: () => void;}) {  const configure = useAtomCommand(deviceEnvironment.configure);  const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });  const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);  const [step, setStep] = useState(0);  const enabled = props.state.hostStatus !== "disabled";  const busy = props.state.hostStatus === "installing" || props.state.hostStatus === "starting";   const update = async (    kind: NonNullable<typeof pending>,    input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },  ) => {    setPending(kind);    try {      const result = await configure({ environmentId: props.environmentId, input });      if (kind === "complete" && result._tag === "Success") props.onComplete?.();    } finally {      setPending(null);    }  };   return (    <>      <WizardHeader        title="Set up devices"        description="Review what runs on this environment before using simulators and emulators."      >        <WizardSteps          steps={["Device hub", "Simulators", "Agent access"]}          currentStep={step}          onStepChange={setStep}          isStepDisabled={(requested) => busy || pending !== null || requested > step}        />      </WizardHeader>       <WizardPanel>        {step === 0 ? (          <section className="space-y-3 text-sm">            <h3 className="font-medium">Enable the device hub</h3>            <div className="flex items-start justify-between gap-4">              <p className="text-muted-foreground">{deviceHubDescription}</p>              <Switch                checked={enabled}                disabled={busy || pending !== null}                aria-label="Enable device hub"                onCheckedChange={(checked) =>                  void update("hub", {                    enabled: Boolean(checked),                    ...(checked ? {} : { agentAccessEnabled: false }),                  })                }              />            </div>            <DeviceHubSetupStatus              state={props.state}              pending={pending === "hub" || (busy && pending !== "agent")}            />          </section>        ) : null}         {step === 1 ? (          <section className="space-y-3 text-sm">            <h3 className="font-medium">Check simulator support</h3>            <DevicePlatformSetup              state={props.state}              checking={pending === "check"}              disabled={!enabled || busy || pending !== null}              onCheck={() => {                setPending("check");                void list({ environmentId: props.environmentId, input: {} }).finally(() =>                  setPending(null),                );              }}            />          </section>        ) : null}         {step === 2 ? (          <section className="space-y-3 text-sm">            <h3 className="font-medium">Allow agent control</h3>            <div className="flex items-start justify-between gap-4">              <p className="text-muted-foreground">{agentDeviceDescription}</p>              <Switch                checked={props.state.agentAccessEnabled}                disabled={!enabled || busy || pending !== null}                aria-label="Allow agents to control devices"                onCheckedChange={(checked) =>                  void update("agent", { agentAccessEnabled: Boolean(checked) })                }              />            </div>            <AgentDeviceSetupStatus state={props.state} pending={pending === "agent"} />            <p className="text-xs text-muted-foreground">              Leave this off to keep manual device controls without giving agents access.            </p>          </section>        ) : null}        {props.state.hostStatus === "failed" && props.state.hostStatusDetail ? (          <p role="alert" className="mt-3 text-xs text-destructive">            {props.state.hostStatusDetail}          </p>        ) : null}      </WizardPanel>       <WizardFooter>        {step === 0 ? (          <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>        ) : (          <Button            variant="outline"            disabled={busy || pending !== null}            onClick={() => setStep(step - 1)}          >            Back          </Button>        )}        {step < 2 ? (          <Button            disabled={props.state.hostStatus !== "ready" || pending !== null}            onClick={() => setStep(step + 1)}          >            Continue          </Button>        ) : (          <Button            disabled={props.state.hostStatus !== "ready" || pending !== null}            onClick={() => void update("complete", { onboardingCompleted: true })}          >            {pending === "complete" ? "Saving…" : "Done"}          </Button>        )}      </WizardFooter>    </>  );} export function DeviceHubSetupStatus({  state,  pending,  compact = false,}: {  readonly state: DeviceServiceState;  readonly pending: boolean;  readonly compact?: boolean;}) {  if (!pending && state.hostStatus !== "ready") return null;  return (    <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">      {pending ? <Spinner className="size-3" /> : <Check className="size-3 text-success" />}      {pending        ? state.hostStatus === "installing"          ? compact            ? "Installing…"            : "Installing device hub…"          : state.hostStatus === "starting"            ? compact              ? "Starting…"              : "Starting device hub…"            : compact              ? "Updating…"              : "Updating device hub…"        : "Device hub is ready."}    </p>  );} function DevicePlatformSetup(props: {  readonly state: DeviceServiceState;  readonly checking: boolean;  readonly disabled: boolean;  readonly onCheck: () => void;}) {  return (    <div className="space-y-3">      <PlatformStatus platform="iOS" status={platformSetupStatus(props.state, "ios")} />      <PlatformStatus platform="Android" status={platformSetupStatus(props.state, "android")} />      <p className="text-xs text-muted-foreground">        You can use either platform. Fixing a missing platform does not block the other one.      </p>      <Button size="compact" variant="outline" disabled={props.disabled} onClick={props.onCheck}>        {props.checking ? <Spinner className="size-3" /> : null}        {props.checking ? "Checking…" : "Check again"}      </Button>    </div>  );} export function AgentDeviceSetupStatus(props: {  readonly state: DeviceServiceState;  readonly pending: boolean;  readonly compact?: boolean;}) {  if (props.pending) {    const label =      props.state.hostStatus === "installing"        ? props.compact          ? "Installing…"          : "Installing agent tools…"        : props.state.hostStatus === "starting"          ? props.compact            ? "Starting…"            : "Starting agent tools…"          : props.compact            ? "Updating…"            : "Updating agent access…";    return (      <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">        <Spinner className="size-3" />        {label}      </p>    );  }  if (    props.state.agentAccessEnabled &&    props.state.hostStatus === "ready" &&    props.state.hosts.some((host) => host.agentDeviceInstalled)  ) {    return (      <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">        <Check className="size-3 text-success" />        Agent tools are ready.      </p>    );  }  return null;} export function PlatformStatus(props: {  readonly platform: string;  readonly status: { readonly ready: boolean; readonly message: string };  readonly compact?: boolean;}) {  const Icon = props.status.ready ? Check : CircleAlert;  return (    <div      className={cn("flex gap-2", !props.compact && "rounded-md border border-border/60 px-3 py-2")}    >      <Icon        className={cn(          "mt-0.5 size-4 shrink-0",          props.status.ready ? "text-success" : "text-muted-foreground",        )}      />      <div className={cn(props.compact && props.status.ready && "flex items-center gap-2")}>        <p className="font-medium">{props.platform}</p>        <p className="text-xs text-muted-foreground">          {props.compact && props.status.ready ? "Ready" : props.status.message}        </p>      </div>    </div>  );}