Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6337264
fix(contracts): old message-sent events without turnId no longer stop…
Mnigos Sep 21, 2026
8129345
fix(devices): safely reclaim obsolete managed tool versions (#12819)
juliusmarminge Sep 21, 2026
de067a0
feat(devices): offer manual updates in tool version details (#12877)
juliusmarminge Sep 21, 2026
f7d2a7e
feat(models): add opus 5.5 without changing existing aliases (#13094)
t3-code[bot] Sep 22, 2026
ef05351
fix(mobile): use a proper pull request icon on iOS (#12855)
juliusmarminge Sep 21, 2026
32bbc49
fix(mobile): stop iOS autocorrect from rewriting search queries (#12949)
juliusmarminge Sep 21, 2026
8d051a0
fix(web): retry failed attachment uploads after reconnect (#10338)
StiensWout Sep 22, 2026
7aa6a8c
fix(server): support updatedAt in ModelManifestEnvelopeSchema
rynfar Sep 22, 2026
15151b5
fix(upstream): reconcile resilience batch with current Pylon
rynfar Sep 24, 2026
c86bc87
chore(upstream): bring resilience batch onto latest pylon
rynfar Sep 24, 2026
fb5dfd0
fix(server): retain fresh model bundle and device installs
rynfar Sep 24, 2026
0776b0b
fix(server): defer unsafe device tool pruning
rynfar Sep 24, 2026
e9701e7
fix(server): skip device hosts that resolve to the local machine (#11…
juliusmarminge Sep 14, 2026
807ae5e
fix(web): check SSH device hosts across selected environments
rynfar Sep 24, 2026
b39fc1a
fix(web): validate SSH host draft and fence scoped checks
rynfar Sep 24, 2026
3629503
fix(web): guard SSH host submit against blank labels
rynfar Sep 24, 2026
4b2070a
fix(models): date bundled catalog after Pylon edits
rynfar Sep 24, 2026
f134b4e
test(web): include scoped environments in integrations fixture
rynfar Sep 24, 2026
78a6575
feat(web): apply device host edits across selected environments
rynfar Sep 24, 2026
26f2a3e
Merge commit 'f134b4e149ca64d05f6bf79211a4bda98a667e75' into upstream…
rynfar Sep 24, 2026
816d286
fix(web): apply device settings to selected environments
rynfar Sep 24, 2026
fdc5a63
Merge commit '4b2070a0f779cc984fc3722a90d3cf55f69fb341' into fix/devi…
rynfar Sep 24, 2026
d8d8cb4
style(web): format device scope regression
rynfar Sep 24, 2026
6653fea
Merge current pylon into upstream resilience batch
rynfar Sep 25, 2026
7c54131
Merge reviewed upstream resilience prerequisite
rynfar Sep 25, 2026
4ad09b3
fix(devices): reconcile selected settings and reject duplicate targets
rynfar Sep 25, 2026
bf23678
Merge current pylon after upstream prerequisite
rynfar Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 153 additions & 39 deletions apps/web/src/components/settings/DeviceHostsSettings.tsx

Large diffs are not rendered by default.

110 changes: 102 additions & 8 deletions apps/web/src/components/settings/IntegrationsSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"

const { listBrowserImportSources, selectedDeviceEnvironment } = vi.hoisted(() => ({
listBrowserImportSources: vi.fn().mockResolvedValue([]),
selectedDeviceEnvironment: { id: null as string | null, aggregate: false, projectScope: false },
selectedDeviceEnvironment: {
id: null as string | null,
aggregate: false,
projectScope: false,
versioned: false,
},
}));

vi.mock("../preview/previewBridge", () => ({
previewBridge: { listBrowserImportSources },
}));
vi.mock("../../env", () => ({ isElectron: true }));
vi.mock("../../state/device", async (importOriginal) => {
const original = await importOriginal<typeof import("../../state/device")>();
return {
...original,
useDeviceState: (environmentId: Parameters<typeof original.useDeviceState>[0]) =>
selectedDeviceEnvironment.versioned
? {
loaded: true,
state: {
hosts: [
{
id: "local",
kind: "local",
label: "This machine",
hubInstalled: true,
agentDeviceInstalled: true,
platforms: [],
tools: {
hub: { requiredVersion: "2", installedVersions: ["1"], runningVersion: "1" },
agent: {
requiredVersion: "2",
installedVersions: ["1"],
runningVersion: "1",
},
},
},
],
hostStatus: "ready",
hostStatuses: {},
devices: [],
sessions: [],
onboardingCompleted: true,
agentAccessEnabled: true,
hubBasePath: "/api/device-hub",
revision: 1,
supportsToolUpdate: true,
supportsToolInspection: true,
} satisfies DeviceServiceState,
}
: original.useDeviceState(environmentId),
};
});
vi.mock("../device/DeviceToolVersions", () => ({
DeviceToolVersions: ({ action, kind }: { action: ReactNode; kind: string }) => (
<div data-tool-version-kind={kind}>{action}</div>
),
}));
vi.mock("../../state/environments", () => ({
useEnvironments: () => ({ environments: [], isReady: true }),
usePrimaryEnvironment: () => null,
}));
vi.mock("../../hooks/useSettings", () => ({
PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment",
useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
selector(DEFAULT_CLIENT_SETTINGS),
mergeEnvironmentSettings: (server: object, client: object) => ({ ...server, ...client }),
useClientSettings: (selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS,
useClientSettingsHydrated: () => true,
usePrimarySettingsAvailable: () => true,
usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS,
Expand All @@ -44,6 +97,7 @@ vi.mock("./settingsLayout", async (importOriginal) => ({
vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null }));
vi.mock("./SettingsScopeContext", () => ({
useSettingsScope: () => ({
search: {},
scope: {
kind: selectedDeviceEnvironment.projectScope ? "project" : "all",
environmentIds: selectedDeviceEnvironment.aggregate ? ["remote", "other"] : [],
Expand All @@ -56,7 +110,20 @@ vi.mock("./SettingsScopeContext", () => ({
serverConfig: { settings: DEFAULT_UNIFIED_SETTINGS },
}
: null,
connectedEnvironments: selectedDeviceEnvironment.aggregate ? [{}, {}] : [],
connectedEnvironments: selectedDeviceEnvironment.aggregate
? [
{
environmentId: "remote",
label: "Selected remote",
serverConfig: { settings: DEFAULT_UNIFIED_SETTINGS },
},
{
environmentId: "other",
label: "Other",
serverConfig: { settings: DEFAULT_UNIFIED_SETTINGS },
},
]
: [],
environments: selectedDeviceEnvironment.aggregate
? [
{ environmentId: "remote", label: "Selected remote", connection: { phase: "connected" } },
Expand All @@ -79,6 +146,7 @@ beforeEach(() => {
selectedDeviceEnvironment.id = null;
selectedDeviceEnvironment.aggregate = false;
selectedDeviceEnvironment.projectScope = false;
selectedDeviceEnvironment.versioned = false;
});

afterEach(async () => {
Expand Down Expand Up @@ -121,7 +189,7 @@ describe("Integrations browser discovery", () => {
expect(sections.indexOf("devices")).toBeGreaterThan(sections.indexOf("browser"));
});

it("names the header's representative device environment without a second selector", async () => {
it("shows the selected device settings without a second selector", async () => {
selectedDeviceEnvironment.id = "selected-remote";
selectedDeviceEnvironment.aggregate = true;
await openSettings();
Expand All @@ -130,25 +198,51 @@ describe("Integrations browser discovery", () => {
)[0]!;
expect(
section.findAll((node) => node.type === "h2").map((node) => node.children.join("")),
).toContain("Devices · Selected remote");
).toContain("Devices");
expect(
section.findAll((node) => node.props["aria-label"] === "Device environment"),
).toHaveLength(0);
});

it("keeps environment device helpers but removes their permission switch at a project scope", async () => {
it("keeps environment device helpers and the project-scoped permission switch", async () => {
selectedDeviceEnvironment.projectScope = true;
await openSettings();
const section = renderer!.root.findAll(
(node) => node.type === "section" && node.props.id === "devices",
)[0]!;
expect(
section.findAll((node) => node.props["aria-label"] === "Agent device access"),
).toHaveLength(0);
).not.toHaveLength(0);
expect(
section.findAll((node) => node.props["aria-label"] === "Device hub").length,
).toBeGreaterThan(0);
});

it("offers manual tool updates only at environment scope while retaining project inspection", async () => {
selectedDeviceEnvironment.id = "selected-remote";
selectedDeviceEnvironment.aggregate = true;
selectedDeviceEnvironment.versioned = true;
selectedDeviceEnvironment.projectScope = true;
await openSettings();
const project = renderer!.root.findAll(
(node) => node.type === "section" && node.props.id === "devices",
)[0]!;
expect(
project.findAll((node) => node.props["aria-label"] === "Agent device access"),
).not.toHaveLength(0);
expect(project.findAll((node) => node.children.includes("Check versions"))).not.toHaveLength(0);
expect(project.findAll((node) => node.children.includes("Update to v2"))).toHaveLength(0);

await act(() => renderer?.unmount());
selectedDeviceEnvironment.projectScope = false;
await openSettings();
const environment = renderer!.root.findAll(
(node) => node.type === "section" && node.props.id === "devices",
)[0]!;
expect(environment.findAll((node) => node.children.includes("Update to v2"))).not.toHaveLength(
0,
);
});
});

const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceServiceState => ({
Expand Down
122 changes: 76 additions & 46 deletions apps/web/src/components/settings/IntegrationsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { DeviceHostUpdates } from "../device/DeviceHostUpdates";
import { DeviceToolVersions } from "../device/DeviceToolVersions";
import { ComputerIntegrationSettings } from "./ComputerIntegrationSettings";
import { DeviceHostsSettings } from "./DeviceHostsSettings";
import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
import { ScopedSwitch } from "./ScopedSwitch";
import { configureSelectedDeviceEnvironments } from "./deviceIntegrationSettings.logic";
/**
* Integrations settings - preferences for surfaces Pylon embeds rather than
* owns. Browser is the first section: the defaults a preview tab opens at,
Expand Down Expand Up @@ -606,25 +609,21 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
);
}

/** Device helpers and hosts belong to the environment selected by the settings header. */
/** Device status is shown for the representative; settings follow the selected scope. */
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 (
<SettingsSection
id="devices"
title={aggregate && selected ? `Devices · ${selected.label}` : "Devices"}
>
<SettingsSection id="devices" title="Devices">
<DeviceIntegrationControls
key={selected?.environmentId ?? "none"}
key={`${environmentId}:${JSON.stringify(search)}`}
environmentId={environmentId}
hosts={selected?.serverConfig?.settings.deviceHosts ?? []}
enabled={selected?.serverConfig?.settings.enableDeviceSupport ?? false}
agentAccessEnabled={selected?.serverConfig?.settings.enableAgentDeviceAccess ?? false}
showAgentAccess={scope.kind !== "project" && scope.kind !== "checkout"}
enabled={settings.enableDeviceSupport}
agentAccessEnabled={settings.enableAgentDeviceAccess}
/>
</SettingsSection>
);
Expand All @@ -635,16 +634,20 @@ function DeviceIntegrationControls({
hosts,
enabled,
agentAccessEnabled,
showAgentAccess,
}: {
environmentId: EnvironmentId | null;
hosts: ReadonlyArray<SshDeviceHostConfig>;
enabled: boolean;
agentAccessEnabled: boolean;
showAgentAccess: 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" | "update-hub" | "update-agent" | null
Expand All @@ -664,9 +667,23 @@ 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 failed = await configureSelectedDeviceEnvironments(
environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
connected: environment.connection.phase === "connected",
loaded: environment.serverConfig !== null,
})),
input,
async (environmentId, selectedInput) =>
(await configure({ environmentId, input: selectedInput }))._tag === "Success",
);
if (failed.length > 0) {
toastManager.add({
type: "error",
title: "Device settings not saved on all environments",
description: `Could not update ${failed.join(", ")}.`,
});
}
} finally {
setPending(null);
Expand All @@ -683,7 +700,7 @@ function DeviceIntegrationControls({
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{state.supportsToolUpdate && needsUpdate ? (
{!projectScope && state.supportsToolUpdate && needsUpdate ? (
<Button
size="sm"
disabled={!environmentId || pending !== null || busy}
Expand Down Expand Up @@ -736,6 +753,8 @@ function DeviceIntegrationControls({
<>
<SettingsRow
{...searchableSetting("device-hub")}
serverScoped
settingKeys={["enableDeviceSupport"]}
description={deviceHubDescription}
control={
<>
Expand All @@ -745,9 +764,10 @@ function DeviceIntegrationControls({
tools={state.hosts.find((host) => host.kind === "local")?.tools}
/>
{pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
<Switch
<ScopedSwitch
settingKeys={["enableDeviceSupport"]}
checked={enabled}
disabled={!loaded || !environmentId || busy || pending !== null}
disabled={projectScope || !loaded || !environmentId || busy || pending !== null}
aria-label="Device hub"
onCheckedChange={(checked) =>
void update("hub", {
Expand All @@ -763,6 +783,11 @@ function DeviceIntegrationControls({
{platformsRevealed ? (
<SettingsRow
{...searchableSetting("device-platform-support")}
description={
connectedEnvironments.length > 1
? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.`
: undefined
}
status={
<div className="flex flex-wrap gap-x-5 gap-y-2">
<PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />
Expand Down Expand Up @@ -790,32 +815,37 @@ function DeviceIntegrationControls({
/>
) : null}
</AnimatedHeight>
{showAgentAccess ? (
<SettingsRow
{...searchableSetting("agent-device-access")}
description={agentDeviceDescription}
control={
<>
<DeviceToolVersions
action={versionActions("agent")}
kind="agent"
tools={state.hosts.find((host) => host.kind === "local")?.tools}
/>
{pending === "agent" ? (
<AgentDeviceSetupStatus state={state} pending compact />
) : null}
<Switch
checked={agentAccessEnabled}
disabled={!loaded || !environmentId || !enabled || busy || pending !== null}
aria-label="Agent device access"
onCheckedChange={(checked) =>
void update("agent", { agentAccessEnabled: Boolean(checked) })
}
/>
</>
}
/>
) : null}
<SettingsRow
{...searchableSetting("agent-device-access")}
serverScoped
settingKeys={["enableAgentDeviceAccess"]}
description={agentDeviceDescription}
control={
<>
<DeviceToolVersions
action={versionActions("agent")}
kind="agent"
tools={state.hosts.find((host) => host.kind === "local")?.tools}
/>
{pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
<ScopedSwitch
settingKeys={["enableAgentDeviceAccess"]}
checked={agentAccessEnabled}
disabled={
connectedEnvironments.length === 0 ||
(!projectScope && (!loaded || !anyHubEnabled || busy)) ||
pending !== null
}
aria-label="Agent device access"
onCheckedChange={(checked) =>
projectScope
? updateSettings({ enableAgentDeviceAccess: Boolean(checked) })
: void update("agent", { agentAccessEnabled: Boolean(checked) })
}
/>
</>
}
/>
{environmentId ? (
<DeviceHostUpdates
state={{ ...state, hosts: state.hosts.filter((host) => host.kind === "local") }}
Expand Down
Loading
Loading