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";
1919import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
2020import { SettingsRow } from "./settingsLayout";
2121
22/** Host names and identity paths belong to the selected environment, never all environments. */
23export function DeviceHostsSettings(props: {
24 environmentId: EnvironmentId | null;
25 hosts: ReadonlyArray<SshDeviceHostConfig>;
26}) {
27 const update = useAtomCommand(serverEnvironment.updateSettings);
28 const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
29 const { state } = useDeviceState(props.environmentId);
22import { useSettingsScope } from "./SettingsScopeContext";
23import { toastManager } from "../ui/toast";
24import { updateDeviceHosts } from "./deviceHostsSettings.logic";
25
26export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
27 const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope();
28 const projectScope = scope.kind === "project" || scope.kind === "checkout";
29 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
3030 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
31 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);
3132 const [busy, setBusy] = useState(false);
3233 const validPort = (port: number | undefined) =>
3334 port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
34 const [checks, setChecks] = useState<
35 Record<
36 string,
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;
35 const { checks, testConnection } = useHostConnectionChecks(props.environmentId);
36 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {
37 if (!props.environmentId || projectScope) return;
4438 setBusy(true);
4539 try {
46 const saved = await update({
47 environmentId: props.environmentId,
48 input: { patch: { deviceHosts: hosts } },
40 const results = await Promise.allSettled(
41 environments.map(async (environment) => {
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";
4963 });
50 if (saved._tag === "Success") {
64 if (failed.length === 0) {
5165 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 });
5272 }
5373 } finally {
5474 setBusy(false);
5575 }
5676 };
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 };
7277 return (
7378 <SettingsRow
7479 id="device-hosts"
7580 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."
7784 control={
7885 <Button
7986 size="sm"
8087 variant="outline"
81 disabled={busy || !props.environmentId || editing !== null}
88 disabled={projectScope || busy || !props.environmentId || editing !== null}
8289 onClick={() => {
90 setOriginalHost(null);
8391 setEditing({ id: randomUUID(), label: "", target: "" });
8492 }}
8593 >
......@@ -90,131 +98,35 @@ export function DeviceHostsSettings(props: {
9098 <div className="pt-3 pb-2">
9199 {!props.environmentId ? (
92100 <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.
94102 </p>
95103 ) : (
96104 <>
97 {props.hosts.map((host) => {
98 const status = state.hostStatuses[host.id];
99 const check = checks[host.id];
100 const platforms =
101 check?.platforms ??
102 state.hosts.find((value) => value.id === host.id)?.platforms ??
103 [];
104 const progress = check?.pending
105 ? "Checking connection…"
106 : status?.status === "installing"
107 ? "Installing device support…"
108 : status?.status === "starting"
109 ? "Connecting…"
110 : null;
111 const error =
112 check?.error ?? (status?.status === "failed" ? status.detail : undefined);
113 return (
114 <div
115 key={host.id}
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 })}
105 {connectedEnvironments.map((environment) => (
106 <div key={environment.environmentId}>
107 {connectedEnvironments.length > 1 ? (
108 <p className="pt-3 pb-1 text-xs font-medium text-muted-foreground">
109 {environment.label}
110 </p>
111 ) : null}
112 <DeviceHostList
113 environmentId={environment.environmentId}
114 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
115 busy={projectScope || busy}
116 onEdit={(host) => {
117 setOriginalHost(host);
118 setEditing(host);
119 }}
120 onRemove={(host) => void save(host, true)}
121 />
122 </div>
123 ))}
212124 {editing ? (
213125 <form
214126 className="space-y-3 border-t border-border/50 py-3"
215127 onSubmit={(event) => {
216128 event.preventDefault();
217 void save([...props.hosts.filter((host) => host.id !== editing.id), editing]);
129 void save(editing, false, originalHost ?? editing);
218130 }}
219131 >
220132 <label className="block space-y-1 text-sm">
......@@ -222,7 +134,7 @@ export function DeviceHostsSettings(props: {
222134 <Input
223135 required
224136 value={editing.label}
225 disabled={busy}
137 disabled={projectScope || busy}
226138 onChange={(event) => setEditing({ ...editing, label: event.target.value })}
227139 placeholder="Mac mini"
228140 />
......@@ -232,7 +144,7 @@ export function DeviceHostsSettings(props: {
232144 <Input
233145 required
234146 value={editing.target}
235 disabled={busy}
147 disabled={projectScope || busy}
236148 onChange={(event) => setEditing({ ...editing, target: event.target.value })}
237149 placeholder="user@host or SSH alias"
238150 />
......@@ -241,7 +153,7 @@ export function DeviceHostsSettings(props: {
241153 <span>Identity file, optional</span>
242154 <Input
243155 value={editing.identityFile ?? ""}
244 disabled={busy}
156 disabled={projectScope || busy}
245157 onChange={(event) => {
246158 const { identityFile: _, ...rest } = editing;
247159 setEditing(
......@@ -258,7 +170,7 @@ export function DeviceHostsSettings(props: {
258170 min={1}
259171 max={65535}
260172 value={editing.port ?? ""}
261 disabled={busy}
173 disabled={projectScope || busy}
262174 onChange={(event) => {
263175 const { port: _, ...rest } = editing;
264176 setEditing(
......@@ -293,13 +205,15 @@ export function DeviceHostsSettings(props: {
293205 }
294206 onClick={() => void testConnection(editing)}
295207 >
296 Test connection
208 {connectedEnvironments.length > 1
209 ? `Test from ${selected?.label}`
210 : "Test connection"}
297211 </Button>
298212 <Button
299213 size="sm"
300214 type="button"
301215 variant="ghost"
302 disabled={busy}
216 disabled={projectScope || busy}
303217 onClick={() => {
304218 setEditing(null);
305219 }}
......@@ -332,3 +246,157 @@ export function DeviceHostsSettings(props: {
332246 </SettingsRow>
333247 );
334248}
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", () => ({
2525 useEnvironments: () => ({ environments: [], isReady: true }),
2626 usePrimaryEnvironment: () => null,
2727}));
28vi.mock("../../hooks/useSettings", () => ({
28vi.mock("../../hooks/useSettings", async (importOriginal) => ({
29 ...(await importOriginal<typeof import("../../hooks/useSettings")>()),
2930 PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment",
30 useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
31 selector(DEFAULT_CLIENT_SETTINGS),
31 useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
32 selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS,
3233 useClientSettingsHydrated: () => true,
3334 usePrimarySettingsAvailable: () => true,
3435 usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS,
......@@ -44,7 +45,10 @@ vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => nul
4445vi.mock("./SettingsScopeContext", () => ({
4546 useSettingsScope: () => ({
4647 scope: { kind: "all", environmentIds: [] },
48 search: {},
4749 environment: null,
50 environments: [],
51 target: null,
4852 connectedEnvironments: [],
4953 targets: [],
5054 }),
apps/web/src/components/settings/IntegrationsSettings.tsx+59-28
......@@ -1,3 +1,5 @@
1import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
2import { ScopedSwitch } from "./ScopedSwitch";
13import { DeviceHostsSettings } from "./DeviceHostsSettings";
24/**
35 * Integrations settings - preferences for surfaces T3 Code embeds rather than
......@@ -13,7 +15,6 @@ import {
1315 type BrowserLinkTarget,
1416 type BrowserProfile,
1517 type EnvironmentId,
16 type SshDeviceHostConfig,
1718 BROWSER_PROFILE_NAME_MAX_LENGTH,
1819 BROWSER_RECORDING_FRAME_RATES,
1920 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
......@@ -565,28 +566,19 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
565566 );
566567}
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 */
573569function DeviceIntegrationSettings() {
574 const { scope, environment: selected, connectedEnvironments } = useSettingsScope();
570 const { search, environment: selected } = useSettingsScope();
571 const settings = useScopedSettings();
575572 const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null;
576573 const environmentId = connected ? selected.environmentId : null;
577 const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1;
578574
579575 return (
580 <SettingsSection
581 id="devices"
582 title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"}
583 >
576 <SettingsSection id="devices" title="Devices">
584577 <DeviceIntegrationControls
585 key={selected?.environmentId ?? "none"}
578 key={`${environmentId}:${JSON.stringify(search)}`}
586579 environmentId={environmentId}
587 hosts={selected?.serverConfig?.settings.deviceHosts ?? []}
588 enabled={selected?.serverConfig?.settings.enableDeviceSupport ?? false}
589 agentAccessEnabled={selected?.serverConfig?.settings.enableAgentDeviceAccess ?? false}
580 enabled={settings.enableDeviceSupport}
581 agentAccessEnabled={settings.enableAgentDeviceAccess}
590582 />
591583 </SettingsSection>
592584 );
......@@ -594,17 +586,21 @@ function DeviceIntegrationSettings() {
594586
595587function DeviceIntegrationControls({
596588 environmentId,
597 hosts,
598589 enabled,
599590 agentAccessEnabled,
600591}: {
601592 environmentId: EnvironmentId | null;
602 hosts: ReadonlyArray<SshDeviceHostConfig>;
603593 enabled: boolean;
604594 agentAccessEnabled: boolean;
605595}) {
606596 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 });
608604 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
609605 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
610606 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
......@@ -622,9 +618,27 @@ function DeviceIntegrationControls({
622618 if (!environmentId) return;
623619 setPending(kind);
624620 try {
625 const result = await configure({ environmentId, input });
626 if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) {
627 await configure({ environmentId, input: { onboardingCompleted: true } });
621 const results = await Promise.allSettled(
622 environments.map(async (environment) => {
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 });
628642 }
629643 } finally {
630644 setPending(null);
......@@ -635,13 +649,16 @@ function DeviceIntegrationControls({
635649 <>
636650 <SettingsRow
637651 {...searchableSetting("device-hub")}
652 serverScoped
653 settingKeys={["enableDeviceSupport"]}
638654 description={deviceHubDescription}
639655 control={
640656 <>
641657 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
642 <Switch
658 <ScopedSwitch
659 settingKeys={["enableDeviceSupport"]}
643660 checked={enabled}
644 disabled={!loaded || !environmentId || busy || pending !== null}
661 disabled={projectScope || !loaded || !environmentId || busy || pending !== null}
645662 aria-label="Device hub"
646663 onCheckedChange={(checked) =>
647664 void update("hub", {
......@@ -657,6 +674,11 @@ function DeviceIntegrationControls({
657674 {platformsRevealed ? (
658675 <SettingsRow
659676 {...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 }
660682 status={
661683 <div className="flex flex-wrap gap-x-5 gap-y-2">
662684 <PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />
......@@ -686,16 +708,25 @@ function DeviceIntegrationControls({
686708 </AnimatedHeight>
687709 <SettingsRow
688710 {...searchableSetting("agent-device-access")}
711 serverScoped
712 settingKeys={["enableAgentDeviceAccess"]}
689713 description={agentDeviceDescription}
690714 control={
691715 <>
692716 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
693 <Switch
717 <ScopedSwitch
718 settingKeys={["enableAgentDeviceAccess"]}
694719 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 }
696725 aria-label="Agent device access"
697726 onCheckedChange={(checked) =>
698 void update("agent", { agentAccessEnabled: Boolean(checked) })
727 projectScope
728 ? updateSettings({ enableAgentDeviceAccess: Boolean(checked) })
729 : void update("agent", { agentAccessEnabled: Boolean(checked) })
699730 }
700731 />
701732 </>
......@@ -706,7 +737,7 @@ function DeviceIntegrationControls({
706737 {state.hostStatusDetail}
707738 </p>
708739 ) : null}
709 <DeviceHostsSettings environmentId={environmentId} hosts={hosts} />
740 <DeviceHostsSettings environmentId={environmentId} />
710741 </>
711742 );
712743}
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", () => {
231231 });
232232 });
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
234247 it("refuses environment-wide keys and older servers at project scope", () => {
235248 expect(
236249 planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),