- router.push(
- executorProfileSettingsPath(
- { id: executorId, type: executorType },
- profile.id,
- ),
- )
- }
+ onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
{profile.name}
diff --git a/apps/web/components/settings/legacy-executor-settings-route.test.tsx b/apps/web/components/settings/legacy-executor-settings-route.test.tsx
index 61f4e8fd09..e551f64646 100644
--- a/apps/web/components/settings/legacy-executor-settings-route.test.tsx
+++ b/apps/web/components/settings/legacy-executor-settings-route.test.tsx
@@ -1,10 +1,12 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { useEffect } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { StateProvider } from "@/components/state-provider";
+import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
import { LegacyExecutorSettingsRoute } from "./legacy-executor-settings-route";
+const LEGACY_EXECUTOR_TESTID = "legacy-executor";
const { replace } = vi.hoisted(() => ({ replace: vi.fn() }));
vi.mock("@/lib/routing/client-router", () => ({
@@ -13,27 +15,25 @@ vi.mock("@/lib/routing/client-router", () => ({
vi.mock("@/app/settings/executor/[id]/page", () => ({
default: ({ executorId }: { executorId: string }) => (
-
{executorId}
- ),
-}));
-
-vi.mock("@/app/settings/executor/[id]/profile/[profileId]/page", () => ({
- default: ({ profileId }: { profileId: string }) => (
-
{profileId}
+
{executorId}
),
}));
const TIMESTAMP = "2026-08-24T00:00:00Z";
+const PROFILE_ID = "profile/primary";
+const CANONICAL_PROFILE_PATH = "/settings/executors/profile%2Fprimary";
+const PROFILE_UNAVAILABLE_TESTID = "executor-profile-unavailable";
afterEach(() => {
cleanup();
replace.mockReset();
+ window.history.replaceState({}, "", "/settings");
});
function executor(type: Executor["type"]): Executor {
const id = "executor/primary";
const profile: ExecutorProfile = {
- id: "profile/primary",
+ id: PROFILE_ID,
executor_id: id,
executor_type: type,
name: "Primary profile",
@@ -57,11 +57,17 @@ function executor(type: Executor["type"]): Executor {
};
}
-function renderRoute(record: Executor, profileId?: string) {
+function renderRoute(
+ record: Executor | null,
+ profileId?: string,
+ executorId?: string,
+ options: { executorsLoaded?: boolean } = {},
+) {
render(
-
+
,
);
}
+function HydrateExecutors({ record }: { record: Executor }) {
+ const store = useAppStoreApi();
+ useEffect(() => {
+ store.getState().setExecutors([record]);
+ store.getState().setSettingsData({ executorsLoaded: true });
+ }, [record, store]);
+ return null;
+}
+
+it("waits for executor hydration before rendering an unavailable profile state", () => {
+ renderRoute(null, PROFILE_ID, "executor/missing", { executorsLoaded: false });
+
+ expect(screen.queryByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeNull();
+ expect(replace).not.toHaveBeenCalled();
+});
+
describe("LegacyExecutorSettingsRoute", () => {
it("redirects a member's bookmarked Kubernetes executor before mounting legacy controls", async () => {
renderRoute(executor("k8s"));
- await waitFor(() =>
- expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
- );
- expect(screen.queryByTestId("legacy-executor")).toBeNull();
+ await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
+ expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
});
it("keeps an orphaned Kubernetes executor on the connection recovery page", async () => {
@@ -97,32 +120,106 @@ describe("LegacyExecutorSettingsRoute", () => {
await waitFor(() =>
expect(replace).toHaveBeenCalledWith("/settings/executors/k8s/executor%2Fprimary"),
);
- expect(screen.queryByTestId("legacy-executor")).toBeNull();
+ expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
});
it("redirects a bookmarked Kubernetes profile to the Kubernetes-aware editor", async () => {
- renderRoute(executor("k8s"), "profile/primary");
+ renderRoute(executor("k8s"), PROFILE_ID);
- await waitFor(() =>
- expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
- );
+ await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
expect(screen.queryByTestId("legacy-profile")).toBeNull();
});
it("does not redirect a Kubernetes executor to a profile owned by another executor", () => {
renderRoute(executor("k8s"), "profile/from-another-executor");
- expect(screen.getByTestId("legacy-profile")).toBeTruthy();
+ expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
it.each([
- { profileId: undefined, testId: "legacy-executor" },
- { profileId: "profile/primary", testId: "legacy-profile" },
- ])("keeps SSH on its legacy scoped page", ({ profileId, testId }) => {
- renderRoute(executor("ssh"), profileId);
+ "local",
+ "local_docker",
+ "ssh",
+ "sprites",
+ "local_pc",
+ "worktree",
+ "remote_docker",
+ ] as const)("redirects a %s profile bookmark to the canonical editor", async (type) => {
+ renderRoute(executor(type), PROFILE_ID);
- expect(screen.getByTestId(testId)).toBeTruthy();
+ await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
+ expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
+ });
+
+ it("keeps an executor-only SSH route on the connection editor", () => {
+ renderRoute(executor("ssh"));
+
+ expect(screen.getByTestId(LEGACY_EXECUTOR_TESTID)).toBeTruthy();
expect(replace).not.toHaveBeenCalled();
});
+
+ it("renders an unavailable state for a missing profile without choosing another profile", () => {
+ const record = executor("local_docker");
+ record.profiles = [
+ {
+ ...record.profiles![0],
+ id: "profile/other",
+ },
+ ];
+
+ renderRoute(record, PROFILE_ID);
+
+ expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
+ expect(replace).not.toHaveBeenCalled();
+ });
+
+ it("renders an unavailable state for a missing executor", () => {
+ renderRoute(null, PROFILE_ID, "executor/missing");
+
+ expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
+ expect(replace).not.toHaveBeenCalled();
+ });
+
+ it("preserves query parameters and fragments while replacing a valid bookmark", async () => {
+ window.history.replaceState(
+ {},
+ "",
+ "/settings/executor/executor%2Fprimary/profile/profile%2Fprimary?tab=advanced#docker",
+ );
+ renderRoute(executor("ssh"), PROFILE_ID);
+
+ await waitFor(() =>
+ expect(replace).toHaveBeenCalledWith(
+ "/settings/executors/profile%2Fprimary?tab=advanced#docker",
+ ),
+ );
+ });
+
+ it("retries a valid bookmark when executor state hydrates after the route mounts", async () => {
+ const record = executor("ssh");
+ render(
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
+ });
});
diff --git a/apps/web/components/settings/legacy-executor-settings-route.tsx b/apps/web/components/settings/legacy-executor-settings-route.tsx
index dd60ec490e..751f081163 100644
--- a/apps/web/components/settings/legacy-executor-settings-route.tsx
+++ b/apps/web/components/settings/legacy-executor-settings-route.tsx
@@ -1,13 +1,36 @@
"use client";
import ExecutorEditPage from "@/app/settings/executor/[id]/page";
-import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page";
+import { Button } from "@kandev/ui/button";
+import { Card, CardContent } from "@kandev/ui/card";
import { useAppStore } from "@/components/state-provider";
+import { useRouter } from "@/lib/routing/client-router";
import {
executorConnectionSettingsPath,
executorProfileSettingsPath,
} from "@/lib/settings/executor-settings-routes";
+import type { Executor } from "@/lib/types/http";
import { SettingsRedirect } from "@/src/settings-route-helpers";
+import { useTranslation } from "react-i18next";
+
+const EXECUTORS_ROUTE = "/settings/executors";
+
+function ProfileUnavailableState({ executor }: { executor: Executor | null }) {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const destination = executor ? executorConnectionSettingsPath(executor) : EXECUTORS_ROUTE;
+
+ return (
+
+
+ {t("executors:profileNotFound")}
+
+
+
+ );
+}
export function LegacyExecutorSettingsRoute({
executorId,
@@ -19,23 +42,23 @@ export function LegacyExecutorSettingsRoute({
const executor = useAppStore(
(state) => state.executors.items.find((item) => item.id === executorId) ?? null,
);
+ const executorsLoaded = useAppStore((state) => state.settingsData.executorsLoaded);
+
+ if (profileId !== undefined) {
+ if (!executorsLoaded) return null;
+ const profile = executor?.profiles?.find(
+ (candidate) => candidate.id === profileId && candidate.executor_id === executorId,
+ );
+ if (!executor || !profile) return
;
+ return
;
+ }
if (executor?.type === "k8s") {
- const destinationProfileId = profileId
- ? executor.profiles?.find((profile) => profile.id === profileId)?.id
- : executor.profiles?.[0]?.id;
- if (profileId && !destinationProfileId) {
- return
;
- }
- const destination = destinationProfileId
- ? executorProfileSettingsPath(executor, destinationProfileId)
+ const destination = executor.profiles?.[0]?.id
+ ? executorProfileSettingsPath(executor.profiles[0].id)
: executorConnectionSettingsPath(executor);
return
;
}
- return profileId ? (
-
- ) : (
-
- );
+ return
;
}
diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx
index cdd6bc2706..1349a7d192 100644
--- a/apps/web/components/task-create-dialog-form-body.tsx
+++ b/apps/web/components/task-create-dialog-form-body.tsx
@@ -18,6 +18,7 @@ import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
import { RUNNER_INELIGIBLE_REASON_KEYS } from "@/components/task-create-dialog-helpers";
+import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useTranslation } from "react-i18next";
type SelectorOption = {
@@ -89,7 +90,7 @@ type AgentColumnProps = Pick<
>;
function credentialsHref(executorProfileId: string): string {
- return executorProfileId ? `/settings/executors/${executorProfileId}` : "/settings/executors";
+ return executorProfileId ? executorProfileSettingsPath(executorProfileId) : "/settings/executors";
}
function useExecutorTarget(executorProfileName: string | null): string {
diff --git a/apps/web/components/task/executor-environment-disclosure.tsx b/apps/web/components/task/executor-environment-disclosure.tsx
index 93a0c4165f..444198bcc0 100644
--- a/apps/web/components/task/executor-environment-disclosure.tsx
+++ b/apps/web/components/task/executor-environment-disclosure.tsx
@@ -93,10 +93,7 @@ function KubernetesActions({
touch ? "h-11 w-11" : "h-10 w-10",
);
const settingsPath = env.executor_profile_id
- ? executorProfileSettingsPath(
- { id: env.executor_id, type: env.executor_type },
- env.executor_profile_id,
- )
+ ? executorProfileSettingsPath(env.executor_profile_id)
: kubernetesExecutorSettingsPath(env.executor_id);
return (
{
+ test("converges desktop entry points on the complete Docker profile editor", async ({
+ testPage,
+ apiClient,
+ }) => {
+ test.setTimeout(60_000);
+ const executor = await apiClient.createExecutor("E2E profile routing executor", "local_docker");
+ const profile = await apiClient.createExecutorProfile(executor.id, {
+ name: "E2E profile routing Docker",
+ config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
+ prepare_script: "",
+ cleanup_script: "",
+ env_vars: [],
+ });
+
+ await testPage.route("**/api/v1/docker/build", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/x-ndjson",
+ body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
+ });
+ });
+
+ try {
+ await testPage.goto("/settings/executors");
+ await testPage.getByText(profile.name, { exact: true }).click();
+ await expectDockerProfileEditor(testPage, profile.id);
+
+ const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
+ predicate: (response) => response.ok(),
+ });
+ await testPage.getByRole("button", { name: "Build Image" }).click();
+ await buildResponse;
+ await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
+
+ await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
+ const floatingSave = testPage.getByTestId("settings-floating-save");
+ const saveResponse = waitForHttp(
+ testPage,
+ "PATCH",
+ /^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
+ { predicate: (response) => response.ok() },
+ );
+ await floatingSave.getByRole("button", { name: "Save changes" }).click();
+ await saveResponse;
+ await expect(testPage.getByText("Profile saved")).toBeVisible();
+ await expect
+ .poll(
+ async () =>
+ (await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
+ )
+ .toBe(EDITED_IMAGE_TAG);
+
+ await testPage.reload();
+ await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
+
+ await setSettingsMenuMode(testPage, "accordion");
+ const settingsTree = testPage.getByTestId("app-sidebar-settings-mode");
+ const expandExecutors = settingsTree.getByRole("button", { name: /Expand Executors/i });
+ if (await expandExecutors.count()) await expandExecutors.click();
+ const expandExecutor = settingsTree.getByRole("button", {
+ name: `Expand ${executor.name}`,
+ });
+ if (await expandExecutor.count()) await expandExecutor.click();
+ const profileLink = settingsTree.locator(`a[href="${canonicalProfilePath(profile.id)}"]`);
+ await expect(profileLink).toBeVisible({ timeout: 10_000 });
+ await profileLink.click();
+ await expectDockerProfileEditor(testPage, profile.id);
+
+ await testPage.goto(`/settings/executor/${encodeURIComponent(executor.id)}`);
+ await testPage.getByText(profile.name, { exact: true }).click();
+ await expectDockerProfileEditor(testPage, profile.id);
+
+ const legacyBookmark =
+ `/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
+ "?tab=advanced#docker";
+ await testPage.goto(legacyBookmark);
+ await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
+ await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
+ await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
+
+ await testPage.locator("#image-tag").fill(DRAFT_IMAGE_TAG);
+ const executorsLink = settingsTree.locator('a[href="/settings/executors"]');
+ await executorsLink.click();
+ const leaveDialog = testPage.getByRole("alertdialog");
+ await expect(leaveDialog).toBeVisible();
+ await leaveDialog.getByRole("button", { name: "Continue editing" }).click();
+ await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
+ await expect(testPage.locator("#image-tag")).toHaveValue(DRAFT_IMAGE_TAG);
+
+ await executorsLink.click();
+ await expect(leaveDialog).toBeVisible();
+ await leaveDialog.getByRole("button", { name: "Discard and leave" }).click();
+ await expect(testPage).toHaveURL(/\/settings\/executors$/);
+
+ await testPage.goto(canonicalProfilePath(profile.id));
+ await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
+ } finally {
+ await apiClient.deleteExecutor(executor.id).catch(() => {});
+ }
+ });
+});
diff --git a/apps/web/e2e/tests/settings/mobile-executor-profile-routing.spec.ts b/apps/web/e2e/tests/settings/mobile-executor-profile-routing.spec.ts
new file mode 100644
index 0000000000..8b485f7b72
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-executor-profile-routing.spec.ts
@@ -0,0 +1,98 @@
+import type { Locator } from "@playwright/test";
+import { expect, test } from "../../fixtures/test-base";
+import { waitForHttp } from "../../helpers/causal-waits";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+
+const INITIAL_IMAGE_TAG = "kandev/e2e:mobile-routing-initial";
+const EDITED_IMAGE_TAG = "kandev/e2e:mobile-routing-edited";
+const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
+
+function canonicalProfilePath(profileId: string): string {
+ return `/settings/executors/${encodeURIComponent(profileId)}`;
+}
+
+async function expectTouchTarget(locator: Locator, label: string) {
+ const box = await locator.boundingBox();
+ expect(box, `${label} must have geometry`).not.toBeNull();
+ expect(box!.height, `${label} must be at least 44px tall`).toBeGreaterThanOrEqual(44);
+}
+
+test("phone profile navigation reaches the complete Docker editor", async ({
+ testPage,
+ apiClient,
+}) => {
+ test.setTimeout(60_000);
+ const executor = await apiClient.createExecutor(
+ "E2E mobile profile routing executor",
+ "local_docker",
+ );
+ const profile = await apiClient.createExecutorProfile(executor.id, {
+ name: "E2E mobile profile routing Docker",
+ config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
+ prepare_script: "",
+ cleanup_script: "",
+ env_vars: [],
+ });
+
+ await testPage.route("**/api/v1/docker/build", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/x-ndjson",
+ body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
+ });
+ });
+
+ try {
+ await testPage.goto("/settings/executors");
+ const profileCard = testPage.getByText(profile.name, { exact: true });
+ await expect(profileCard).toBeVisible();
+ await profileCard.tap();
+ await expect(testPage).toHaveURL(canonicalProfilePath(profile.id));
+
+ await expect(testPage.locator("#image-tag")).toBeVisible();
+ await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
+ const buildButton = testPage.getByRole("button", { name: "Build Image" });
+ await expect(buildButton).toBeVisible();
+ await expectTouchTarget(buildButton, "Mobile Docker build button");
+ const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
+ predicate: (response) => response.ok(),
+ });
+ await buildButton.tap();
+ await buildResponse;
+ await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
+
+ await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
+ const floatingSave = testPage.getByTestId("settings-floating-save");
+ const saveButton = floatingSave.getByRole("button", { name: "Save changes" });
+ await expectTouchTarget(saveButton, "Mobile profile save button");
+ const saveResponse = waitForHttp(
+ testPage,
+ "PATCH",
+ /^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
+ { predicate: (response) => response.ok() },
+ );
+ await saveButton.tap();
+ await saveResponse;
+ await expect(testPage.getByText("Profile saved")).toBeVisible();
+ await expect
+ .poll(
+ async () => (await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
+ )
+ .toBe(EDITED_IMAGE_TAG);
+
+ await testPage.reload();
+ await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
+ await assertNoDocumentHorizontalOverflow(testPage, "mobile canonical executor profile editor");
+
+ const legacyBookmark =
+ `/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
+ "?tab=advanced#docker";
+ await testPage.goto(legacyBookmark);
+ await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
+ await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
+ await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
+ await assertNoDocumentHorizontalOverflow(testPage, "mobile legacy executor profile bookmark");
+ } finally {
+ await apiClient.deleteExecutor(executor.id).catch(() => {});
+ }
+});
diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts
index b8f1427a35..e1ec8fcb65 100644
--- a/apps/web/lib/settings-discovery/catalog.test.ts
+++ b/apps/web/lib/settings-discovery/catalog.test.ts
@@ -197,9 +197,13 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
],
executors: [
{
- type: "docker",
+ type: "local_docker",
profiles: [{ id: "executor / one", name: "Docker Local" }],
},
+ {
+ type: "ssh",
+ profiles: [{ id: "ssh profile / two", name: "SSH Remote" }],
+ },
],
});
@@ -212,6 +216,9 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
expect(resolved.find((entry) => entry.id === "executor-profile:executor / one")?.href).toBe(
"/settings/executors/executor%20%2F%20one",
);
+ expect(resolved.find((entry) => entry.id === "executor-profile:ssh profile / two")?.href).toBe(
+ "/settings/executors/ssh%20profile%20%2F%20two",
+ );
expect(resolved.find((entry) => entry.id === "workspace:workspace / one:name")?.href).toBe(
"/settings/workspaces/workspace%20%2F%20one#setting-workspace-workspace%20%2F%20one-name",
);
diff --git a/apps/web/lib/settings-discovery/resolve.ts b/apps/web/lib/settings-discovery/resolve.ts
index 9c2865437f..308dda0f11 100644
--- a/apps/web/lib/settings-discovery/resolve.ts
+++ b/apps/web/lib/settings-discovery/resolve.ts
@@ -11,6 +11,7 @@ import {
executorProfileDiscoveryTarget,
workspaceDiscoveryTarget,
} from "./dynamic-targets";
+import { executorProfileSettingsPath } from "../settings/executor-settings-routes";
function workspaceDefinitions(
workspaces: SettingsDiscoveryContext["workspaces"],
@@ -147,7 +148,7 @@ function executorProfileDefinitions(
for (const [executorIndex, executor] of executors.entries()) {
for (const [profileIndex, profile] of (executor.profiles ?? []).entries()) {
const profileId = `executor-profile:${profile.id}`;
- const href = `/settings/executors/${encodeURIComponent(profile.id)}`;
+ const href = executorProfileSettingsPath(profile.id);
const order = 30_000 + executorIndex * 100 + profileIndex * 10;
entries.push({
id: profileId,
diff --git a/apps/web/lib/settings/executor-settings-routes.test.ts b/apps/web/lib/settings/executor-settings-routes.test.ts
new file mode 100644
index 0000000000..0f5414ec57
--- /dev/null
+++ b/apps/web/lib/settings/executor-settings-routes.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+
+import { executorProfileSettingsPath } from "./executor-settings-routes";
+
+describe("executorProfileSettingsPath", () => {
+ // @covers AC-EXECUTORS-PROFILE-EDITOR-001.1
+ it("routes every profile to the canonical editor", () => {
+ expect(executorProfileSettingsPath("profile/primary")).toBe(
+ "/settings/executors/profile%2Fprimary",
+ );
+ });
+});
diff --git a/apps/web/lib/settings/executor-settings-routes.ts b/apps/web/lib/settings/executor-settings-routes.ts
index 382f9efe06..f370a71582 100644
--- a/apps/web/lib/settings/executor-settings-routes.ts
+++ b/apps/web/lib/settings/executor-settings-routes.ts
@@ -13,11 +13,6 @@ export function executorConnectionSettingsPath(executor: ExecutorRouteIdentity):
: `/settings/executor/${encodeURIComponent(executor.id)}`;
}
-export function executorProfileSettingsPath(
- executor: ExecutorRouteIdentity,
- profileId: string,
-): string {
- return executor.type === "k8s"
- ? `/settings/executors/${encodeURIComponent(profileId)}`
- : `${executorConnectionSettingsPath(executor)}/profile/${encodeURIComponent(profileId)}`;
+export function executorProfileSettingsPath(profileId: string): string {
+ return `/settings/executors/${encodeURIComponent(profileId)}`;
}
diff --git a/docs/plans/executor-profile-editor-unification/plan.md b/docs/plans/executor-profile-editor-unification/plan.md
new file mode 100644
index 0000000000..3b5cc98dea
--- /dev/null
+++ b/docs/plans/executor-profile-editor-unification/plan.md
@@ -0,0 +1,226 @@
+---
+created: 2026-09-17
+status: complete
+requirements:
+ - REQ-EXECUTORS-PROFILE-EDITOR-001
+system_design:
+ - ../../specs/executors/system-design/profile-editor.md
+legacy_specs: []
+---
+
+# Implementation Plan: Unified executor profile editor
+
+## Overview
+
+Repair [issue #3740](https://github.com/kdlbs/kandev/issues/3740) by routing all
+profile entry points to the existing complete editor. One sequential work
+order covers route generation, bookmark compatibility, and regression tests.
+
+The [requirement](../../specs/executors/requirements/profile-editor.md) and
+[design](../../specs/executors/system-design/profile-editor.md) define the
+implementation boundary. Task 01 is complete.
+
+## Evidence and root cause
+
+Investigation used commit `f32eb373781bcec12583c37e4a9d52b1ee12072f`, also the
+local `origin/main` reference. The issue contained no comments or attachments.
+
+The hub hardcodes the plural route. `executorProfileSettingsPath` generates an
+executor-scoped singular route for every type except `k8s`.
+`renderExecutorSettingsRoute` then mounts `LegacyExecutorSettingsRoute`, which
+renders a separate reduced form for non-Kubernetes profiles.
+
+A read-only Node execution of the actual helper produced:
+
+| Type | Destination for `exec-1` / `profile-1` |
+| ---------------------------------------------------------- | --------------------------------------------- |
+| local, worktree, local_docker, remote_docker, ssh, sprites | `/settings/executor/exec-1/profile/profile-1` |
+| k8s | `/settings/executors/profile-1` |
+
+A source trace found `DockerSections`, `SSHAgentReadinessCard`,
+`SSHTaskDirReclamationCard`, `KubernetesProfileSections`, `SpritesSections`,
+and `McpPolicyCard` only in the complete editor. Existing component tests
+explicitly expect the reduced SSH route, so passing tests currently preserve
+the defect.
+
+Smallest user reproduction: open a saved Local Docker profile from the hub,
+then from the desktop settings tree. Compare the URL and the Dockerfile,
+image-tag, and build controls. This investigation used executable route and
+source evidence, not a live browser reproduction.
+
+The issue's remote-Docker connection-card claim is not a requirement to invent
+new controls. This repair preserves the complete editor's current capabilities
+and existing executor connection pages.
+
+## Scope
+
+### In scope
+
+- Canonical profile URLs from every production entry point.
+- Valid legacy bookmark replacement and invalid-pair recovery.
+- One editor implementation with existing type-specific controls and save behavior.
+- Desktop and phone regression coverage.
+
+### Out of scope
+
+- Runtime execution, new controls, connection redesign, backend changes, and migrations.
+- Generic settings redesign or unrelated form cleanup.
+
+## Technical approach
+
+Change `executorProfileSettingsPath` to accept one profile ID. Migrate all
+existing callers and hardcoded production profile destinations. Keep executor
+connection helpers unchanged.
+
+Handle explicit profile IDs first in `LegacyExecutorSettingsRoute`. Validate
+executor/profile ownership before redirecting. Preserve executor-only behavior
+and the unavailable-profile state. Remove the reduced editor's form logic while
+keeping the legacy route adapter for old bookmarks.
+
+Reuse the complete editor and `SettingsRedirect`. Preserve discovery fragments,
+encoded identifiers, shared save coordination, and existing role gates.
+
+No existing executor requirement explicitly covers entry-point parity. The new
+vertical requirement belongs to executors, not the reusable UI system. The
+existing card-spacing contract remains a dependency. No new architectural
+ownership boundary or persistence model requires an ADR.
+
+## ASCII UI preview
+
+### UI-01: Saved Docker profile, desktop
+
+Entry: hub card, settings tree, executor profile list, task disclosure, or bookmark.
+
+```text
+BEFORE: sidebar AFTER: every entry point
++-----------------------+ +----------+---------------------------+
+| Profile details | | Settings | Profile name |
+| Environment variables | | tree | Profile details |
+| Prepare / cleanup | | | Dockerfile + image tag |
+| No Docker build card | | | [Build Image] |
++-----------------------+ | | Applicable credentials |
+ | | Environment / scripts |
+ | | MCP policy |
+ +----------+---------------------------+
+ [Save changes] when dirty
+```
+
+### UI-02: Saved Docker profile, phone
+
+Entry: Settings index > Executors > profile card, or a valid bookmark.
+
+```text
++-----------------------------+
+| Settings > Executors |
+| Profile name |
+| Profile details |
+| Dockerfile + image tag |
+| [Build Image] |
+| Applicable credentials |
+| Environment / scripts |
+| MCP policy |
+| |
+| [Save changes] when dirty |
++-----------------------------+
+```
+
+The phone opens a full settings destination without a desktop sidebar. Both
+views retain one content scroll region and the shared floating save control.
+Cards and labels are illustrative. Existing card order, type applicability,
+localized copy, and spacing remain authoritative. Controls stay inside the
+viewport, and the save control clears the safe area.
+
+### UI-03: Invalid legacy bookmark
+
+```text
++-----------------------------+
+| Profile not found |
+| [Back to executor] |
++-----------------------------+
+```
+
+Use the existing localized recovery control. Missing executors can recover to
+the hub. No editor or save control mounts. Views map to AC-001.1 through
+AC-001.6 under `AC-EXECUTORS-PROFILE-EDITOR`.
+
+## Tests
+
+The work order lists exact paths and commands. Required regression cases:
+
+| Criteria | Evidence |
+| -------- | ------------------------------------------------------------------------- |
+| .1, .7 | Helper unit tests, sidebar/profile-list tests, discovery and route tests |
+| .2, .5 | Desktop Docker edit/build/save/reload plus existing executor-specific E2E |
+| .3, .4 | Legacy route component tests for valid, missing, and mismatched pairs |
+| .5 | Discard, unsaved-navigation guard, and unchanged role gates |
+| .6 | Phone navigation, save/reload, and viewport containment |
+
+First failing regression: `routes every profile to the canonical editor` in
+`apps/web/lib/settings/executor-settings-routes.test.ts`. It failed against the
+old two-argument helper for non-Kubernetes profiles, then passed after the
+helper and all production callers migrated to the canonical route.
+
+## E2E tests
+
+Add `settings/executor-profile-routing.spec.ts` for the `chromium` project.
+Open one disposable Docker profile through hub, sidebar, profile list, and
+legacy bookmark. Assert the same ID and Docker controls. Mock one successful
+build, save an image-tag edit, reload, and verify persistence. Exercise discard
+and unsaved navigation. Cover task-disclosure navigation with its existing
+seed/store fixture pattern.
+
+Add `settings/mobile-executor-profile-routing.spec.ts` for `mobile-chrome`.
+Tap the hub profile card, edit, save, reload, and open the legacy bookmark.
+Assert complete controls, safe save reachability, and no horizontal overflow.
+Use disposable records and clean them in `finally`.
+
+Existing Docker persistence, Kubernetes settings, SSH connection-link, and
+executor-agent-config suites provide the type-specific and permission checks.
+No Docker daemon is required for these settings tests.
+
+## Work orders
+
+- [x] [Task 01: Unify profile navigation and bookmark handling](task-01-unify-profile-editor.md) (done)
+
+## Verification results
+
+Design validation on 2026-09-17:
+
+- `python3 scripts/list-docs.py validate`: passed (285 decisions, 988 specifications).
+- `python3 scripts/lint-spec-files.test.py`: passed (36 tests).
+- `python3 scripts/lint-spec-files.py --all`: passed.
+- `git diff --check -- docs/specs docs/plans/executor-profile-editor-unification`: passed.
+- `git status --short -- docs/plans/executor-profile-editor-unification`: both new plan files accounted for.
+- Baseline Vitest run: 43 tests passed across `legacy-executor-settings-route.test.tsx`,
+ `executor-profiles-card.test.tsx`, and `settings-menu-branches.test.ts`.
+- Read-only helper execution and section source trace reproduced the route split.
+- Issue assignment: `carlosflorencio`.
+
+Implementation validation on 2026-09-17:
+
+- `pnpm exec vitest run lib/settings/executor-settings-routes.test.ts components/settings/legacy-executor-settings-route.test.tsx components/settings/executor-profiles-card.test.tsx components/settings/executor-profile-navigation.test.tsx components/app-sidebar/sections/settings/settings-menu-branches.test.ts src/settings-routes.test.ts src/settings-route-helpers.test.ts lib/settings-discovery/catalog.test.ts components/settings/settings-layout-client.test.tsx`: 9 files and 126 tests passed.
+- `pnpm run typecheck`: passed.
+- `pnpm run i18n:check`: passed; 8,397 referenced keys and complete required catalogs.
+- `pnpm e2e:run --project chromium tests/settings/executor-profile-routing.spec.ts tests/settings/docker-profile-persistence.spec.ts tests/settings/kubernetes-executor.spec.ts tests/settings/ssh-profile-connection-link.spec.ts tests/settings/executor-agent-config.spec.ts`: 12 tests passed, including the production Vite build.
+- `pnpm e2e:run --project mobile-chrome tests/settings/mobile-executor-profile-routing.spec.ts tests/settings/mobile-kubernetes-executor.spec.ts tests/settings/mobile-ssh-profile-connection-link.spec.ts tests/settings/mobile-executor-agent-config.spec.ts`: 6 tests passed, including the production Vite build.
+- `node --test scripts/validate-public-docs.test.mjs`: 62 tests passed.
+- `node scripts/validate-public-docs.mjs`: 46 published pages validated.
+- `python3 scripts/list-docs.py validate`: passed for 285 decisions and 988 specifications.
+- `python3 scripts/lint-spec-files.py --all`: passed.
+- Explicit ESLint checks for every changed TypeScript/TSX file and Prettier checks for changed source/test files and plan Markdown: passed.
+- `git diff --check`: passed.
+
+The reduced profile editor was removed. The legacy route now validates executor
+ownership, preserves query/hash suffixes, and redirects valid bookmarks to the
+canonical editor. No backend or persistence changes were required.
+
+## Risks
+
+- Legacy bookmark redirects validate executor/profile ownership before opening
+ the canonical editor.
+- The canonical editor now owns the profile header, delete-return destination,
+ type-specific controls, and save coordination.
+- The legacy adapter preserves the old route surface while invalid pairs render
+ localized recovery instead of mounting an editor.
+- Role-gated controls, discovery fragments, and mobile safe-area behavior passed
+ the focused component and browser coverage.
diff --git a/docs/plans/executor-profile-editor-unification/task-01-unify-profile-editor.md b/docs/plans/executor-profile-editor-unification/task-01-unify-profile-editor.md
new file mode 100644
index 0000000000..dbf435a04e
--- /dev/null
+++ b/docs/plans/executor-profile-editor-unification/task-01-unify-profile-editor.md
@@ -0,0 +1,187 @@
+---
+id: "01-unify-profile-editor"
+title: "Unify profile navigation and bookmark handling"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-EXECUTORS-PROFILE-EDITOR-001
+acceptance_criteria:
+ - AC-EXECUTORS-PROFILE-EDITOR-001.1
+ - AC-EXECUTORS-PROFILE-EDITOR-001.2
+ - AC-EXECUTORS-PROFILE-EDITOR-001.3
+ - AC-EXECUTORS-PROFILE-EDITOR-001.4
+ - AC-EXECUTORS-PROFILE-EDITOR-001.5
+ - AC-EXECUTORS-PROFILE-EDITOR-001.6
+ - AC-EXECUTORS-PROFILE-EDITOR-001.7
+system_design:
+ - ../../specs/executors/system-design/profile-editor.md
+---
+
+# Task 01: Unify profile navigation and bookmark handling
+
+## Summary
+
+Make every profile entry point open the existing complete editor. Replace valid
+legacy bookmarks without losing ownership checks, query parameters, or fragments.
+
+## In scope
+
+1. Add the failing canonical-route regression before production changes.
+2. Reduce the profile helper to one encoded profile ID and migrate its callers.
+3. Replace the reduced form with bookmark compatibility and localized recovery.
+4. Add route, navigation, save, and phone regression coverage.
+5. Update public executor documentation with one sentence about equivalent entry points.
+
+## Out of scope
+
+Runtime changes, data migrations, new profile fields, and connection-page redesign.
+
+## Acceptance
+
+- All production profile navigation uses the canonical helper. Exactly one editor owns profile form state and persistence.
+- Legacy profile routes validate ownership, preserve suffixes, and replace history. Invalid pairs never mount an editor or mutate data.
+- All referenced criteria pass their targeted checks, including desktop and phone save/reload, discard, and existing permissions.
+
+## Implementation sequence
+
+Start with the failing helper regression described in the plan. Extend
+`LegacyExecutorSettingsRoute` tests with local Docker, SSH, Sprites, local,
+worktree, remote Docker, and Kubernetes cases. Cover missing executors, missing
+profiles, a foreign profile beside a valid profile, and later store hydration.
+The foreign-profile case must not select the executor's first profile.
+
+Retain existing executor-only Kubernetes recovery and non-Kubernetes connection
+behavior. Cover encoded IDs, query/hash preservation, and router replacement.
+
+Migrate the helper and hardcoded production links. Update obsolete assertions
+that expect the singular profile route. Preserve tests for old bookmarked URLs.
+Use `rg` to inventory callers and literals after the migration.
+
+Keep the plural editor as the form owner. Reduce the singular page to a small
+adapter or unavailable state. Do not retain its save contributor or duplicate
+field components. Do not remove an i18n guard entry to silence path checks.
+
+Add the two routing E2E files described in the plan. Reuse Docker response mocks
+from `docker-profile-persistence.spec.ts` and mobile settings-index navigation.
+Use actual clicks or taps for navigation evidence. Add
+`components/settings/executor-profile-navigation.test.tsx` for hub and
+task-disclosure navigation. Extend `lib/settings-discovery/catalog.test.ts`
+for discovery destinations.
+
+The documentation change belongs in `docs/public/executors.md`, a reference
+page. Preserve connection-page guidance. No new public page is required.
+
+## ASCII UI preview
+
+Excerpt from [UI-01 through UI-03](plan.md#ascii-ui-preview), covering criteria
+.1 through .6:
+
+```text
+UI-01 desktop: Settings tree | Profile > Details > Docker build > Policies
+UI-02 phone: Settings > Executors > Profile
+ Details
+ Dockerfile + image tag + [Build Image]
+ Credentials / environment / scripts / MCP
+ [Save changes] when dirty
+UI-03 invalid: Profile not found > recovery link
+```
+
+The phone uses direct navigation and one content scroll region. The existing
+floating save control retains safe-area clearance. No desktop sidebar appears.
+Use the full plan preview for structural requirements and type applicability.
+
+## Verification
+
+Run from the repository root. Install dependencies once if this is a fresh
+worktree. Run desktop and mobile E2E commands sequentially.
+
+```bash
+(cd apps && pnpm install --frozen-lockfile)
+(cd apps/web && pnpm exec vitest run lib/settings/executor-settings-routes.test.ts components/settings/legacy-executor-settings-route.test.tsx components/settings/executor-profiles-card.test.tsx components/settings/executor-profile-navigation.test.tsx components/app-sidebar/sections/settings/settings-menu-branches.test.ts src/settings-routes.test.ts src/settings-route-helpers.test.ts lib/settings-discovery/catalog.test.ts components/settings/settings-layout-client.test.tsx)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm run i18n:check)
+(cd apps/web && pnpm e2e:run --project chromium tests/settings/executor-profile-routing.spec.ts tests/settings/docker-profile-persistence.spec.ts tests/settings/kubernetes-executor.spec.ts tests/settings/ssh-profile-connection-link.spec.ts tests/settings/executor-agent-config.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-executor-profile-routing.spec.ts tests/settings/mobile-kubernetes-executor.spec.ts tests/settings/mobile-ssh-profile-connection-link.spec.ts tests/settings/mobile-executor-agent-config.spec.ts)
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+python3 scripts/list-docs.py validate
+python3 scripts/lint-spec-files.py --all
+git diff --check
+```
+
+Run ESLint on every changed TS/TSX file with `pnpm exec eslint --max-warnings 0`
+from `apps/web`, using explicit paths from the final diff. Add exact commands
+for any further changed test suite to Results before marking this task done.
+
+## Files likely touched
+
+All paths below are relative to `apps/web`, except the public document.
+
+- `lib/settings/executor-settings-routes.ts` and new `.test.ts`.
+- `components/settings/legacy-executor-settings-route.tsx` and `.test.tsx`.
+- `app/settings/executor/[id]/profile/[profileId]/page.tsx`.
+- `app/settings/executors/page.tsx`.
+- New `components/settings/executor-profile-navigation.test.tsx`.
+- `app/settings/executors/new/[type]/page.tsx`.
+- `app/settings/executors/new/[type]/ssh-create-page.tsx`.
+- `app/settings/executors/new/[type]/kubernetes-create-page.tsx`.
+- `app/settings/executors/k8s/[executorId]/page.tsx`.
+- `components/settings/executor-profiles-card.tsx` and `.test.tsx`.
+- `components/app-sidebar/sections/settings/settings-menu-branches.ts` and `.test.ts`.
+- `components/task/executor-environment-disclosure.tsx`.
+- `components/task-create-dialog-form-body.tsx`.
+- `lib/settings-discovery/resolve.ts` and `catalog.test.ts`.
+- `src/settings-routes.test.ts`, `src/settings-route-helpers.test.ts`.
+- `components/settings/settings-layout-client.test.tsx`.
+- New `e2e/tests/settings/executor-profile-routing.spec.ts` and `mobile-executor-profile-routing.spec.ts`.
+- `docs/public/executors.md` (repository-relative).
+
+The canonical editor needs no form redesign. Change it only if a small shared
+unavailable-state extraction is necessary.
+
+## Dependencies
+
+None.
+
+## Risks
+
+See the plan's ownership, compatibility, permissions, and test-entry-point risks.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/executors/requirements/profile-editor.md).
+- [System design](../../specs/executors/system-design/profile-editor.md).
+- Existing legacy route and profile-list component tests.
+- Existing Docker persistence and mobile settings-sidebar E2E patterns.
+- Repository `/tdd`, `/mobile-parity`, `/e2e`, and `/docs-maintainer` skills.
+
+## Results
+
+Implementation complete on 2026-09-17.
+
+- `executorProfileSettingsPath` now accepts one profile ID and all production
+ profile links use the encoded canonical `/settings/executors/:profileId`
+ route.
+- The reduced singular profile editor was removed. `LegacyExecutorSettingsRoute`
+ retains executor-only behavior, validates legacy executor/profile ownership,
+ preserves query and hash suffixes, and renders localized recovery for invalid
+ pairs.
+- Component coverage includes the RED-to-GREEN helper regression, legacy route
+ hydration and ownership cases, profile cards, settings tree, discovery, hub,
+ and task-disclosure navigation.
+- Desktop routing coverage exercises hub, settings tree, executor profile list,
+ legacy bookmarks, Docker build/save/reload, and unsaved-change discard.
+- Phone coverage exercises the complete Docker editor, touch-sized controls,
+ save/reload, legacy bookmarks, and horizontal-overflow protection.
+- `pnpm exec vitest run lib/settings/executor-settings-routes.test.ts components/settings/legacy-executor-settings-route.test.tsx components/settings/executor-profiles-card.test.tsx components/settings/executor-profile-navigation.test.tsx components/app-sidebar/sections/settings/settings-menu-branches.test.ts src/settings-routes.test.ts src/settings-route-helpers.test.ts lib/settings-discovery/catalog.test.ts components/settings/settings-layout-client.test.tsx`: 9 files and 126 tests passed.
+- `pnpm run typecheck`, `pnpm run i18n:check`, explicit changed-source ESLint
+ and Prettier checks, and `git diff --check`: passed.
+- `pnpm e2e:run --project chromium tests/settings/executor-profile-routing.spec.ts tests/settings/docker-profile-persistence.spec.ts tests/settings/kubernetes-executor.spec.ts tests/settings/ssh-profile-connection-link.spec.ts tests/settings/executor-agent-config.spec.ts`: 12 tests passed.
+- `pnpm e2e:run --project mobile-chrome tests/settings/mobile-executor-profile-routing.spec.ts tests/settings/mobile-kubernetes-executor.spec.ts tests/settings/mobile-ssh-profile-connection-link.spec.ts tests/settings/mobile-executor-agent-config.spec.ts`: 6 tests passed.
+- `node --test scripts/validate-public-docs.test.mjs`, `node scripts/validate-public-docs.mjs`, `python3 scripts/list-docs.py validate`, and `python3 scripts/lint-spec-files.py --all`: passed.
diff --git a/docs/public/executors.md b/docs/public/executors.md
index 94ad2d63dc..c4e9476342 100644
--- a/docs/public/executors.md
+++ b/docs/public/executors.md
@@ -43,6 +43,8 @@ download requirements.
Open **Settings > Executors**, then choose **Local**, **Worktree**, **Docker**, **Kubernetes**, **Sprites.dev**, or **SSH** under **Create New Profile**. Local and Worktree profiles already exist in a new database.
+Open a saved profile from the Executors hub, the Settings tree, an executor profile list, Settings search, or a task's executor disclosure to use the same complete profile editor.
+
