From 52435507938d4df4bc45b9ba39dac8eddd3c1596 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:47:57 -0700 Subject: [PATCH 1/5] fix(web): test device hosts across selected environments --- .../components/settings/DeviceHostEditor.tsx | 225 +++++++++++++++++ .../settings/DeviceHostsSettings.tsx | 235 +++++------------- .../deviceHostConnectionChecks.test.ts | 69 +++++ .../settings/deviceHostConnectionChecks.ts | 50 ++++ .../settings/useHostConnectionChecks.ts | 46 ++++ 5 files changed, 456 insertions(+), 169 deletions(-) create mode 100644 apps/web/src/components/settings/DeviceHostEditor.tsx create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.test.ts create mode 100644 apps/web/src/components/settings/deviceHostConnectionChecks.ts create mode 100644 apps/web/src/components/settings/useHostConnectionChecks.ts diff --git a/apps/web/src/components/settings/DeviceHostEditor.tsx b/apps/web/src/components/settings/DeviceHostEditor.tsx new file mode 100644 index 000000000000..7c04a473f2c2 --- /dev/null +++ b/apps/web/src/components/settings/DeviceHostEditor.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { CheckIcon, MonitorIcon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogPanel, + DialogFooter, +} from "../ui/dialog"; +import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; +import { deviceHostConnectionKey, type DeviceHostCheckTarget } from "./deviceHostConnectionChecks"; + +export function DeviceHostEditor({ + host, + isNew, + targets, + busy, + onSave, + onClose, +}: { + host: SshDeviceHostConfig; + isNew: boolean; + targets: ReadonlyArray; + busy: boolean; + onSave: (host: SshDeviceHostConfig) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(host); + const { checks, testConnection } = useHostConnectionChecks(targets); + const results = checks[deviceHostConnectionKey(draft)]; + const checking = Object.values(results ?? {}).some((check) => check.status === "pending"); + const valid = + draft.target.trim().length > 0 && + (draft.port === undefined || + (Number.isInteger(draft.port) && draft.port >= 1 && draft.port <= 65535)); + const failed = Object.values(results ?? {}).filter((check) => check.status === "failed").length; + return ( + { + if (!open && !busy) onClose(); + }} + > + { + event.preventDefault(); + if (valid && !busy && !checking) + onSave({ ...draft, label: draft.label.trim(), target: draft.target.trim() }); + }} + /> + } + > + + {isNew ? "Add device host" : "Edit device host"} + + {targets.length === 1 + ? `Connect from ${targets[0]?.label}.` + : `Connect from ${targets.length} selected environments.`}{" "} + Hosts on the same machine are skipped. + + + + + +
+ SSH options +
+ + +
+

+ Optional. Resolved separately on each environment. +

+
+
+
+

+ {checking + ? "Checking environments…" + : results + ? failed + ? `${failed} of ${targets.length} failed` + : "Connection checks passed" + : "Check access before saving"} +

+ +
+ {results ? ( +
    + {targets.map((target) => { + const result = results[target.environmentId]; + if (!result) return null; + return ( +
  • +
    + {target.label} + + {result.status === "pending" ? ( + <> + Checking… + + ) : result.status === "local" ? ( + <> + Already available locally + + ) : result.status === "failed" ? ( + <> + Failed + + ) : ( + <> + Connected + + )} + +
    + {result.status === "connected" ? ( +
    + +
    + ) : null} + {result.status === "failed" ? ( +
    + Show error +

    + {result.error} +

    +
    + ) : null} +
  • + ); + })} +
+ ) : null} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index c5a5f194d7cd..e4d9fd201454 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -1,20 +1,13 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { AppleIcon, AndroidIcon } from "../Icons"; -import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; import { Spinner } from "../ui/spinner"; -import type { - DevicePlatformAvailability, - EnvironmentId, - SshDeviceHostConfig, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; import { randomUUID } from "../../lib/utils"; import { useState } from "react"; -import { deviceEnvironment, useDeviceState } from "../../state/device"; +import { useDeviceState } from "../../state/device"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; @@ -22,17 +15,23 @@ import { SettingsRow } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { toastManager } from "../ui/toast"; import { updateDeviceHosts } from "./deviceHostsSettings.logic"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; +import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope(); + const { scope, environments, connectedEnvironments } = useSettingsScope(); const projectScope = scope.kind === "project" || scope.kind === "checkout"; const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const [editing, setEditing] = useState(null); const [originalHost, setOriginalHost] = useState(null); const [busy, setBusy] = useState(false); - const validPort = (port: number | undefined) => - port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); - const { checks, testConnection } = useHostConnectionChecks(props.environmentId); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + })); + const { checks, testConnection } = useHostConnectionChecks(targets); const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { if (!props.environmentId || projectScope) return; setBusy(true); @@ -113,6 +112,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null environmentId={environment.environmentId} hosts={environment.serverConfig?.settings.deviceHosts ?? []} busy={projectScope || busy} + checks={checks} + testConnection={async (host) => { + const results = await testConnection(host); + if (!results) return; + const failed = targets.filter( + (target) => results[target.environmentId]?.status === "failed", + ); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.` + : "Connected or already available locally on each selected environment.", + }); + return results; + }} onEdit={(host) => { setOriginalHost(host); setEditing(host); @@ -122,123 +139,15 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null ))} {editing ? ( -
{ - event.preventDefault(); - void save(editing, false, originalHost ?? editing); - }} - > - - - - -
- - - -
- {checks[editing.id]?.pending ? ( - - - Checking connection… - - ) : null} - {checks[editing.id]?.platforms ? ( - - ) : null} - {checks[editing.id]?.error ? ( -

- {checks[editing.id]?.error} -

- ) : null} - + void save(host, false, originalHost ?? host)} + onClose={() => setEditing(null)} + /> ) : null} )} @@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null ); } -function useHostConnectionChecks(environmentId: EnvironmentId | null) { - const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); - const [checks, setChecks] = useState< - Record< - string, - { pending?: boolean; platforms?: ReadonlyArray; error?: string } - > - >({}); - const setCheck = (id: string, value: (typeof checks)[string]) => - setChecks((current) => ({ ...current, [id]: value })); - const testConnection = async (host: SshDeviceHostConfig) => { - if (!environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); - try { - const summary = await test({ environmentId: environmentId, input: host }); - setCheck( - host.id, - summary._tag === "Failure" - ? { error: Cause.pretty(summary.cause) } - : { platforms: summary.value.platforms }, - ); - } catch (error) { - setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); - } - }; - return { checks, testConnection }; -} - function DeviceHostList({ environmentId, hosts, busy, onEdit, onRemove, + checks, + testConnection, }: { environmentId: EnvironmentId; hosts: ReadonlyArray; busy: boolean; onEdit: (host: SshDeviceHostConfig) => void; onRemove: (host: SshDeviceHostConfig) => void; + checks: ReturnType["checks"]; + testConnection: ReturnType["testConnection"]; }) { const { state } = useDeviceState(environmentId); - const { checks, testConnection } = useHostConnectionChecks(environmentId); return ( <> {hosts.length === 0 ? ( @@ -297,17 +181,27 @@ function DeviceHostList({ ) : null} {hosts.map((host) => { const status = state.hostStatuses[host.id]; - const check = checks[host.id]; + const check = checks[deviceHostConnectionKey(host)]?.[environmentId]; const platforms = - check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? []; - const progress = check?.pending - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined); + (check?.status === "connected" ? check.platforms : undefined) ?? + state.hosts.find((value) => value.id === host.id)?.platforms ?? + []; + const progress = + check?.status === "pending" + ? "Checking connection…" + : status?.status === "installing" + ? "Installing device support…" + : status?.status === "starting" + ? "Connecting…" + : null; + const error = + check?.status === "failed" + ? check.error + : check + ? undefined + : status?.status === "failed" + ? status.detail + : undefined; return (
@@ -342,6 +236,9 @@ function DeviceHostList({ ))}

{host.target}

+ {check?.status === "local" ? ( +

Already available locally

+ ) : null} {error ? (
diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts new file mode 100644 index 000000000000..d7481a84ec62 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts"; +import { + checkDeviceHostConnections, + deviceHostConnectionKey, + type DeviceHostCheck, +} from "./deviceHostConnectionChecks"; + +const host = { id: "mac", label: "Mac mini", target: "user@mac" }; +const ids = ["a", "b", "c", "d"].map((id) => EnvironmentId.make(id)); +const targets = ids.map((environmentId, index) => ({ + environmentId, + label: environmentId, + connected: index !== 3, +})); +const summary: DeviceHostSummary = { + id: "mac", + label: "Mac mini", + kind: "ssh", + platforms: [{ platform: "ios", available: true }], + hubInstalled: false, + agentDeviceInstalled: false, +}; + +describe("device host connection checks", () => { + it("starts all connected environments and retains success, local, failure, and offline results", async () => { + const calls: string[] = []; + const pending = new Map< + string, + { resolve: (value: DeviceHostSummary) => void; reject: (error: Error) => void } + >(); + const results = new Map(); + const run = checkDeviceHostConnections( + targets, + host, + (environmentId) => { + calls.push(environmentId); + return new Promise((resolve, reject) => pending.set(environmentId, { resolve, reject })); + }, + (environmentId, result) => results.set(environmentId, result), + ); + expect(calls).toEqual(ids.slice(0, 3)); + expect(results.get(ids[0]!)).toEqual({ status: "pending" }); + pending.get(ids[0]!)!.resolve(summary); + pending.get(ids[1]!)!.resolve({ ...summary, id: "local", kind: "local" }); + pending.get(ids[2]!)!.reject(new Error("SSH key rejected")); + await run; + expect([...results.values()]).toEqual([ + { status: "connected", platforms: summary.platforms }, + { status: "local" }, + { status: "failed", error: "SSH key rejected" }, + { status: "failed", error: "Environment disconnected" }, + ]); + }); + + it("does not reuse results after editing a destination or SSH options", () => { + const key = deviceHostConnectionKey(host); + for (const changed of [ + { ...host, target: "other" }, + { ...host, port: 2222 }, + { ...host, identityFile: "~/.ssh/other" }, + ]) { + expect(deviceHostConnectionKey(changed)).not.toBe(key); + } + expect( + deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), + ).toBe(key); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts new file mode 100644 index 000000000000..e1266572cf7e --- /dev/null +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -0,0 +1,50 @@ +import type { + DeviceHostSummary, + DevicePlatformAvailability, + EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; + +export interface DeviceHostCheckTarget { + environmentId: EnvironmentId; + label: string; + connected: boolean; +} +export type DeviceHostCheck = + | { status: "pending" } + | { status: "local" } + | { status: "connected"; platforms: ReadonlyArray } + | { status: "failed"; error: string }; + +export function deviceHostConnectionKey(host: SshDeviceHostConfig) { + return JSON.stringify([host.target.trim(), host.port, host.identityFile]); +} + +/** Each environment settles independently so one failure cannot hide the other results. */ +export async function checkDeviceHostConnections( + targets: ReadonlyArray, + host: SshDeviceHostConfig, + probe: (environmentId: EnvironmentId, host: SshDeviceHostConfig) => Promise, + report: (environmentId: EnvironmentId, result: DeviceHostCheck) => void, +) { + await Promise.all( + targets.map(async (target) => { + report(target.environmentId, { status: "pending" }); + try { + if (!target.connected) throw new Error("Environment disconnected"); + const result = await probe(target.environmentId, host); + report( + target.environmentId, + result.kind === "local" + ? { status: "local" } + : { status: "connected", platforms: result.platforms }, + ); + } catch (error) { + report(target.environmentId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} diff --git a/apps/web/src/components/settings/useHostConnectionChecks.ts b/apps/web/src/components/settings/useHostConnectionChecks.ts new file mode 100644 index 000000000000..7d642fa62a60 --- /dev/null +++ b/apps/web/src/components/settings/useHostConnectionChecks.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from "react"; +import * as Cause from "effect/Cause"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import { deviceEnvironment } from "../../state/device"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + checkDeviceHostConnections, + deviceHostConnectionKey, + type DeviceHostCheck, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; + +export function useHostConnectionChecks(targets: ReadonlyArray) { + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const [checks, setChecks] = useState>>({}); + const running = useRef(new Set()); + const testConnection = async (host: SshDeviceHostConfig) => { + const key = deviceHostConnectionKey(host); + if (running.current.has(key)) return; + running.current.add(key); + setChecks((current) => ({ ...current, [key]: {} })); + const results: Record = {}; + try { + await checkDeviceHostConnections( + targets, + host, + async (environmentId, input) => { + const result = await test({ environmentId, input }); + if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause)); + return result.value; + }, + (environmentId, result) => { + results[environmentId] = result; + setChecks((current) => ({ + ...current, + [key]: { ...current[key], [environmentId]: result }, + })); + }, + ); + return results; + } finally { + running.current.delete(key); + } + }; + return { checks, testConnection }; +} From 8e899c9d2d535be8a81e44fd53e52b5708dab975 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:49:34 -0700 Subject: [PATCH 2/5] fix(web): keep device runtime failures visible after testing --- apps/web/src/components/settings/DeviceHostsSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index e4d9fd201454..50841ee5b53a 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -197,7 +197,7 @@ function DeviceHostList({ const error = check?.status === "failed" ? check.error - : check + : check?.status === "local" ? undefined : status?.status === "failed" ? status.detail From da63f244771925e51da17d06d59cdd82568e323f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:51:26 -0700 Subject: [PATCH 3/5] docs: explain device host selection and local targets --- docs/user/devices.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/user/devices.md b/docs/user/devices.md index 2dbe0e26007a..f40d11904459 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -75,14 +75,16 @@ still-image stream and Android cannot show video. ## SSH device hosts -In Settings → Integrations → Devices, select one connected environment -and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with +In Settings → Integrations → Devices, choose the environments that should use +the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. **Test connection** checks SSH, Node, npm, and platform tools without installing -anything. The first device listing installs pinned device tools on the host. +anything, with a result for each selected environment. Targets that resolve to +the environment’s own machine are skipped, since its devices are already local. +The first device listing installs pinned device tools on the host. Node 22 or newer and npm must be available to non-interactive SSH commands. T3 checks common Homebrew and Android SDK locations; custom installations need the appropriate PATH and ANDROID_HOME on the host. From c94a219d07ac0805695ab143c4039a0304e1549f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:59:47 -0700 Subject: [PATCH 4/5] fix(web): validate device host drafts before testing or saving --- .../components/settings/DeviceHostEditor.tsx | 27 +++++++++---------- .../deviceHostConnectionChecks.test.ts | 16 +++++++++++ .../settings/deviceHostConnectionChecks.ts | 19 +++++++++---- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/settings/DeviceHostEditor.tsx b/apps/web/src/components/settings/DeviceHostEditor.tsx index 7c04a473f2c2..4ab03ad3c156 100644 --- a/apps/web/src/components/settings/DeviceHostEditor.tsx +++ b/apps/web/src/components/settings/DeviceHostEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import * as Option from "effect/Option"; import type { SshDeviceHostConfig } from "@t3tools/contracts"; import { CheckIcon, MonitorIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; @@ -15,7 +16,11 @@ import { } from "../ui/dialog"; import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; import { useHostConnectionChecks } from "./useHostConnectionChecks"; -import { deviceHostConnectionKey, type DeviceHostCheckTarget } from "./deviceHostConnectionChecks"; +import { + deviceHostConnectionKey, + parseDeviceHostDraft, + type DeviceHostCheckTarget, +} from "./deviceHostConnectionChecks"; export function DeviceHostEditor({ host, @@ -36,10 +41,8 @@ export function DeviceHostEditor({ const { checks, testConnection } = useHostConnectionChecks(targets); const results = checks[deviceHostConnectionKey(draft)]; const checking = Object.values(results ?? {}).some((check) => check.status === "pending"); - const valid = - draft.target.trim().length > 0 && - (draft.port === undefined || - (Number.isInteger(draft.port) && draft.port >= 1 && draft.port <= 65535)); + const input = parseDeviceHostDraft({ ...draft, label: draft.label.trim() || draft.target }); + const valid = Option.isSome(input); const failed = Object.values(results ?? {}).filter((check) => check.status === "failed").length; return ( { event.preventDefault(); - if (valid && !busy && !checking) - onSave({ ...draft, label: draft.label.trim(), target: draft.target.trim() }); + if (Option.isSome(input) && draft.label.trim() && !busy && !checking) + onSave(input.value); }} /> } @@ -149,13 +152,9 @@ export function DeviceHostEditor({ size="sm" variant="outline" disabled={busy || checking || !valid} - onClick={() => - void testConnection({ - ...draft, - label: draft.label.trim() || draft.target, - target: draft.target.trim(), - }) - } + onClick={() => { + if (Option.isSome(input)) void testConnection(input.value); + }} > {checking ? : null} Test connection diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts index d7481a84ec62..f9b003812bfe 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.test.ts @@ -1,7 +1,9 @@ +import * as Option from "effect/Option"; import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts"; import { checkDeviceHostConnections, + parseDeviceHostDraft, deviceHostConnectionKey, type DeviceHostCheck, } from "./deviceHostConnectionChecks"; @@ -66,4 +68,18 @@ describe("device host connection checks", () => { deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }), ).toBe(key); }); + it("validates SSH targets and normalizes optional identity files through the host contract", () => { + for (const target of ["-invalid", "user@bad host", " "]) { + expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None"); + } + for (const port of [0, 65536, 1.5]) { + expect(parseDeviceHostDraft({ ...host, port })._tag).toBe("None"); + } + expect(parseDeviceHostDraft({ ...host, target: " user@mac ", identityFile: " " })).toEqual( + Option.some(host), + ); + expect(parseDeviceHostDraft({ ...host, identityFile: " ~/.ssh/device " })).toEqual( + Option.some({ ...host, identityFile: "~/.ssh/device" }), + ); + }); }); diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts index e1266572cf7e..fda8953e3dd8 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -1,9 +1,10 @@ -import type { - DeviceHostSummary, - DevicePlatformAvailability, - EnvironmentId, +import { + type DeviceHostSummary, + type DevicePlatformAvailability, + type EnvironmentId, SshDeviceHostConfig, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; export interface DeviceHostCheckTarget { environmentId: EnvironmentId; @@ -16,8 +17,16 @@ export type DeviceHostCheck = | { status: "connected"; platforms: ReadonlyArray } | { status: "failed"; error: string }; +export function parseDeviceHostDraft(host: SshDeviceHostConfig) { + const { identityFile, ...rest } = host; + return Schema.decodeUnknownOption(SshDeviceHostConfig)({ + ...rest, + ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}), + }); +} + export function deviceHostConnectionKey(host: SshDeviceHostConfig) { - return JSON.stringify([host.target.trim(), host.port, host.identityFile]); + return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]); } /** Each environment settles independently so one failure cannot hide the other results. */ From f4004db61d53a64d272970aadc872001342f9a17 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:00:24 -0700 Subject: [PATCH 5/5] perf(web): reuse the device host draft decoder --- .../web/src/components/settings/deviceHostConnectionChecks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/deviceHostConnectionChecks.ts b/apps/web/src/components/settings/deviceHostConnectionChecks.ts index fda8953e3dd8..97312b671513 100644 --- a/apps/web/src/components/settings/deviceHostConnectionChecks.ts +++ b/apps/web/src/components/settings/deviceHostConnectionChecks.ts @@ -17,9 +17,11 @@ export type DeviceHostCheck = | { status: "connected"; platforms: ReadonlyArray } | { status: "failed"; error: string }; +const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig); + export function parseDeviceHostDraft(host: SshDeviceHostConfig) { const { identityFile, ...rest } = host; - return Schema.decodeUnknownOption(SshDeviceHostConfig)({ + return decodeDeviceHostDraft({ ...rest, ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}), });