authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 17:08:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 17:08:41-07:00
log66e39ca2aabde054bc50312a9c34f05dbd1f6f9e
treed2924437d5c3868196a7db17ceca23e3fbfbe5a6
parent3689c98d2739c6e5bf788850bc1a82a7372d313d
signature Signed by PGP key B5690EEEBB952194

fix(web): apply device settings to selected environments (#11541)


6 files changed, 401 insertions(+), 193 deletions(-)

apps/web/src/components/settings/DeviceHostsSettings.tsx+230-162
...@@ -19,67 +19,75 @@ import { MoreVertical, PlusIcon } from "lucide-react";...@@ -19,67 +19,75 @@ import { MoreVertical, PlusIcon } from "lucide-react";
19import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";19import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
20import { SettingsRow } from "./settingsLayout";20import { SettingsRow } from "./settingsLayout";
2121
22/** Host names and identity paths belong to the selected environment, never all environments. */22import { useSettingsScope } from "./SettingsScopeContext";
23export function DeviceHostsSettings(props: {23import { toastManager } from "../ui/toast";
24 environmentId: EnvironmentId | null;24import { updateDeviceHosts } from "./deviceHostsSettings.logic";
25 hosts: ReadonlyArray<SshDeviceHostConfig>;25
26}) {26export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
27 const update = useAtomCommand(serverEnvironment.updateSettings);27 const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope();
28 const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });28 const projectScope = scope.kind === "project" || scope.kind === "checkout";
29 const { state } = useDeviceState(props.environmentId);29 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
30 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);30 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
31 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);
31 const [busy, setBusy] = useState(false);32 const [busy, setBusy] = useState(false);
32 const validPort = (port: number | undefined) =>33 const validPort = (port: number | undefined) =>
33 port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);34 port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
34 const [checks, setChecks] = useState<35 const { checks, testConnection } = useHostConnectionChecks(props.environmentId);
35 Record<36 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {
36 string,37 if (!props.environmentId || projectScope) return;
37 { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string }
38 >
39 >({});
40 const setCheck = (id: string, value: (typeof checks)[string]) =>
41 setChecks((current) => ({ ...current, [id]: value }));
42 const save = async (hosts: ReadonlyArray<SshDeviceHostConfig>) => {
43 if (!props.environmentId) return;
44 setBusy(true);38 setBusy(true);
45 try {39 try {
46 const saved = await update({40 const results = await Promise.allSettled(
47 environmentId: props.environmentId,41 environments.map(async (environment) => {
48 input: { patch: { deviceHosts: hosts } },42 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
43 throw new Error("Environment disconnected");
44 }
45 return update({
46 environmentId: environment.environmentId,
47 input: {
48 patch: {
49 deviceHosts: updateDeviceHosts(
50 environment.serverConfig.settings.deviceHosts,
51 host,
52 remove,
53 original,
54 ),
55 },
56 },
57 });
58 }),
59 );
60 const failed = environments.filter((_, index) => {
61 const result = results[index];
62 return result?.status !== "fulfilled" || result.value._tag === "Failure";
49 });63 });
50 if (saved._tag === "Success") {64 if (failed.length === 0) {
51 setEditing(null);65 setEditing(null);
66 } else {
67 toastManager.add({
68 type: "error",
69 title: "Device hosts not saved on all environments",
70 description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`,
71 });
52 }72 }
53 } finally {73 } finally {
54 setBusy(false);74 setBusy(false);
55 }75 }
56 };76 };
57 const testConnection = async (host: SshDeviceHostConfig) => {
58 if (!props.environmentId || checks[host.id]?.pending) return;
59 setCheck(host.id, { pending: true });
60 try {
61 const summary = await test({ environmentId: props.environmentId, input: host });
62 setCheck(
63 host.id,
64 summary._tag === "Failure"
65 ? { error: Cause.pretty(summary.cause) }
66 : { platforms: summary.value.platforms },
67 );
68 } catch (error) {
69 setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
70 }
71 };
72 return (77 return (
73 <SettingsRow78 <SettingsRow
74 id="device-hosts"79 id="device-hosts"
75 title="Device hosts"80 title="Device hosts"
76 description="Add remote machines with simulator or emulator runtimes installed, and this environment will connect over SSH and set up device tools automatically."81 serverScoped
82 settingKeys={["deviceHosts"]}
83 description="Add remote machines with simulator or emulator runtimes installed, and the selected environments will connect over SSH and set up device tools automatically."
77 control={84 control={
78 <Button85 <Button
79 size="sm"86 size="sm"
80 variant="outline"87 variant="outline"
81 disabled={busy || !props.environmentId || editing !== null}88 disabled={projectScope || busy || !props.environmentId || editing !== null}
82 onClick={() => {89 onClick={() => {
90 setOriginalHost(null);
83 setEditing({ id: randomUUID(), label: "", target: "" });91 setEditing({ id: randomUUID(), label: "", target: "" });
84 }}92 }}
85 >93 >
...@@ -90,131 +98,35 @@ export function DeviceHostsSettings(props: {...@@ -90,131 +98,35 @@ export function DeviceHostsSettings(props: {
90 <div className="pt-3 pb-2">98 <div className="pt-3 pb-2">
91 {!props.environmentId ? (99 {!props.environmentId ? (
92 <p className="text-sm text-muted-foreground">100 <p className="text-sm text-muted-foreground">
93 Select one connected environment to manage its device hosts.101 Connect a selected environment to manage device hosts.
94 </p>102 </p>
95 ) : (103 ) : (
96 <>104 <>
97 {props.hosts.map((host) => {105 {connectedEnvironments.map((environment) => (
98 const status = state.hostStatuses[host.id];106 <div key={environment.environmentId}>
99 const check = checks[host.id];107 {connectedEnvironments.length > 1 ? (
100 const platforms =108 <p className="pt-3 pb-1 text-xs font-medium text-muted-foreground">
101 check?.platforms ??109 {environment.label}
102 state.hosts.find((value) => value.id === host.id)?.platforms ??110 </p>
103 [];111 ) : null}
104 const progress = check?.pending112 <DeviceHostList
105 ? "Checking connection…"113 environmentId={environment.environmentId}
106 : status?.status === "installing"114 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
107 ? "Installing device support…"115 busy={projectScope || busy}
108 : status?.status === "starting"116 onEdit={(host) => {
109 ? "Connecting…"117 setOriginalHost(host);
110 : null;118 setEditing(host);
111 const error =119 }}
112 check?.error ?? (status?.status === "failed" ? status.detail : undefined);120 onRemove={(host) => void save(host, true)}
113 return (121 />
114 <div122 </div>
115 key={host.id}123 ))}
116 className="flex items-center gap-2 border-t border-border/50 py-2.5"
117 >
118 <div className="min-w-0 flex-1">
119 <div className="flex min-w-0 items-center gap-2">
120 <p className="truncate text-sm font-medium">{host.label}</p>
121 {platforms
122 .filter((platform) => platform.available)
123 .map((platform) => (
124 <Tooltip key={platform.platform}>
125 <TooltipTrigger
126 render={
127 <span
128 tabIndex={0}
129 role="img"
130 aria-label={
131 platform.platform === "ios"
132 ? "iOS available"
133 : "Android available"
134 }
135 className="shrink-0 text-muted-foreground"
136 />
137 }
138 >
139 {platform.platform === "ios" ? (
140 <AppleIcon className="size-3.5" />
141 ) : (
142 <AndroidIcon className="size-3.5" />
143 )}
144 </TooltipTrigger>
145 <TooltipPopup>
146 {platform.platform === "ios" ? "iOS available" : "Android available"}
147 </TooltipPopup>
148 </Tooltip>
149 ))}
150 </div>
151 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
152 {error ? (
153 <div className="mt-1" role="status">
154 <details className="text-xs text-destructive">
155 <summary>Connection failed</summary>
156 <p className="mt-1 whitespace-pre-wrap break-words">{error}</p>
157 </details>
158 </div>
159 ) : null}
160 </div>
161 {progress ? (
162 <span
163 role="status"
164 className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
165 >
166 <Spinner className="size-3" />
167 {progress}
168 </span>
169 ) : null}
170 <Menu>
171 <MenuTrigger
172 render={
173 <Button
174 size="icon-sm"
175 variant="ghost-muted"
176 disabled={busy}
177 aria-label={host.label + " options"}
178 />
179 }
180 >
181 <MoreVertical />
182 </MenuTrigger>
183 <MenuPopup align="end">
184 <MenuItem
185 onClick={() => {
186 setEditing(host);
187 }}
188 >
189 Edit
190 </MenuItem>
191 <MenuItem
192 variant="destructive"
193 onClick={() =>
194 void save(props.hosts.filter((value) => value.id !== host.id))
195 }
196 >
197 Remove
198 </MenuItem>
199 </MenuPopup>
200 </Menu>
201 <Button
202 size="sm"
203 variant="outline"
204 disabled={busy || progress !== null}
205 onClick={() => void testConnection(host)}
206 >
207 Test connection
208 </Button>
209 </div>
210 );
211 })}
212 {editing ? (124 {editing ? (
213 <form125 <form
214 className="space-y-3 border-t border-border/50 py-3"126 className="space-y-3 border-t border-border/50 py-3"
215 onSubmit={(event) => {127 onSubmit={(event) => {
216 event.preventDefault();128 event.preventDefault();
217 void save([...props.hosts.filter((host) => host.id !== editing.id), editing]);129 void save(editing, false, originalHost ?? editing);
218 }}130 }}
219 >131 >
220 <label className="block space-y-1 text-sm">132 <label className="block space-y-1 text-sm">
...@@ -222,7 +134,7 @@ export function DeviceHostsSettings(props: {...@@ -222,7 +134,7 @@ export function DeviceHostsSettings(props: {
222 <Input134 <Input
223 required135 required
224 value={editing.label}136 value={editing.label}
225 disabled={busy}137 disabled={projectScope || busy}
226 onChange={(event) => setEditing({ ...editing, label: event.target.value })}138 onChange={(event) => setEditing({ ...editing, label: event.target.value })}
227 placeholder="Mac mini"139 placeholder="Mac mini"
228 />140 />
...@@ -232,7 +144,7 @@ export function DeviceHostsSettings(props: {...@@ -232,7 +144,7 @@ export function DeviceHostsSettings(props: {
232 <Input144 <Input
233 required145 required
234 value={editing.target}146 value={editing.target}
235 disabled={busy}147 disabled={projectScope || busy}
236 onChange={(event) => setEditing({ ...editing, target: event.target.value })}148 onChange={(event) => setEditing({ ...editing, target: event.target.value })}
237 placeholder="user@host or SSH alias"149 placeholder="user@host or SSH alias"
238 />150 />
...@@ -241,7 +153,7 @@ export function DeviceHostsSettings(props: {...@@ -241,7 +153,7 @@ export function DeviceHostsSettings(props: {
241 <span>Identity file, optional</span>153 <span>Identity file, optional</span>
242 <Input154 <Input
243 value={editing.identityFile ?? ""}155 value={editing.identityFile ?? ""}
244 disabled={busy}156 disabled={projectScope || busy}
245 onChange={(event) => {157 onChange={(event) => {
246 const { identityFile: _, ...rest } = editing;158 const { identityFile: _, ...rest } = editing;
247 setEditing(159 setEditing(
...@@ -258,7 +170,7 @@ export function DeviceHostsSettings(props: {...@@ -258,7 +170,7 @@ export function DeviceHostsSettings(props: {
258 min={1}170 min={1}
259 max={65535}171 max={65535}
260 value={editing.port ?? ""}172 value={editing.port ?? ""}
261 disabled={busy}173 disabled={projectScope || busy}
262 onChange={(event) => {174 onChange={(event) => {
263 const { port: _, ...rest } = editing;175 const { port: _, ...rest } = editing;
264 setEditing(176 setEditing(
...@@ -293,13 +205,15 @@ export function DeviceHostsSettings(props: {...@@ -293,13 +205,15 @@ export function DeviceHostsSettings(props: {
293 }205 }
294 onClick={() => void testConnection(editing)}206 onClick={() => void testConnection(editing)}
295 >207 >
296 Test connection208 {connectedEnvironments.length > 1
209 ? `Test from ${selected?.label}`
210 : "Test connection"}
297 </Button>211 </Button>
298 <Button212 <Button
299 size="sm"213 size="sm"
300 type="button"214 type="button"
301 variant="ghost"215 variant="ghost"
302 disabled={busy}216 disabled={projectScope || busy}
303 onClick={() => {217 onClick={() => {
304 setEditing(null);218 setEditing(null);
305 }}219 }}
...@@ -332,3 +246,157 @@ export function DeviceHostsSettings(props: {...@@ -332,3 +246,157 @@ export function DeviceHostsSettings(props: {
332 </SettingsRow>246 </SettingsRow>
333 );247 );
334}248}
249
250function useHostConnectionChecks(environmentId: EnvironmentId | null) {
251 const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
252 const [checks, setChecks] = useState<
253 Record<
254 string,
255 { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string }
256 >
257 >({});
258 const setCheck = (id: string, value: (typeof checks)[string]) =>
259 setChecks((current) => ({ ...current, [id]: value }));
260 const testConnection = async (host: SshDeviceHostConfig) => {
261 if (!environmentId || checks[host.id]?.pending) return;
262 setCheck(host.id, { pending: true });
263 try {
264 const summary = await test({ environmentId: environmentId, input: host });
265 setCheck(
266 host.id,
267 summary._tag === "Failure"
268 ? { error: Cause.pretty(summary.cause) }
269 : { platforms: summary.value.platforms },
270 );
271 } catch (error) {
272 setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
273 }
274 };
275 return { checks, testConnection };
276}
277
278function DeviceHostList({
279 environmentId,
280 hosts,
281 busy,
282 onEdit,
283 onRemove,
284}: {
285 environmentId: EnvironmentId;
286 hosts: ReadonlyArray<SshDeviceHostConfig>;
287 busy: boolean;
288 onEdit: (host: SshDeviceHostConfig) => void;
289 onRemove: (host: SshDeviceHostConfig) => void;
290}) {
291 const { state } = useDeviceState(environmentId);
292 const { checks, testConnection } = useHostConnectionChecks(environmentId);
293 return (
294 <>
295 {hosts.length === 0 ? (
296 <p className="py-2 text-sm text-muted-foreground">No device hosts.</p>
297 ) : null}
298 {hosts.map((host) => {
299 const status = state.hostStatuses[host.id];
300 const check = checks[host.id];
301 const platforms =
302 check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? [];
303 const progress = check?.pending
304 ? "Checking connection…"
305 : status?.status === "installing"
306 ? "Installing device support…"
307 : status?.status === "starting"
308 ? "Connecting…"
309 : null;
310 const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined);
311 return (
312 <div key={host.id} className="flex items-center gap-2 border-t border-border/50 py-2.5">
313 <div className="min-w-0 flex-1">
314 <div className="flex min-w-0 items-center gap-2">
315 <p className="truncate text-sm font-medium">{host.label}</p>
316 {platforms
317 .filter((platform) => platform.available)
318 .map((platform) => (
319 <Tooltip key={platform.platform}>
320 <TooltipTrigger
321 render={
322 <span
323 tabIndex={0}
324 role="img"
325 aria-label={
326 platform.platform === "ios" ? "iOS available" : "Android available"
327 }
328 className="shrink-0 text-muted-foreground"
329 />
330 }
331 >
332 {platform.platform === "ios" ? (
333 <AppleIcon className="size-3.5" />
334 ) : (
335 <AndroidIcon className="size-3.5" />
336 )}
337 </TooltipTrigger>
338 <TooltipPopup>
339 {platform.platform === "ios" ? "iOS available" : "Android available"}
340 </TooltipPopup>
341 </Tooltip>
342 ))}
343 </div>
344 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
345 {error ? (
346 <div className="mt-1" role="status">
347 <details className="text-xs text-destructive">
348 <summary>Connection failed</summary>
349 <p className="mt-1 whitespace-pre-wrap break-words">{error}</p>
350 </details>
351 </div>
352 ) : null}
353 </div>
354 {progress ? (
355 <span
356 role="status"
357 className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
358 >
359 <Spinner className="size-3" />
360 {progress}
361 </span>
362 ) : null}
363 <Menu>
364 <MenuTrigger
365 render={
366 <Button
367 size="icon-sm"
368 variant="ghost-muted"
369 disabled={busy}
370 aria-label={host.label + " options"}
371 />
372 }
373 >
374 <MoreVertical />
375 </MenuTrigger>
376 <MenuPopup align="end">
377 <MenuItem
378 onClick={() => {
379 onEdit(host);
380 }}
381 >
382 Edit
383 </MenuItem>
384 <MenuItem variant="destructive" onClick={() => onRemove(host)}>
385 Remove
386 </MenuItem>
387 </MenuPopup>
388 </Menu>
389 <Button
390 size="sm"
391 variant="outline"
392 disabled={busy || progress !== null}
393 onClick={() => void testConnection(host)}
394 >
395 Test connection
396 </Button>
397 </div>
398 );
399 })}
400 </>
401 );
402}
apps/web/src/components/settings/IntegrationsSettings.test.tsx+7-3
...@@ -25,10 +25,11 @@ vi.mock("../../state/environments", () => ({...@@ -25,10 +25,11 @@ vi.mock("../../state/environments", () => ({
25 useEnvironments: () => ({ environments: [], isReady: true }),25 useEnvironments: () => ({ environments: [], isReady: true }),
26 usePrimaryEnvironment: () => null,26 usePrimaryEnvironment: () => null,
27}));27}));
28vi.mock("../../hooks/useSettings", () => ({28vi.mock("../../hooks/useSettings", async (importOriginal) => ({
29 ...(await importOriginal<typeof import("../../hooks/useSettings")>()),
29 PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment",30 PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment",
30 useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>31 useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
31 selector(DEFAULT_CLIENT_SETTINGS),32 selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS,
32 useClientSettingsHydrated: () => true,33 useClientSettingsHydrated: () => true,
33 usePrimarySettingsAvailable: () => true,34 usePrimarySettingsAvailable: () => true,
34 usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS,35 usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS,
...@@ -44,7 +45,10 @@ vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => nul...@@ -44,7 +45,10 @@ vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => nul
44vi.mock("./SettingsScopeContext", () => ({45vi.mock("./SettingsScopeContext", () => ({
45 useSettingsScope: () => ({46 useSettingsScope: () => ({
46 scope: { kind: "all", environmentIds: [] },47 scope: { kind: "all", environmentIds: [] },
48 search: {},
47 environment: null,49 environment: null,
50 environments: [],
51 target: null,
48 connectedEnvironments: [],52 connectedEnvironments: [],
49 targets: [],53 targets: [],
50 }),54 }),
apps/web/src/components/settings/IntegrationsSettings.tsx+59-28
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
2import { ScopedSwitch } from "./ScopedSwitch";
1import { DeviceHostsSettings } from "./DeviceHostsSettings";3import { DeviceHostsSettings } from "./DeviceHostsSettings";
2/**4/**
3 * Integrations settings - preferences for surfaces T3 Code embeds rather than5 * Integrations settings - preferences for surfaces T3 Code embeds rather than
...@@ -13,7 +15,6 @@ import {...@@ -13,7 +15,6 @@ import {
13 type BrowserLinkTarget,15 type BrowserLinkTarget,
14 type BrowserProfile,16 type BrowserProfile,
15 type EnvironmentId,17 type EnvironmentId,
16 type SshDeviceHostConfig,
17 BROWSER_PROFILE_NAME_MAX_LENGTH,18 BROWSER_PROFILE_NAME_MAX_LENGTH,
18 BROWSER_RECORDING_FRAME_RATES,19 BROWSER_RECORDING_FRAME_RATES,
19 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,20 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
...@@ -565,28 +566,19 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })...@@ -565,28 +566,19 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
565 );566 );
566}567}
567568
568/**
569 * Device support installs helper processes and hosts on one machine, so it
570 * follows the environment crumb. With several environments selected it shows
571 * the representative, named in the section title.
572 */
573function DeviceIntegrationSettings() {569function DeviceIntegrationSettings() {
574 const { scope, environment: selected, connectedEnvironments } = useSettingsScope();570 const { search, environment: selected } = useSettingsScope();
571 const settings = useScopedSettings();
575 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;572 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
576 const environmentId = connected ? selected.environmentId : null;573 const environmentId = connected ? selected.environmentId : null;
577 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
578574
579 return (575 return (
580 <SettingsSection576 <SettingsSection id="devices" title="Devices">
581 id="devices"
582 title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"}
583 >
584 <DeviceIntegrationControls577 <DeviceIntegrationControls
585 key={selected?.environmentId ?? "none"}578 key={`${environmentId}:${JSON.stringify(search)}`}
586 environmentId={environmentId}579 environmentId={environmentId}
587 hosts={selected?.serverConfig?.settings.deviceHosts ?? []}580 enabled={settings.enableDeviceSupport}
588 enabled={selected?.serverConfig?.settings.enableDeviceSupport ?? false}581 agentAccessEnabled={settings.enableAgentDeviceAccess}
589 agentAccessEnabled={selected?.serverConfig?.settings.enableAgentDeviceAccess ?? false}
590 />582 />
591 </SettingsSection>583 </SettingsSection>
592 );584 );
...@@ -594,17 +586,21 @@ function DeviceIntegrationSettings() {...@@ -594,17 +586,21 @@ function DeviceIntegrationSettings() {
594586
595function DeviceIntegrationControls({587function DeviceIntegrationControls({
596 environmentId,588 environmentId,
597 hosts,
598 enabled,589 enabled,
599 agentAccessEnabled,590 agentAccessEnabled,
600}: {591}: {
601 environmentId: EnvironmentId | null;592 environmentId: EnvironmentId | null;
602 hosts: ReadonlyArray<SshDeviceHostConfig>;
603 enabled: boolean;593 enabled: boolean;
604 agentAccessEnabled: boolean;594 agentAccessEnabled: boolean;
605}) {595}) {
606 const { state, loaded } = useDeviceState(environmentId);596 const { state, loaded } = useDeviceState(environmentId);
607 const configure = useAtomCommand(deviceEnvironment.configure);597 const { scope, environments, connectedEnvironments } = useSettingsScope();
598 const updateSettings = useUpdateScopedSettings();
599 const projectScope = scope.kind === "project" || scope.kind === "checkout";
600 const anyHubEnabled = connectedEnvironments.some(
601 (environment) => environment.serverConfig?.settings.enableDeviceSupport,
602 );
603 const configure = useAtomCommand(deviceEnvironment.configure, { reportFailure: false });
608 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });604 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
609 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);605 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
610 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";606 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
...@@ -622,9 +618,27 @@ function DeviceIntegrationControls({...@@ -622,9 +618,27 @@ function DeviceIntegrationControls({
622 if (!environmentId) return;618 if (!environmentId) return;
623 setPending(kind);619 setPending(kind);
624 try {620 try {
625 const result = await configure({ environmentId, input });621 const results = await Promise.allSettled(
626 if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) {622 environments.map(async (environment) => {
627 await configure({ environmentId, input: { onboardingCompleted: true } });623 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
624 throw new Error("Environment disconnected");
625 }
626 return configure({
627 environmentId: environment.environmentId,
628 input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) },
629 });
630 }),
631 );
632 const failed = environments.filter((_, index) => {
633 const result = results[index];
634 return result?.status !== "fulfilled" || result.value._tag === "Failure";
635 });
636 if (failed.length > 0) {
637 toastManager.add({
638 type: "error",
639 title: "Device settings not saved on all environments",
640 description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`,
641 });
628 }642 }
629 } finally {643 } finally {
630 setPending(null);644 setPending(null);
...@@ -635,13 +649,16 @@ function DeviceIntegrationControls({...@@ -635,13 +649,16 @@ function DeviceIntegrationControls({
635 <>649 <>
636 <SettingsRow650 <SettingsRow
637 {...searchableSetting("device-hub")}651 {...searchableSetting("device-hub")}
652 serverScoped
653 settingKeys={["enableDeviceSupport"]}
638 description={deviceHubDescription}654 description={deviceHubDescription}
639 control={655 control={
640 <>656 <>
641 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}657 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
642 <Switch658 <ScopedSwitch
659 settingKeys={["enableDeviceSupport"]}
643 checked={enabled}660 checked={enabled}
644 disabled={!loaded || !environmentId || busy || pending !== null}661 disabled={projectScope || !loaded || !environmentId || busy || pending !== null}
645 aria-label="Device hub"662 aria-label="Device hub"
646 onCheckedChange={(checked) =>663 onCheckedChange={(checked) =>
647 void update("hub", {664 void update("hub", {
...@@ -657,6 +674,11 @@ function DeviceIntegrationControls({...@@ -657,6 +674,11 @@ function DeviceIntegrationControls({
657 {platformsRevealed ? (674 {platformsRevealed ? (
658 <SettingsRow675 <SettingsRow
659 {...searchableSetting("device-platform-support")}676 {...searchableSetting("device-platform-support")}
677 description={
678 connectedEnvironments.length > 1
679 ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.`
680 : undefined
681 }
660 status={682 status={
661 <div className="flex flex-wrap gap-x-5 gap-y-2">683 <div className="flex flex-wrap gap-x-5 gap-y-2">
662 <PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />684 <PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />
...@@ -686,16 +708,25 @@ function DeviceIntegrationControls({...@@ -686,16 +708,25 @@ function DeviceIntegrationControls({
686 </AnimatedHeight>708 </AnimatedHeight>
687 <SettingsRow709 <SettingsRow
688 {...searchableSetting("agent-device-access")}710 {...searchableSetting("agent-device-access")}
711 serverScoped
712 settingKeys={["enableAgentDeviceAccess"]}
689 description={agentDeviceDescription}713 description={agentDeviceDescription}
690 control={714 control={
691 <>715 <>
692 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}716 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
693 <Switch717 <ScopedSwitch
718 settingKeys={["enableAgentDeviceAccess"]}
694 checked={agentAccessEnabled}719 checked={agentAccessEnabled}
695 disabled={!loaded || !environmentId || !enabled || busy || pending !== null}720 disabled={
721 connectedEnvironments.length === 0 ||
722 (!projectScope && (!loaded || !anyHubEnabled || busy)) ||
723 pending !== null
724 }
696 aria-label="Agent device access"725 aria-label="Agent device access"
697 onCheckedChange={(checked) =>726 onCheckedChange={(checked) =>
698 void update("agent", { agentAccessEnabled: Boolean(checked) })727 projectScope
728 ? updateSettings({ enableAgentDeviceAccess: Boolean(checked) })
729 : void update("agent", { agentAccessEnabled: Boolean(checked) })
699 }730 }
700 />731 />
701 </>732 </>
...@@ -706,7 +737,7 @@ function DeviceIntegrationControls({...@@ -706,7 +737,7 @@ function DeviceIntegrationControls({
706 {state.hostStatusDetail}737 {state.hostStatusDetail}
707 </p>738 </p>
708 ) : null}739 ) : null}
709 <DeviceHostsSettings environmentId={environmentId} hosts={hosts} />740 <DeviceHostsSettings environmentId={environmentId} />
710 </>741 </>
711 );742 );
712}743}
apps/web/src/components/settings/deviceHostsSettings.logic.test.ts created+57
...@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vite-plus/test";
2import { updateDeviceHosts } from "./deviceHostsSettings.logic";
3
4describe("device host changes across environments", () => {
5 const shared = { id: "shared", label: "Mac mini", target: "julius@macmini" };
6 const local = { id: "other", label: "Android", target: "julius@android" };
7
8 it("adds to differing host lists without losing environment-specific hosts, including on retry", () => {
9 const environments = [[], [local], [shared, local]];
10 const saved = environments.map((hosts) => updateDeviceHosts(hosts, shared, false));
11 expect(saved).toEqual([[shared], [local, shared], [shared, local]]);
12 expect(saved.map((hosts) => updateDeviceHosts(hosts, shared, false))).toEqual(saved);
13 });
14
15 it("edits and removes the shared host while preserving unrelated hosts", () => {
16 const edited = { ...shared, target: "julius@new-address" };
17 const saved = [[shared], [local, shared]].map((hosts) =>
18 updateDeviceHosts(hosts, edited, false),
19 );
20 expect(saved).toEqual([[edited], [local, edited]]);
21 expect(saved.map((hosts) => updateDeviceHosts(hosts, edited, true))).toEqual([[], [local]]);
22 });
23
24 it("recognizes a host added separately on another environment and preserves its local ID", () => {
25 const remote = { ...shared, id: "remote-id" };
26 const edited = { ...shared, target: "julius@new-address" };
27 expect(updateDeviceHosts([remote, local], shared, false)).toEqual([remote, local]);
28 const saved = updateDeviceHosts([remote, local], edited, false, shared);
29 expect(saved).toEqual([{ ...edited, id: remote.id }, local]);
30 expect(updateDeviceHosts(saved, edited, false, shared)).toEqual(saved);
31 expect(updateDeviceHosts([remote, local], shared, true)).toEqual([local]);
32 });
33
34 it("keeps distinct SSH connections to the same target separate", () => {
35 const anotherPort = { ...shared, id: "another-port", port: 2222 };
36 const anotherIdentity = { ...shared, id: "another-key", identityFile: "~/.ssh/another" };
37 expect(updateDeviceHosts([anotherPort, anotherIdentity], shared, false)).toEqual([
38 anotherPort,
39 anotherIdentity,
40 shared,
41 ]);
42 });
43
44 it("prefers the selected ID over a sibling with the same destination", () => {
45 const sibling = { ...shared, id: "sibling", label: "Another entry" };
46 const edited = { ...shared, label: "Renamed" };
47 expect(updateDeviceHosts([sibling, shared], edited, false, shared)).toEqual([sibling, edited]);
48 expect(updateDeviceHosts([sibling, shared], shared, true)).toEqual([sibling]);
49 });
50
51 it("refuses an ambiguous destination on another environment instead of changing a sibling", () => {
52 const remote = { ...shared, id: "remote" };
53 const sibling = { ...shared, id: "sibling" };
54 expect(() => updateDeviceHosts([remote, sibling], shared, true)).toThrow("Multiple hosts");
55 expect(() => updateDeviceHosts([remote, sibling], shared, false)).toThrow("Multiple hosts");
56 });
57});
apps/web/src/components/settings/deviceHostsSettings.logic.ts created+35
...@@ -0,0 +1,35 @@
1import type { SshDeviceHostConfig } from "@t3tools/contracts";
2
3/** Apply one host change without replacing another environment's host list. */
4export function updateDeviceHosts(
5 hosts: ReadonlyArray<SshDeviceHostConfig>,
6 host: SshDeviceHostConfig,
7 remove: boolean,
8 original = host,
9): ReadonlyArray<SshDeviceHostConfig> {
10 const sameDestination = (candidate: SshDeviceHostConfig, other: SshDeviceHostConfig) =>
11 candidate.target === other.target &&
12 candidate.port === other.port &&
13 candidate.identityFile === other.identityFile;
14 const findDestination = (destination: SshDeviceHostConfig) => {
15 const matches = hosts.filter((candidate) => sameDestination(candidate, destination));
16 if (matches.length > 1) {
17 throw new Error(
18 "Multiple hosts match this SSH destination. Select the environment to edit its hosts.",
19 );
20 }
21 return matches[0];
22 };
23 // A retry can encounter the updated destination on an environment that
24 // already saved, including one with a different environment-local host ID.
25 const existing =
26 hosts.find((candidate) => candidate.id === original.id) ??
27 findDestination(original) ??
28 (remove ? undefined : findDestination(host));
29 if (remove) return hosts.filter((candidate) => candidate.id !== existing?.id);
30 return existing
31 ? hosts.map((candidate) =>
32 candidate.id === existing.id ? { ...host, id: existing.id } : candidate,
33 )
34 : [...hosts, host];
35}
apps/web/src/components/settings/scopedSettings.test.ts+13
...@@ -231,6 +231,19 @@ describe("scoped settings writes", () => {...@@ -231,6 +231,19 @@ describe("scoped settings writes", () => {
231 });231 });
232 });232 });
233233
234 it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => {
235 const plan = planScopedSettingsPatch(project, [laptop, server], {
236 enableAgentDeviceAccess: true,
237 });
238 expect(plan.serverWrites.map((write) => write.patch)).toEqual([
239 { projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } } },
240 { projectSettingsOverrides: { [laptopProjectId]: { enableAgentDeviceAccess: true } } },
241 ]);
242 for (const patch of [{ enableDeviceSupport: true }, { deviceHosts: [] }]) {
243 expect(planScopedSettingsPatch(project, environments, patch).serverWrites).toEqual([]);
244 }
245 });
246
234 it("refuses environment-wide keys and older servers at project scope", () => {247 it("refuses environment-wide keys and older servers at project scope", () => {
235 expect(248 expect(
236 planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),249 planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),