diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index e61cf742dfd5..c5a5f194d7cd 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -19,67 +19,75 @@ import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; -/** Host names and identity paths belong to the selected environment, never all environments. */ -export function DeviceHostsSettings(props: { - environmentId: EnvironmentId | null; - hosts: ReadonlyArray; -}) { - const update = useAtomCommand(serverEnvironment.updateSettings); - const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); - const { state } = useDeviceState(props.environmentId); +import { useSettingsScope } from "./SettingsScopeContext"; +import { toastManager } from "../ui/toast"; +import { updateDeviceHosts } from "./deviceHostsSettings.logic"; + +export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { + const { scope, environments, connectedEnvironments, environment: selected } = 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, setChecks] = useState< - Record< - string, - { pending?: boolean; platforms?: ReadonlyArray; error?: string } - > - >({}); - const setCheck = (id: string, value: (typeof checks)[string]) => - setChecks((current) => ({ ...current, [id]: value })); - const save = async (hosts: ReadonlyArray) => { - if (!props.environmentId) return; + const { checks, testConnection } = useHostConnectionChecks(props.environmentId); + const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { + if (!props.environmentId || projectScope) return; setBusy(true); try { - const saved = await update({ - environmentId: props.environmentId, - input: { patch: { deviceHosts: hosts } }, + const results = await Promise.allSettled( + environments.map(async (environment) => { + if (environment.connection.phase !== "connected" || !environment.serverConfig) { + throw new Error("Environment disconnected"); + } + return update({ + environmentId: environment.environmentId, + input: { + patch: { + deviceHosts: updateDeviceHosts( + environment.serverConfig.settings.deviceHosts, + host, + remove, + original, + ), + }, + }, + }); + }), + ); + const failed = environments.filter((_, index) => { + const result = results[index]; + return result?.status !== "fulfilled" || result.value._tag === "Failure"; }); - if (saved._tag === "Success") { + if (failed.length === 0) { setEditing(null); + } else { + toastManager.add({ + type: "error", + title: "Device hosts not saved on all environments", + description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, + }); } } finally { setBusy(false); } }; - const testConnection = async (host: SshDeviceHostConfig) => { - if (!props.environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); - try { - const summary = await test({ environmentId: props.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 ( { + setOriginalHost(null); setEditing({ id: randomUUID(), label: "", target: "" }); }} > @@ -90,131 +98,35 @@ export function DeviceHostsSettings(props: {
{!props.environmentId ? (

- Select one connected environment to manage its device hosts. + Connect a selected environment to manage device hosts.

) : ( <> - {props.hosts.map((host) => { - const status = state.hostStatuses[host.id]; - const check = checks[host.id]; - 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); - return ( -
-
-
-

{host.label}

- {platforms - .filter((platform) => platform.available) - .map((platform) => ( - - - } - > - {platform.platform === "ios" ? ( - - ) : ( - - )} - - - {platform.platform === "ios" ? "iOS available" : "Android available"} - - - ))} -
-

{host.target}

- {error ? ( -
-
- Connection failed -

{error}

-
-
- ) : null} -
- {progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - { - setEditing(host); - }} - > - Edit - - - void save(props.hosts.filter((value) => value.id !== host.id)) - } - > - Remove - - - - -
- ); - })} + {connectedEnvironments.map((environment) => ( +
+ {connectedEnvironments.length > 1 ? ( +

+ {environment.label} +

+ ) : null} + { + setOriginalHost(host); + setEditing(host); + }} + onRemove={(host) => void save(host, true)} + /> +
+ ))} {editing ? (
{ event.preventDefault(); - void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + void save(editing, false, originalHost ?? editing); }} >
+ ); + })} + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 9ecd469f310c..15b19029e818 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -25,10 +25,11 @@ vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [], isReady: true }), usePrimaryEnvironment: () => null, })); -vi.mock("../../hooks/useSettings", () => ({ +vi.mock("../../hooks/useSettings", async (importOriginal) => ({ + ...(await importOriginal()), PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", - useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - selector(DEFAULT_CLIENT_SETTINGS), + useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, useClientSettingsHydrated: () => true, usePrimarySettingsAvailable: () => true, usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, @@ -44,7 +45,10 @@ vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => nul vi.mock("./SettingsScopeContext", () => ({ useSettingsScope: () => ({ scope: { kind: "all", environmentIds: [] }, + search: {}, environment: null, + environments: [], + target: null, connectedEnvironments: [], targets: [], }), diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index cd759ab8323c..db6276d94685 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,5 @@ +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { ScopedSwitch } from "./ScopedSwitch"; import { DeviceHostsSettings } from "./DeviceHostsSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than @@ -13,7 +15,6 @@ import { type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, - type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -565,28 +566,19 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) ); } -/** - * Device support installs helper processes and hosts on one machine, so it - * follows the environment crumb. With several environments selected it shows - * the representative, named in the section title. - */ function DeviceIntegrationSettings() { - const { scope, environment: selected, connectedEnvironments } = useSettingsScope(); + const { search, environment: selected } = useSettingsScope(); + const settings = useScopedSettings(); const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; - const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1; return ( - + ); @@ -594,17 +586,21 @@ function DeviceIntegrationSettings() { function DeviceIntegrationControls({ environmentId, - hosts, enabled, agentAccessEnabled, }: { environmentId: EnvironmentId | null; - hosts: ReadonlyArray; enabled: boolean; agentAccessEnabled: boolean; }) { const { state, loaded } = useDeviceState(environmentId); - const configure = useAtomCommand(deviceEnvironment.configure); + const { scope, environments, connectedEnvironments } = useSettingsScope(); + const updateSettings = useUpdateScopedSettings(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const anyHubEnabled = connectedEnvironments.some( + (environment) => environment.serverConfig?.settings.enableDeviceSupport, + ); + const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false }); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; @@ -622,9 +618,27 @@ function DeviceIntegrationControls({ if (!environmentId) return; setPending(kind); try { - const result = await configure({ environmentId, input }); - if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) { - await configure({ environmentId, input: { onboardingCompleted: true } }); + const results = await Promise.allSettled( + environments.map(async (environment) => { + if (environment.connection.phase !== "connected" || !environment.serverConfig) { + throw new Error("Environment disconnected"); + } + return configure({ + environmentId: environment.environmentId, + input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) }, + }); + }), + ); + const failed = environments.filter((_, index) => { + const result = results[index]; + return result?.status !== "fulfilled" || result.value._tag === "Failure"; + }); + if (failed.length > 0) { + toastManager.add({ + type: "error", + title: "Device settings not saved on all environments", + description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, + }); } } finally { setPending(null); @@ -635,13 +649,16 @@ function DeviceIntegrationControls({ <> {pending === "hub" ? : null} - void update("hub", { @@ -657,6 +674,11 @@ function DeviceIntegrationControls({ {platformsRevealed ? ( 1 + ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` + : undefined + } status={
@@ -686,16 +708,25 @@ function DeviceIntegrationControls({ {pending === "agent" ? : null} - - void update("agent", { agentAccessEnabled: Boolean(checked) }) + projectScope + ? updateSettings({ enableAgentDeviceAccess: Boolean(checked) }) + : void update("agent", { agentAccessEnabled: Boolean(checked) }) } /> @@ -706,7 +737,7 @@ function DeviceIntegrationControls({ {state.hostStatusDetail}

) : null} - + ); } diff --git a/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts new file mode 100644 index 000000000000..274cf7ecd9ab --- /dev/null +++ b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; +import { updateDeviceHosts } from "./deviceHostsSettings.logic"; + +describe("device host changes across environments", () => { + const shared = { id: "shared", label: "Mac mini", target: "julius@macmini" }; + const local = { id: "other", label: "Android", target: "julius@android" }; + + it("adds to differing host lists without losing environment-specific hosts, including on retry", () => { + const environments = [[], [local], [shared, local]]; + const saved = environments.map((hosts) => updateDeviceHosts(hosts, shared, false)); + expect(saved).toEqual([[shared], [local, shared], [shared, local]]); + expect(saved.map((hosts) => updateDeviceHosts(hosts, shared, false))).toEqual(saved); + }); + + it("edits and removes the shared host while preserving unrelated hosts", () => { + const edited = { ...shared, target: "julius@new-address" }; + const saved = [[shared], [local, shared]].map((hosts) => + updateDeviceHosts(hosts, edited, false), + ); + expect(saved).toEqual([[edited], [local, edited]]); + expect(saved.map((hosts) => updateDeviceHosts(hosts, edited, true))).toEqual([[], [local]]); + }); + + it("recognizes a host added separately on another environment and preserves its local ID", () => { + const remote = { ...shared, id: "remote-id" }; + const edited = { ...shared, target: "julius@new-address" }; + expect(updateDeviceHosts([remote, local], shared, false)).toEqual([remote, local]); + const saved = updateDeviceHosts([remote, local], edited, false, shared); + expect(saved).toEqual([{ ...edited, id: remote.id }, local]); + expect(updateDeviceHosts(saved, edited, false, shared)).toEqual(saved); + expect(updateDeviceHosts([remote, local], shared, true)).toEqual([local]); + }); + + it("keeps distinct SSH connections to the same target separate", () => { + const anotherPort = { ...shared, id: "another-port", port: 2222 }; + const anotherIdentity = { ...shared, id: "another-key", identityFile: "~/.ssh/another" }; + expect(updateDeviceHosts([anotherPort, anotherIdentity], shared, false)).toEqual([ + anotherPort, + anotherIdentity, + shared, + ]); + }); + + it("prefers the selected ID over a sibling with the same destination", () => { + const sibling = { ...shared, id: "sibling", label: "Another entry" }; + const edited = { ...shared, label: "Renamed" }; + expect(updateDeviceHosts([sibling, shared], edited, false, shared)).toEqual([sibling, edited]); + expect(updateDeviceHosts([sibling, shared], shared, true)).toEqual([sibling]); + }); + + it("refuses an ambiguous destination on another environment instead of changing a sibling", () => { + const remote = { ...shared, id: "remote" }; + const sibling = { ...shared, id: "sibling" }; + expect(() => updateDeviceHosts([remote, sibling], shared, true)).toThrow("Multiple hosts"); + expect(() => updateDeviceHosts([remote, sibling], shared, false)).toThrow("Multiple hosts"); + }); +}); diff --git a/apps/web/src/components/settings/deviceHostsSettings.logic.ts b/apps/web/src/components/settings/deviceHostsSettings.logic.ts new file mode 100644 index 000000000000..ae633b0c3eb6 --- /dev/null +++ b/apps/web/src/components/settings/deviceHostsSettings.logic.ts @@ -0,0 +1,35 @@ +import type { SshDeviceHostConfig } from "@t3tools/contracts"; + +/** Apply one host change without replacing another environment's host list. */ +export function updateDeviceHosts( + hosts: ReadonlyArray, + host: SshDeviceHostConfig, + remove: boolean, + original = host, +): ReadonlyArray { + const sameDestination = (candidate: SshDeviceHostConfig, other: SshDeviceHostConfig) => + candidate.target === other.target && + candidate.port === other.port && + candidate.identityFile === other.identityFile; + const findDestination = (destination: SshDeviceHostConfig) => { + const matches = hosts.filter((candidate) => sameDestination(candidate, destination)); + if (matches.length > 1) { + throw new Error( + "Multiple hosts match this SSH destination. Select the environment to edit its hosts.", + ); + } + return matches[0]; + }; + // A retry can encounter the updated destination on an environment that + // already saved, including one with a different environment-local host ID. + const existing = + hosts.find((candidate) => candidate.id === original.id) ?? + findDestination(original) ?? + (remove ? undefined : findDestination(host)); + if (remove) return hosts.filter((candidate) => candidate.id !== existing?.id); + return existing + ? hosts.map((candidate) => + candidate.id === existing.id ? { ...host, id: existing.id } : candidate, + ) + : [...hosts, host]; +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 707c82c81ec6..b0cf734a01cb 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -231,6 +231,19 @@ describe("scoped settings writes", () => { }); }); + it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => { + const plan = planScopedSettingsPatch(project, [laptop, server], { + enableAgentDeviceAccess: true, + }); + expect(plan.serverWrites.map((write) => write.patch)).toEqual([ + { projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } } }, + { projectSettingsOverrides: { [laptopProjectId]: { enableAgentDeviceAccess: true } } }, + ]); + for (const patch of [{ enableDeviceSupport: true }, { deviceHosts: [] }]) { + expect(planScopedSettingsPatch(project, environments, patch).serverWrites).toEqual([]); + } + }); + it("refuses environment-wide keys and older servers at project scope", () => { expect( planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),