fix: unify executor profile editor routes (#3740) (#3762)

This commit is contained in:
Carlos Florêncio
2026-09-17 15:24:36 +01:00
committed by GitHub
parent 25d4a977f4
commit c277822ac1
26 changed files with 1117 additions and 657 deletions
@@ -1,565 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsPageHeader, SettingsFieldLabel } from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { serializeSettingsRevision } from "@/components/settings/settings-save-revision";
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
import {
updateExecutorProfile,
deleteExecutorProfile,
listScriptPlaceholders,
} from "@/lib/api/domains/settings-api";
import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api";
import {
upsertExecutorProfile,
type SaveStatus,
} from "@/components/settings/profile-edit/profile-edit-page-chrome";
import {
SpritesConnectionCard,
SpritesInstancesCard,
} from "@/components/settings/sprites-settings";
import { ScriptCard } from "@/components/settings/profile-edit/script-card";
import type { Executor, ExecutorProfile, ProfileEnvVar } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
type EnvVarRow = {
key: string;
mode: "value" | "secret";
value: string;
secretId: string;
};
function envVarsToRows(envVars?: ProfileEnvVar[]): EnvVarRow[] {
if (!envVars || envVars.length === 0) return [];
return envVars.map((ev) => ({
key: ev.key,
mode: ev.secret_id ? "secret" : "value",
value: ev.value ?? "",
secretId: ev.secret_id ?? "",
}));
}
function rowsToEnvVars(rows: EnvVarRow[]): ProfileEnvVar[] {
return rows
.filter((r) => r.key.trim())
.map((r) => {
if (r.mode === "secret" && r.secretId) {
return { key: r.key.trim(), secret_id: r.secretId };
}
return { key: r.key.trim(), value: r.value };
});
}
export default function ProfileDetailPage({
executorId,
profileId,
}: {
executorId: string;
profileId: string;
}) {
const { t } = useTranslation();
const router = useRouter();
const executor = useAppStore(
(state) => state.executors.items.find((e: Executor) => e.id === executorId) ?? null,
);
const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null;
if (!executor || !profile) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
<Button
className="mt-4 cursor-pointer"
onClick={() => router.push(`/settings/executor/${executorId}`)}
>
{t("executors:backToExecutor")}
</Button>
</CardContent>
</Card>
);
}
return <ProfileEditForm key={profile.id} executor={executor} profile={profile} />;
}
function ProfileDetailsCard({
name,
baselineName,
onNameChange,
}: {
name: string;
baselineName: string;
onNameChange: (v: string) => void;
}) {
const { t } = useTranslation();
const isDirty = name.trim() !== baselineName.trim();
return (
<SettingsCard isDirty={isDirty}>
<SettingsCardHeader title={t("executors:profileDetails")} />
<CardContent className="space-y-4">
<div className="space-y-2">
<SettingsFieldLabel htmlFor="profile-name">{t("executors:name")}</SettingsFieldLabel>
<Input
id="profile-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
data-settings-dirty={isDirty}
/>
</div>
</CardContent>
</SettingsCard>
);
}
function EnvVarRow({
row,
index,
secrets,
onUpdate,
onRemove,
baselineRow,
}: {
row: EnvVarRow;
index: number;
secrets: { id: string; name: string }[];
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
onRemove: (index: number) => void;
baselineRow?: EnvVarRow;
}) {
const { t } = useTranslation();
const isDirty = !baselineRow || JSON.stringify(row) !== JSON.stringify(baselineRow);
return (
<div
className="flex items-start gap-2"
data-settings-dirty={isDirty}
data-settings-dirty-level="container"
>
<Input
value={row.key}
onChange={(e) => onUpdate(index, "key", e.target.value)}
placeholder="KEY"
className="font-mono text-xs flex-[2]"
data-settings-dirty={!baselineRow || row.key !== baselineRow.key}
/>
<Select value={row.mode} onValueChange={(v) => onUpdate(index, "mode", v)}>
<SelectTrigger
className="w-[100px] text-xs"
data-settings-dirty={!baselineRow || row.mode !== baselineRow.mode}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="value">{t("executors:value")}</SelectItem>
<SelectItem value="secret">{t("executors:secret")}</SelectItem>
</SelectContent>
</Select>
{row.mode === "value" ? (
<Input
value={row.value}
onChange={(e) => onUpdate(index, "value", e.target.value)}
placeholder="value"
className="font-mono text-xs flex-[3]"
data-settings-dirty={!baselineRow || row.value !== baselineRow.value}
/>
) : (
<Select value={row.secretId} onValueChange={(v) => onUpdate(index, "secretId", v)}>
<SelectTrigger
className="flex-[3] text-xs"
data-settings-dirty={!baselineRow || row.secretId !== baselineRow.secretId}
>
<SelectValue placeholder={t("executors:selectSecret")} />
</SelectTrigger>
<SelectContent>
{secrets.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(index)}
className="cursor-pointer shrink-0"
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
);
}
function EnvVarsCard({
rows,
baselineRows,
secrets,
onAdd,
onUpdate,
onRemove,
}: {
rows: EnvVarRow[];
baselineRows: EnvVarRow[];
secrets: { id: string; name: string }[];
onAdd: () => void;
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
onRemove: (index: number) => void;
}) {
const { t } = useTranslation();
const isDirty =
JSON.stringify(rowsToEnvVars(rows)) !== JSON.stringify(rowsToEnvVars(baselineRows));
return (
<SettingsCard isDirty={isDirty}>
<SettingsCardHeader
title={t("executors:environmentVariables")}
description={t("executors:injectedIntoTheExecutionEnvironmentVariables")}
actions={
<Button
type="button"
variant="outline"
onClick={onAdd}
className={settingsActionClassName("cursor-pointer")}
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
{t("executors:add")}
</Button>
}
/>
<CardContent className="space-y-3">
{rows.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("executors:noEnvironmentVariablesConfigured")}
</p>
)}
{rows.map((row, idx) => (
<EnvVarRow
key={idx}
row={row}
baselineRow={baselineRows[idx]}
index={idx}
secrets={secrets}
onUpdate={onUpdate}
onRemove={onRemove}
/>
))}
</CardContent>
</SettingsCard>
);
}
function ProfileActions({
executorId,
onRequestDelete,
}: {
executorId: string;
onRequestDelete: () => void;
}) {
const { t } = useTranslation();
const router = useRouter();
return (
<div className="flex items-center justify-between">
<Button
variant="destructive"
onClick={onRequestDelete}
className={settingsActionClassName("cursor-pointer")}
>
<IconTrash className="h-4 w-4 mr-1" />
{t("executors:deleteProfile")}
</Button>
<Button
variant="outline"
onClick={() => router.push(`/settings/executor/${executorId}`)}
className="cursor-pointer"
>
{t("common:cancel")}
</Button>
</div>
);
}
function DeleteProfileDialog({
open,
onOpenChange,
onDelete,
deleting,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onDelete: () => void;
deleting: boolean;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executors:deleteProfile")}</DialogTitle>
<DialogDescription>{t("executors:areYouSureYouWantTo")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common:cancel")}
</Button>
<Button variant="destructive" onClick={onDelete} disabled={deleting}>
{deleting ? t("executors:deleting") : t("executors:delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const router = useRouter();
const { toast } = useToast();
const executors = useAppStore((state) => state.executors.items);
const setExecutors = useAppStore((state) => state.setExecutors);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const save = useCallback(
async (data: {
name: string;
prepare_script: string;
cleanup_script: string;
env_vars: ProfileEnvVar[];
}) => {
setSaveStatus("loading");
setError(null);
try {
const updated = await updateExecutorProfile(executor.id, profile.id, data);
setSaveStatus("success");
toast({ title: t("executors:profileSaved"), variant: "success" });
setExecutors(upsertExecutorProfile(executors, executor, updated));
window.setTimeout(() => setSaveStatus("idle"), 1500);
} catch (err) {
const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile");
setError(message);
setSaveStatus("error");
toast({
title: t("executors:failedToSaveProfile"),
description: message,
variant: "error",
});
throw err;
}
},
[executor, profile.id, executors, setExecutors, toast],
);
const remove = useCallback(async () => {
setDeleting(true);
try {
await deleteExecutorProfile(executor.id, profile.id);
setExecutors(
executors.map((e: Executor) =>
e.id === executor.id
? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) }
: e,
),
);
runWithNavigationBlockerBypassed(() => router.push(`/settings/executor/${executor.id}`));
} catch {
setDeleting(false);
setDeleteDialogOpen(false);
}
}, [executor.id, profile.id, executors, setExecutors, router]);
return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove };
}
function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const [name, setName] = useState(profile.name);
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? "");
const [envVarRows, setEnvVarRows] = useState<EnvVarRow[]>(() => envVarsToRows(profile.env_vars));
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
const isRemote =
executor.type === "sprites" ||
executor.type === "local_docker" ||
executor.type === "remote_docker";
const isSprites = executor.type === "sprites";
const spritesSecretId = useMemo(() => {
const tokenVar = envVarRows.find((r) => r.key === "SPRITES_API_TOKEN" && r.mode === "secret");
return tokenVar?.secretId;
}, [envVarRows]);
useEffect(() => {
listScriptPlaceholders()
.then((res) => setPlaceholders(res.placeholders ?? []))
.catch(() => {});
}, []);
const addEnvVar = useCallback(() => {
setEnvVarRows((prev) => [...prev, { key: "", mode: "value", value: "", secretId: "" }]);
}, []);
const removeEnvVar = useCallback((index: number) => {
setEnvVarRows((prev) => prev.filter((_, i) => i !== index));
}, []);
const updateEnvVar = useCallback((index: number, field: keyof EnvVarRow, val: string) => {
setEnvVarRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: val } : row)));
}, []);
// `{{` is the editor's placeholder-completion trigger, not an interpolation
// token, so it is interpolated as a value rather than written into the
// catalog — i18next would otherwise try to resolve it.
const prepareDesc = isRemote
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
: t("executors:prepareScriptDescriptionLocal");
return {
name,
setName,
prepareScript,
setPrepareScript,
cleanupScript,
setCleanupScript,
envVarRows,
addEnvVar,
removeEnvVar,
updateEnvVar,
placeholders,
isRemote,
isSprites,
spritesSecretId,
prepareDesc,
};
}
function ProfileEditHeader({ executor, profileName }: { executor: Executor; profileName: string }) {
const { t } = useTranslation();
const router = useRouter();
return (
<SettingsPageHeader
title={profileName}
description={t("executors:profileForExecutor", { name: executor.name })}
actions={
<Button
variant="outline"
className={settingsActionClassName("cursor-pointer")}
onClick={() => router.push(`/settings/executor/${executor.id}`)}
>
{t("executors:backToExecutor")}
</Button>
}
/>
);
}
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
const { t } = useTranslation();
const { items: secrets } = useSecrets();
const persistence = useProfilePersistence(executor, profile);
const form = useProfileFormState(executor, profile);
const savePayload = {
name: form.name.trim(),
prepare_script: form.prepareScript,
cleanup_script: form.cleanupScript,
env_vars: rowsToEnvVars(form.envVarRows),
};
const saveRevision = serializeSettingsRevision(savePayload);
const [savedRevision, setSavedRevision] = useState(saveRevision);
const handleSave = async () => {
await persistence.save(savePayload);
setSavedRevision(saveRevision);
};
useSettingsSaveContributor({
id: `legacy-executor-profile:${profile.id}`,
revision: saveRevision,
isDirty: saveRevision !== savedRevision,
canSave: Boolean(form.name.trim()),
invalidReason: form.name.trim() ? undefined : t("executors:profileNameIsRequired"),
save: handleSave,
discard: () => undefined,
});
return (
<div className="space-y-8">
<ProfileEditHeader executor={executor} profileName={profile.name} />
<Separator />
<ProfileDetailsCard
name={form.name}
baselineName={profile.name}
onNameChange={form.setName}
/>
{form.isSprites && form.spritesSecretId && (
<>
<SpritesConnectionCard secretId={form.spritesSecretId} />
<SpritesInstancesCard secretId={form.spritesSecretId} />
</>
)}
<EnvVarsCard
rows={form.envVarRows}
baselineRows={envVarsToRows(profile.env_vars)}
secrets={secrets}
onAdd={form.addEnvVar}
onUpdate={form.updateEnvVar}
onRemove={form.removeEnvVar}
/>
<ScriptCard
title={t("executors:prepareScript")}
description={form.prepareDesc}
value={form.prepareScript}
baselineValue={profile.prepare_script ?? ""}
onChange={form.setPrepareScript}
height="300px"
placeholders={form.placeholders}
executorType={executor.type}
/>
{form.isRemote && (
<ScriptCard
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={form.cleanupScript}
baselineValue={profile.cleanup_script ?? ""}
onChange={form.setCleanupScript}
height="200px"
placeholders={form.placeholders}
executorType={executor.type}
/>
)}
{persistence.error && <p className="text-sm text-destructive">{persistence.error}</p>}
<ProfileActions
executorId={executor.id}
onRequestDelete={() => persistence.setDeleteDialogOpen(true)}
/>
<DeleteProfileDialog
open={persistence.deleteDialogOpen}
onOpenChange={persistence.setDeleteDialogOpen}
onDelete={persistence.remove}
deleting={persistence.deleting}
/>
</div>
);
}
@@ -58,11 +58,7 @@ export default function KubernetesExecutorPage({ executorId }: { executorId: str
)?.profiles?.[0]?.id ?? null,
);
if (profileId) {
return (
<SettingsRedirect
to={executorProfileSettingsPath({ id: executorId, type: "k8s" }, profileId)}
/>
);
return <SettingsRedirect to={executorProfileSettingsPath(profileId)} />;
}
return <KubernetesExecutorRecoveryPage executorId={executorId} />;
}
@@ -71,7 +71,7 @@ export function KubernetesCreatePage() {
try {
const created = await resource.create(payload);
runWithNavigationBlockerBypassed(() =>
router.push(executorProfileSettingsPath(created.executor, created.profile.id)),
router.push(executorProfileSettingsPath(created.profile.id)),
);
} catch (cause) {
setError(cause);
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Badge } from "@kandev/ui/badge";
@@ -407,7 +408,9 @@ function useCreateProfileSave(executorId: string) {
e.id === executorId ? { ...e, profiles: [...(e.profiles ?? []), profile] } : e,
),
);
runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`));
runWithNavigationBlockerBypassed(() =>
router.push(executorProfileSettingsPath(profile.id)),
);
} catch (err) {
setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile"));
throw err;
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
import { useRouter } from "@/lib/routing/client-router";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -68,7 +69,7 @@ export function SSHCreatePage() {
const current = store.getState().executors.items;
const merged = current.some((e) => e.id === next.id) ? current : [...current, next];
store.getState().setExecutors(merged);
router.push(`/settings/executors/${profile.id}`);
router.push(executorProfileSettingsPath(profile.id));
},
[router, store],
);
+2 -1
View File
@@ -22,6 +22,7 @@ import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
import { KubernetesReadOnlyNotice } from "@/components/settings/kubernetes-read-only-notice";
import { settingsActionClassName } from "@/components/settings/settings-control";
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
type ProfileWithExecutor = ExecutorProfile & {
executor_type: string;
@@ -114,7 +115,7 @@ function ProfileCard({
return (
<Card
className="group cursor-pointer transition-colors hover:bg-muted/50"
onClick={() => router.push(`/settings/executors/${profile.id}`)}
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
<CardContent className="flex items-center gap-3 p-4">
<ExecutorIconBadge type={profile.executor_type} />
@@ -331,13 +331,11 @@ describe("buildAgentsBranch", () => {
});
describe("buildExecutorsBranch", () => {
it("uses the executor-scoped profile route, the one with an executor crumb", () => {
it("uses the canonical profile route", () => {
const [executor] = buildExecutorsBranch(EXECUTORS);
expect(executor.href).toBe("/settings/executor/exec-1");
expect(hrefsOf(executor.children ?? [])).toEqual([
"/settings/executor/exec-1/profile/exec-profile-1",
]);
expect(hrefsOf(executor.children ?? [])).toEqual(["/settings/executors/exec-profile-1"]);
});
it("makes a configured Kubernetes executor disclosure-only and links its profile root", () => {
@@ -446,12 +444,12 @@ describe("findActiveNodePath", () => {
});
it("stops at the row when the route has no node — the row stays active", () => {
// The install catalogue is not an agent, and the flat profile spelling has
// no executor to nest under. Both are the row's own pages.
// The install catalogue is not an agent, and an unknown profile has no
// executor to nest under. Both are the row's own pages.
expect(findActiveNodePath(forest, "/settings/agents/browse")).toEqual([
settingsMenuRowKey("/settings/agents"),
]);
expect(findActiveNodePath(forest, "/settings/executors/exec-profile-1")).toEqual([
expect(findActiveNodePath(forest, "/settings/executors/unknown-profile")).toEqual([
settingsMenuRowKey(EXECUTORS_HREF),
]);
});
@@ -316,11 +316,11 @@ export function buildAgentsBranch(
/**
* One node per executor, holding its profiles.
*
* Most executors use the scoped legacy spellings so their executor breadcrumb
* matches the branch. A configured Kubernetes executor only discloses its
* profile children: connection, diagnostics, sessions and workload settings
* all live on that profile page. Its standalone connection route remains
* reachable only when there is no profile to recover through.
* Profile children always open the canonical profile editor. A configured
* Kubernetes executor only discloses its profile children: connection,
* diagnostics, sessions and workload settings all live on that profile page.
* Its standalone connection route remains reachable only when there is no
* profile to recover through.
*/
export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>): SettingsMenuNode[] {
return executors.map((executor) => {
@@ -337,7 +337,7 @@ export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>):
// Same split as agents: the executor ships with kandev, the profiles do not.
children: profiles.map((profile) => ({
key: `executor:${executor.id}:profile:${profile.id}`,
href: executorProfileSettingsPath(executor, profile.id),
href: executorProfileSettingsPath(profile.id),
label: { text: profile.name },
isUserRecord: true,
})),
@@ -0,0 +1,114 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import ExecutorsHubPage from "@/app/settings/executors/page";
import { StateProvider } from "@/components/state-provider";
import { ExecutorEnvironmentDisclosure } from "@/components/task/executor-environment-disclosure";
import type { TaskEnvironment } from "@/lib/api/domains/task-environment-api";
import type { Executor, ExecutorProfile } from "@/lib/types/http";
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push }),
}));
vi.mock("@/components/task/executor-environment-info", () => ({
EnvironmentInfo: ({ kubernetesActions }: { kubernetesActions?: ReactNode }) => (
<>{kubernetesActions}</>
),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const TIMESTAMP = "2026-08-24T00:00:00Z";
afterEach(() => {
cleanup();
push.mockReset();
});
function profile(executorId: string): ExecutorProfile {
return {
id: "profile/primary",
executor_id: executorId,
executor_type: "local_docker",
name: "Primary profile",
config: {},
prepare_script: "",
cleanup_script: "",
env_vars: [],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}
function executor(): Executor {
const id = "executor/primary";
return {
id,
name: "Primary executor",
type: "local_docker",
status: "active",
is_system: false,
config: {},
profiles: [profile(id)],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}
const kubernetesEnvironment = {
id: "environment-1",
task_id: "task-1",
repository_id: "repository-1",
executor_type: "k8s",
executor_id: "executor/primary",
executor_profile_id: "profile/primary",
agent_execution_id: "execution-1",
control_port: 0,
status: "running",
created_at: TIMESTAMP,
} satisfies TaskEnvironment;
describe("executor profile navigation", () => {
it("opens the hub profile card in the encoded canonical editor", () => {
const record = executor();
render(
<StateProvider initialState={{ executors: { items: [record] } }}>
<ExecutorsHubPage />
</StateProvider>,
);
fireEvent.click(screen.getByText("Primary profile"));
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
it("opens the task disclosure settings action in the encoded canonical editor", () => {
render(
<ExecutorEnvironmentDisclosure
env={kubernetesEnvironment}
container={null}
ssh={null}
kubernetes={null}
kubernetesLoaded={false}
kubernetesError={null}
loading={false}
refreshing={false}
isResetting={false}
onRefresh={async () => {}}
onReset={vi.fn()}
/>,
);
expect(screen.getByTestId("executor-settings-link").getAttribute("href")).toBe(
"/settings/executors/profile%2Fprimary",
);
});
});
@@ -63,13 +63,11 @@ describe("ExecutorProfilesCard profile navigation", () => {
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
it("preserves the scoped legacy profile route for SSH", () => {
it("opens an SSH profile in the canonical editor", () => {
renderCard(executor("ssh"));
fireEvent.click(screen.getByText("Primary profile"));
expect(push).toHaveBeenCalledWith(
"/settings/executor/executor%2Fprimary/profile/profile%2Fprimary",
);
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
});
});
@@ -61,7 +61,6 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
const [dialogOpen, setDialogOpen] = useState(false);
const executors = useAppStore((state) => state.executors.items);
const setExecutors = useAppStore((state) => state.setExecutors);
const executorType = executors.find((executor) => executor.id === executorId)?.type;
const refreshProfiles = useCallback(async () => {
try {
@@ -81,9 +80,9 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
const handleProfileCreated = useCallback(
(profile: ExecutorProfile) => {
refreshProfiles();
router.push(executorProfileSettingsPath({ id: executorId, type: executorType }, profile.id));
router.push(executorProfileSettingsPath(profile.id));
},
[executorId, executorType, refreshProfiles, router],
[refreshProfiles, router],
);
const handleDelete = useCallback(
@@ -117,14 +116,7 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
<div
key={profile.id}
className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() =>
router.push(
executorProfileSettingsPath(
{ id: executorId, type: executorType },
profile.id,
),
)
}
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">{profile.name}</span>
@@ -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 }) => (
<div data-testid="legacy-executor">{executorId}</div>
),
}));
vi.mock("@/app/settings/executor/[id]/profile/[profileId]/page", () => ({
default: ({ profileId }: { profileId: string }) => (
<div data-testid="legacy-profile">{profileId}</div>
<div data-testid={LEGACY_EXECUTOR_TESTID}>{executorId}</div>
),
}));
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(
<StateProvider
initialState={{
executors: { items: [record] },
executors: { items: record ? [record] : [] },
settingsData: { executorsLoaded: options.executorsLoaded ?? true, agentsLoaded: true },
auth: {
mode: "enabled",
authenticated: true,
@@ -75,19 +81,36 @@ function renderRoute(record: Executor, profileId?: string) {
},
}}
>
<LegacyExecutorSettingsRoute executorId={record.id} profileId={profileId} />
<LegacyExecutorSettingsRoute
executorId={executorId ?? record?.id ?? "executor/missing"}
profileId={profileId}
/>
</StateProvider>,
);
}
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(
<StateProvider
initialState={{
executors: { items: [] },
auth: {
mode: "enabled",
authenticated: true,
user: {
id: "member-1",
email: "member@example.com",
display_name: "Member",
role: "member",
status: "active",
},
},
}}
>
<LegacyExecutorSettingsRoute executorId={record.id} profileId={PROFILE_ID} />
<HydrateExecutors record={record} />
</StateProvider>,
);
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
});
});
@@ -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 (
<Card data-testid="executor-profile-unavailable">
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
<Button className="mt-4 cursor-pointer" onClick={() => router.push(destination)}>
{executor ? t("executors:backToExecutor") : t("executors:backToExecutors")}
</Button>
</CardContent>
</Card>
);
}
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 <ProfileUnavailableState executor={executor} />;
return <SettingsRedirect to={executorProfileSettingsPath(profile.id)} />;
}
if (executor?.type === "k8s") {
const destinationProfileId = profileId
? executor.profiles?.find((profile) => profile.id === profileId)?.id
: executor.profiles?.[0]?.id;
if (profileId && !destinationProfileId) {
return <ProfileDetailPage executorId={executorId} profileId={profileId} />;
}
const destination = destinationProfileId
? executorProfileSettingsPath(executor, destinationProfileId)
const destination = executor.profiles?.[0]?.id
? executorProfileSettingsPath(executor.profiles[0].id)
: executorConnectionSettingsPath(executor);
return <SettingsRedirect to={destination} />;
}
return profileId ? (
<ProfileDetailPage executorId={executorId} profileId={profileId} />
) : (
<ExecutorEditPage executorId={executorId} />
);
return <ExecutorEditPage executorId={executorId} />;
}
@@ -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 {
@@ -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 (
<div
@@ -0,0 +1,123 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { setSettingsMenuMode } from "../../helpers/settings-menu";
const INITIAL_IMAGE_TAG = "kandev/e2e:routing-initial";
const EDITED_IMAGE_TAG = "kandev/e2e:routing-edited";
const DRAFT_IMAGE_TAG = "kandev/e2e:routing-draft";
const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
function canonicalProfilePath(profileId: string): string {
return `/settings/executors/${encodeURIComponent(profileId)}`;
}
async function expectDockerProfileEditor(testPage: Page, profileId: string) {
await expect(testPage).toHaveURL(canonicalProfilePath(profileId));
await expect(testPage.locator("#image-tag")).toBeVisible();
await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
}
test.describe("executor profile routing", () => {
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(() => {});
}
});
});
@@ -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(() => {});
}
});
@@ -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",
);
+2 -1
View File
@@ -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,
@@ -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",
);
});
});
@@ -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)}`;
}
@@ -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.
@@ -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.
+2
View File
@@ -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.
![Settings > Executors showing existing Local, Worktree, and Sprites profiles plus Local, Worktree, Docker, Sprites.dev, and SSH profile creation options.](../screenshots/settings-executors.png)
<DocsVideo
@@ -0,0 +1,47 @@
---
status: draft
system: executors
created: 2026-09-17
owners:
- kandev
---
# Executor profile editor requirements
## Overview
Users need the same profile controls regardless of how they reach an executor
profile. The executor system owns this contract because it owns profiles and
their available settings.
## Requirements
### REQ-EXECUTORS-PROFILE-EDITOR-001: Consistent profile editing
**Intent:** Every entry point opens the complete editor for the selected profile.
#### Acceptance criteria
- **AC-EXECUTORS-PROFILE-EDITOR-001.1:** The executor hub, settings tree, executor profile list, and task disclosure shall open the same editor for a selected profile.
- **AC-EXECUTORS-PROFILE-EDITOR-001.2:** Every supported executor type shall expose its applicable profile controls, regardless of entry point. Docker profiles shall expose Dockerfile, image tag, and build controls. SSH profiles shall expose readiness and task-directory reclamation controls. Kubernetes and Sprites profiles shall retain their applicable runtime controls. Applicable credential and policy controls shall remain available.
- **AC-EXECUTORS-PROFILE-EDITOR-001.3:** A valid executor-scoped bookmark shall reach the complete editor for that profile. Navigation shall preserve query parameters and section fragments. Browser Back shall not revisit a redirect page.
- **AC-EXECUTORS-PROFILE-EDITOR-001.4:** A bookmark with a missing executor, missing profile, or mismatched ownership shall show an unavailable-profile state. It shall not open another profile or change stored data.
- **AC-EXECUTORS-PROFILE-EDITOR-001.5:** Profile edits shall retain the shared Save changes, discard, navigation guard, and permission behavior. Saved values shall survive reload. Discard shall restore saved values.
- **AC-EXECUTORS-PROFILE-EDITOR-001.6:** On phones, users shall reach the same controls through direct settings navigation and valid bookmarks. They shall save and reload profile edits without horizontal page overflow.
- **AC-EXECUTORS-PROFILE-EDITOR-001.7:** Settings search, profile creation, and task-creation credential links shall open that same profile editor.
## Related requirements
The [card-spacing requirement](../../ui/requirements/executor-settings-card-spacing.md)
owns the existing form rhythm and scroll behavior. This requirement extends
editor reachability without redesigning those controls.
## Out of scope
- New executor capabilities, backend APIs, or data migrations.
- Changes to permission policy or executor connection ownership.
- Redesign of the settings shell or profile creation forms.
## Implementation plans
- [Unified profile editor](../../../plans/executor-profile-editor-unification/plan.md)
@@ -0,0 +1,106 @@
---
status: draft
system: executors
requirements:
- REQ-EXECUTORS-PROFILE-EDITOR-001
---
# Executor profile editor design
## Purpose and boundaries
The existing complete editor at `/settings/executors/:profileId` owns profile
editing. All profile navigation converges on this editor. Executor connection
pages retain their existing routes and behavior.
## Requirement mapping
| Acceptance criteria | Design section |
| --- | --- |
| AC-EXECUTORS-PROFILE-EDITOR-001.1, .2, .7 | Components and navigation |
| AC-EXECUTORS-PROFILE-EDITOR-001.3, .4 | Bookmark compatibility |
| AC-EXECUTORS-PROFILE-EDITOR-001.5 | State and permissions |
| AC-EXECUTORS-PROFILE-EDITOR-001.6 | Phone composition |
## Components and navigation
`apps/web/app/settings/executors/[profileId]/page.tsx` remains the single
editor implementation. Its `ProfileEditPage` resolves profile ownership from
the hydrated executor store. Its existing section components determine which
controls apply to the executor type.
`executorProfileSettingsPath` in
`apps/web/lib/settings/executor-settings-routes.ts` takes only `profileId` and
returns `/settings/executors/${encodeURIComponent(profileId)}`. Profile
navigation does not depend on executor type. Connection helpers remain separate.
Every production caller uses this helper, including the hub, settings tree,
profile list, task disclosure, settings discovery, creation success navigation,
and task-creation credential links. Discovery appends its existing fragments.
## Bookmark compatibility
`LegacyExecutorSettingsRoute` handles both existing executor-scoped route shapes.
For an explicit profile ID, it first finds the named executor and confirms that
the profile belongs to that executor. A valid pair renders `SettingsRedirect`
to the canonical profile route. It never mounts an editable legacy form.
An invalid pair renders the existing localized unavailable-profile message and
a recovery link. It does not fall back to the first profile or resolve the
profile globally. The reduced page can remain as a small compatibility wrapper
or unavailable-state component, but contains no form state or persistence logic.
`SettingsRedirect` already uses router replacement and preserves query
parameters and fragments through `resolveSettingsRedirect`. Reuse it without
changing generic redirect behavior. Store updates can resolve a temporarily
missing pair. An unavailable state must not cause an eager redirect.
Executor-only URLs retain their current behavior: Kubernetes resolves its first
profile or its connection recovery page. Other executor types retain their
connection editor. An explicit invalid Kubernetes profile must not use the
executor-only fallback.
## State and permissions
The canonical editor retains its current save contributors, serialization,
baseline readiness, deletion handling, and store updates. No migration or
backend change is required. Navigation itself never saves or deletes data.
Kubernetes members retain read-only controls. Docker build controls retain
their administrator checks. The URL ownership check prevents accidental profile
substitution and does not replace backend authorization.
The canonical editor owns the resulting header, recovery, and delete navigation.
The old editor's separate Cancel button and save contributor disappear. The
settings shell continues to own unsaved-change prompts and discard behavior.
## Phone composition
The existing settings index and executor hub provide direct phone navigation.
`mobile-settings-sidebar.spec.ts` confirms that phones do not expose the desktop
settings sidebar. No new menu or drawer is required.
The nearest shipped form is the canonical profile editor, covered by
`mobile-executor-profile-spacing.spec.ts`. The curated direct-navigation
pattern in `components/kanban-with-preview.tsx` supports this choice: the
profile is a primary destination with a long form, not a temporary picker.
The settings content region remains the single page scroll owner. Cards retain
their existing vertical rhythm. The shared floating save control remains the
primary action and retains safe-area clearance. Touch controls retain their
existing phone sizing. Phone tests cover navigation, editing, save, reload,
bookmark recovery, and zero horizontal document overflow.
## Verification boundaries
Route-helper tests cover supported executor callers and encoded profile IDs.
Component tests cover bookmark ownership, missing records, store hydration,
redirect suffixes, and unchanged executor-only routes. Browser tests exercise
the real navigation controls and complete editor together.
Mock Docker build responses follow the existing persistence E2E pattern. This
repair verifies access to the controls, not container runtime execution.
## Implementation plans
- [Unified profile editor](../../../plans/executor-profile-editor-unification/plan.md)