@@ -110,7 +112,7 @@ export function SpritesConnectionCard({ secretId }: { secretId?: string }) {
{testResult &&
}
-
+
);
}
@@ -200,7 +202,7 @@ export function SpritesInstancesCard({ secretId }: { secretId?: string }) {
}, [secretId, instances, removeSpritesInstance]);
return (
-
+
@@ -235,7 +237,7 @@ export function SpritesInstancesCard({ secretId }: { secretId?: string }) {
onDestroy={handleDestroy}
/>
-
+
);
}
diff --git a/apps/web/components/settings/startup-page-settings-card.tsx b/apps/web/components/settings/startup-page-settings-card.tsx
index 18300011b7..f163183b7e 100644
--- a/apps/web/components/settings/startup-page-settings-card.tsx
+++ b/apps/web/components/settings/startup-page-settings-card.tsx
@@ -6,6 +6,7 @@ import { Label } from "@kandev/ui/label";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
import type { StartupPage } from "@/lib/types/http";
import { SettingsCard } from "./settings-card";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
/**
* Holds catalog KEYS, not copy. A module-scope table is evaluated once at import,
@@ -37,7 +38,11 @@ export function StartupPageSettingsCard({
}) {
const { t } = useTranslation();
return (
-
+
{t("settings:openKandevTo")}
{t("settings:thisAppliesWhenKandevStarts")}
diff --git a/apps/web/components/settings/system-metrics-settings-card.tsx b/apps/web/components/settings/system-metrics-settings-card.tsx
index 82abd31e34..549c0c4753 100644
--- a/apps/web/components/settings/system-metrics-settings-card.tsx
+++ b/apps/web/components/settings/system-metrics-settings-card.tsx
@@ -10,6 +10,7 @@ import { fetchSystemMetricsSettings, updateSystemMetricsSettings } from "@/lib/a
import type { SystemMetricId, SystemMetricsGlobalSettings } from "@/lib/types/system";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { SettingsCard } from "./settings-card";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
import { useTranslation } from "react-i18next";
const METRIC_OPTIONS: Array<{ id: SystemMetricId; labelKey: string }> = [
@@ -90,7 +91,10 @@ export function SystemMetricsSettingsCard({
};
return (
-
+
{t("settings:resourceMetrics")}
diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx
index c9f882ea01..fcbf40cff4 100644
--- a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx
+++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx
@@ -17,6 +17,8 @@ import { StorageOverviewCard } from "./storage-overview-card";
import { StoragePolicyCard } from "./storage-policy-card";
import { StorageQuarantineCard } from "./storage-quarantine-card";
import { StorageRunHistory } from "./storage-run-history";
+import { SettingsTarget } from "../../settings-target";
+import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system";
function StorageJobButtonContent({
job,
@@ -74,7 +76,10 @@ function StorageActions({
const cleanupActive =
controller.cleanupJob?.state === "queued" || controller.cleanupJob?.state === "running";
return (
-
+
{t("system:storageActionsTitle")}
{t("system:storageActionsDescription")}
@@ -122,7 +127,7 @@ function StorageActions({
-
+
);
}
diff --git a/apps/web/components/settings/system/storage/storage-policy-fields.tsx b/apps/web/components/settings/system/storage/storage-policy-fields.tsx
index 8f288a212e..6391508be6 100644
--- a/apps/web/components/settings/system/storage/storage-policy-fields.tsx
+++ b/apps/web/components/settings/system/storage/storage-policy-fields.tsx
@@ -4,6 +4,7 @@ import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { SettingsCard } from "@/components/settings/settings-card";
import { StorageSettingHelp } from "./storage-setting-help";
+import { storageSettingsTarget } from "@/lib/settings-discovery/catalog/system";
export function PolicySection({
sectionId,
@@ -22,6 +23,7 @@ export function PolicySection({
diff --git a/apps/web/components/settings/terminal-settings.tsx b/apps/web/components/settings/terminal-settings.tsx
index 0e4bbc43e4..aee968b64a 100644
--- a/apps/web/components/settings/terminal-settings.tsx
+++ b/apps/web/components/settings/terminal-settings.tsx
@@ -27,6 +27,11 @@ import { useSettingsSaveContributor } from "./settings-save-provider";
import { TERMINAL_FONT_PRESETS } from "@/lib/terminal/terminal-font";
import type { FontCategory } from "@/lib/terminal/terminal-font";
import { useTranslation } from "react-i18next";
+import {
+ TERMINAL_FONT_FAMILY_TARGET,
+ TERMINAL_FONT_SIZE_TARGET,
+ TERMINAL_LINKS_TARGET,
+} from "@/lib/settings-discovery/catalog/general";
const CUSTOM_VALUE = "__custom__";
/**
@@ -97,7 +102,11 @@ function TerminalFontSizeCard({
};
return (
-
+
{t("settings:terminalFontSize")}
@@ -172,7 +181,11 @@ function TerminalFontCard({
const selectValue = isCustom ? CUSTOM_VALUE : fontFamily || "default";
return (
-
+
{t("settings:terminalFont")}
@@ -227,7 +240,11 @@ function TerminalLinksCard({
}) {
const { t } = useTranslation();
return (
-
+
{t("settings:terminalLinks")}
diff --git a/apps/web/components/settings/unread-divider-settings.tsx b/apps/web/components/settings/unread-divider-settings.tsx
index 9026c9ab7c..3c1b105e10 100644
--- a/apps/web/components/settings/unread-divider-settings.tsx
+++ b/apps/web/components/settings/unread-divider-settings.tsx
@@ -7,6 +7,7 @@ import { Switch } from "@kandev/ui/switch";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import { SettingsCard } from "./settings-card";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { useTranslation } from "react-i18next";
@@ -47,7 +48,11 @@ export function UnreadDividerSettings() {
});
return (
-
+
{t("settings:unreadMessages")}
diff --git a/apps/web/components/settings/utility-sections.tsx b/apps/web/components/settings/utility-sections.tsx
index a9970811df..25c92e661d 100644
--- a/apps/web/components/settings/utility-sections.tsx
+++ b/apps/web/components/settings/utility-sections.tsx
@@ -3,7 +3,7 @@
import { useTranslation } from "react-i18next";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
-import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
+import { CardAction, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import {
Select,
@@ -25,6 +25,7 @@ import {
import { InferenceAgentStatusNote } from "@/components/settings/inference-agent-status";
import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api";
import { SettingsCard } from "@/components/settings/settings-card";
+import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { isUtilityAgentDirty } from "@/components/settings/utility-dirty";
const USE_DEFAULT = "__USE_DEFAULT__";
@@ -97,7 +98,11 @@ export function DefaultModelSection({
}));
return (
-
+
{t("settings:utilityDefaultModelTitle")}
@@ -299,6 +304,7 @@ export function PerActionOverridesSection({
savedBuiltins.find((saved) => saved.id === agent.id),
),
)}
+ discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityActions}
data-testid="utility-actions-card"
>
@@ -337,7 +343,10 @@ type CustomAgentsSectionProps = {
export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) {
const { t } = useTranslation();
return (
-
+
{t("settings:utilityCustomAgentsTitle")}
@@ -366,7 +375,7 @@ export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomA
)}
-
+
);
}
diff --git a/apps/web/components/settings/voice-mode-settings.tsx b/apps/web/components/settings/voice-mode-settings.tsx
index 2badbfad22..41006583b3 100644
--- a/apps/web/components/settings/voice-mode-settings.tsx
+++ b/apps/web/components/settings/voice-mode-settings.tsx
@@ -37,6 +37,7 @@ import type {
} from "@/lib/types/http-voice";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { SettingsCard } from "./settings-card";
+import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
const VOICE_SECURE_SCHEME = "HTTPS";
const VOICE_LOCALHOST_HOST = "localhost";
@@ -264,7 +265,10 @@ function EngineCard({ caps }: { caps: VoiceCapabilities }) {
const options = useMemo(() => buildEngineOptions(caps), [caps]);
return (
-
+
{t("settings:voiceEngineTitle")}
@@ -401,7 +405,7 @@ function BehaviorCard() {
voiceMode.mode !== savedVoiceMode.mode ||
voiceMode.autoSend !== savedVoiceMode.autoSend;
return (
-
+
{t("settings:voiceBehavior")}
@@ -422,7 +426,10 @@ function WhisperModelCard() {
const { save, saving } = useVoiceModeSaver();
return (
-
+
{t("settings:voiceWhisperModelTitle")}
@@ -469,6 +476,7 @@ function EnableCard() {
return (
@@ -550,7 +558,7 @@ function VoiceShortcutCard() {
}, [overrides, persist]);
return (
-
+
{/* The shortcut's own label comes from the shared keyboard registry,
diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx
index 78ab4acce2..b33f091519 100644
--- a/apps/web/components/slack/slack-settings.tsx
+++ b/apps/web/components/slack/slack-settings.tsx
@@ -36,6 +36,7 @@ import {
useSlackSettings,
type FormState,
} from "./slack-settings-state";
+import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations";
type SecretFieldsProps = {
form: FormState;
@@ -396,6 +397,7 @@ export function SlackConnectionSection({ workspaceId }: { workspaceId: string })
return (
}
title={t("slack:integrationTitle")}
description={t("slack:integrationDescription", { example: DEFAULT_PREFIX_EXAMPLE })}
diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts
index 2b30cd3ed7..e0863ac35a 100644
--- a/apps/web/e2e/fixtures/test-base.ts
+++ b/apps/web/e2e/fixtures/test-base.ts
@@ -266,6 +266,15 @@ export const test = backendFixture.extend<
await apiClient.rawRequest("DELETE", `/api/v1/jira/config`).catch(() => undefined);
await apiClient.rawRequest("DELETE", `/api/v1/linear/config`).catch(() => undefined);
await Promise.all([
+ // Provider-focused specs reuse and mutate the worker-scoped seed row.
+ // Restore its local-only identity before the next test; otherwise a
+ // removed mock remote plus stale provider metadata breaks workspace prep.
+ apiClient.updateRepository(seedData.repositoryId, {
+ provider: "",
+ provider_host: "",
+ provider_owner: "",
+ provider_name: "",
+ }),
apiClient.mockJiraReset().catch(() => undefined),
apiClient.mockLinearReset().catch(() => undefined),
apiClient.mockSentryReset().catch(() => undefined),
diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts
index 27ec175ad5..b4a43daf68 100644
--- a/apps/web/e2e/tests/command-panel.spec.ts
+++ b/apps/web/e2e/tests/command-panel.spec.ts
@@ -13,11 +13,11 @@ const HOME_COMMAND_ALIASES: CommandAliasExpectation[] = [
{ query: "statistics", label: "Go to Stats" },
{ query: "issues", label: "Go to GitHub Dashboard" },
{ query: "pull request", label: "Go to GitHub Dashboard" },
- { query: "agent profiles", label: "Agents Settings" },
- { query: "claude", label: "Agents Settings" },
- { query: "execution environment", label: "Executors Settings" },
- { query: "custom prompts", label: "Prompts Settings" },
- { query: "prompt templates", label: "Prompts Settings" },
+ { query: "agent profiles", label: "Agents" },
+ { query: "claude", label: "Agents" },
+ { query: "execution environment", label: "Executors" },
+ { query: "custom prompts", label: "Prompts" },
+ { query: "prompt templates", label: "Prompts" },
{ query: "color theme", label: "Switch to Dark Mode" },
{ query: "new quick chat", label: "Quick Chat" },
{ query: "configure kandev", label: "Configuration Chat" },
@@ -113,6 +113,33 @@ test.describe("Command Panel", () => {
await expect(commandOption(testPage, "Environments Settings")).toHaveCount(0);
});
+ test("settings discovery stays quiet until typing and lands on an exact control", async ({
+ testPage,
+ }) => {
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ await openCommandPanel(testPage);
+ const dialog = commandDialog(testPage);
+ await expect(dialog).toBeVisible({ timeout: 5_000 });
+ await expect(commandOption(testPage, "Terminal Font Size")).toHaveCount(0);
+
+ const input = dialog.getByRole("combobox");
+ await input.fill("terminal font size");
+ await expect(dialog.locator("[cmdk-group-heading]")).toHaveText(["Settings"]);
+ const option = commandOption(testPage, "Terminal Font Size");
+ await expect(option).toBeVisible();
+ await expect(option.getByText("Settings › General › Terminal", { exact: true })).toBeVisible();
+ await expect(option).toHaveAttribute("data-selected", "true");
+ await input.press("Enter");
+
+ await expect(dialog).not.toBeVisible();
+ await expect(testPage).toHaveURL(/\/settings\/general\/terminal#setting-terminal-font-size$/);
+ const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
+ await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
+ await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
+ });
+
test("common aliases find worktree session commands", async ({
testPage,
apiClient,
diff --git a/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts b/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts
index fc9a566280..6cad183899 100644
--- a/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts
+++ b/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts
@@ -10,17 +10,14 @@ const PR_NUMBER = 42;
const PR_URL = `https://github.com/${OWNER}/${REPO}/pull/${PR_NUMBER}`;
type SeedResult = {
- workflowId: string;
- inboxStepId: string;
- workingStepId: string;
doneStepId: string;
taskId: string;
};
/**
- * Stand up a workspace + workflow + task that reaches the Done column
- * immediately (auto-start + on_turn_complete moves it). Returns the IDs the
- * spec needs to seed PR data + open the task.
+ * Stand up a workspace + workflow + completed task. These tests exercise the
+ * PR popover, so seed the session directly instead of launching an agent and
+ * making unrelated executor timing part of the fixture.
*/
async function seedTask(
apiClient: ApiClient,
@@ -30,17 +27,8 @@ async function seedTask(
title: string,
): Promise {
const workflow = await apiClient.createWorkflow(workspaceId, `${title} Workflow`);
- const inbox = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
- const working = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
- const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
-
- await apiClient.updateWorkflowStep(working.id, {
- prompt: 'e2e:message("done")\n{{task_prompt}}',
- events: {
- on_enter: [{ type: "auto_start_agent" }],
- on_turn_complete: [{ type: "move_to_step", config: { step_id: done.id } }],
- },
- });
+ await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
+ const done = await apiClient.createWorkflowStep(workflow.id, "Done", 1);
await apiClient.saveUserSettings({
workspace_id: workspaceId,
@@ -53,15 +41,20 @@ async function seedTask(
const task = await apiClient.createTask(workspaceId, title, {
workflow_id: workflow.id,
- workflow_step_id: inbox.id,
+ workflow_step_id: done.id,
agent_profile_id: agentProfileId,
repository_ids: [repositoryId],
});
+ const now = new Date().toISOString();
+ await apiClient.seedTaskSession(task.id, {
+ state: "COMPLETED",
+ agentProfileId,
+ repositoryId,
+ startedAt: now,
+ completedAt: now,
+ });
return {
- workflowId: workflow.id,
- inboxStepId: inbox.id,
- workingStepId: working.id,
doneStepId: done.id,
taskId: task.id,
};
@@ -124,14 +117,12 @@ async function expectScrollablePopoverWithinViewport(testPage: Page, locator: Lo
async function openTaskAndWait(
testPage: import("@playwright/test").Page,
- apiClient: ApiClient,
seed: SeedResult,
title: string,
): Promise {
const kanban = new KanbanPage(testPage);
await kanban.goto();
- await apiClient.moveTask(seed.taskId, seed.workflowId, seed.workingStepId);
- await expect(kanban.taskCardInColumn(title, seed.doneStepId)).toBeVisible({ timeout: 45_000 });
+ await expect(kanban.taskCardInColumn(title, seed.doneStepId)).toBeVisible({ timeout: 15_000 });
await kanban.taskCardInColumn(title, seed.doneStepId).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
@@ -163,7 +154,7 @@ test.describe("PR top-bar CI popover", () => {
review_count: 1,
pending_review_count: 0,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prCheckGroupCount("passed")).toHaveText("22");
@@ -191,7 +182,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 22,
checks_passing: 22,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prCheckGroup("passed")).toBeVisible();
@@ -234,7 +225,7 @@ test.describe("PR top-bar CI popover", () => {
{ name: "E2E / e", status: "completed", conclusion: "success", html_url: "" },
],
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prWorkflowRow("Lint")).toBeVisible({ timeout: 10_000 });
@@ -268,7 +259,7 @@ test.describe("PR top-bar CI popover", () => {
pr_number: PR_NUMBER,
checks: manyRunningChecks(30),
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await expect(session.prStatusChip()).toBeVisible();
await session.hoverPRTopbar();
@@ -299,7 +290,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 1,
checks_passing: 1,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prReviewRow()).toBeVisible();
@@ -331,7 +322,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 1,
checks_passing: 1,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prReviewRow()).toBeVisible();
@@ -357,7 +348,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 1,
checks_passing: 1,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
// Move the cursor far away from the popover; close timer fires and the
@@ -399,7 +390,7 @@ test.describe("PR top-bar CI popover", () => {
},
],
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
// Wait for the async CI content so the popover is at its final size/position
@@ -456,7 +447,7 @@ test.describe("PR top-bar CI popover", () => {
},
],
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await expect(session.prStatusChip()).toBeVisible();
await session.hoverPRChip();
@@ -512,7 +503,7 @@ test.describe("PR top-bar CI popover", () => {
},
],
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
// Move into the popover so it stays open while we read the row.
await session.prTopbarPopover().hover();
@@ -537,7 +528,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 1,
checks_passing: 1,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prPopoverPRLink()).toHaveAttribute("href", PR_URL);
await expect(session.prTopbarPopover().getByLabel("View all checks on GitHub")).toHaveCount(0);
@@ -558,7 +549,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 0,
checks_passing: 0,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prChecksEmpty()).toBeVisible();
await expect(session.prChecksEmpty()).toContainText("No checks have started");
@@ -580,7 +571,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 2,
checks_passing: 1,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await expect(session.prPopoverReconnectLink()).toBeVisible();
await expect(session.prPopoverReconnectLink()).toHaveAttribute("href", /settings/);
@@ -605,7 +596,7 @@ test.describe("PR top-bar CI popover", () => {
checks_total: 27,
checks_passing: 22,
});
- const session = await openTaskAndWait(testPage, apiClient, seed, title);
+ const session = await openTaskAndWait(testPage, seed, title);
await session.hoverPRTopbar();
await session.prTopbarPopover().hover();
await expect(session.prCheckGroupCount("passed")).toHaveText("22");
diff --git a/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts
new file mode 100644
index 0000000000..ea8fc540ec
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts
@@ -0,0 +1,77 @@
+import type { Locator } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+
+function settingsResult(scope: Locator, label: string) {
+ return scope.getByRole("link").filter({ hasText: new RegExp(`^${label}`) });
+}
+
+test.describe("Mobile settings discovery", () => {
+ test("searches in the Settings sheet and reveals the exact control", async ({ testPage }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/settings/general/appearance");
+
+ const menuButton = testPage.getByTestId("settings-mobile-menu-button");
+ const menuButtonBox = await menuButton.boundingBox();
+ expect(menuButtonBox).not.toBeNull();
+ expect(menuButtonBox!.width).toBeGreaterThanOrEqual(44);
+ expect(menuButtonBox!.height).toBeGreaterThanOrEqual(44);
+ await menuButton.click();
+
+ const menu = testPage.getByTestId("settings-mobile-menu");
+ await expect(menu).toBeVisible();
+ await expect
+ .poll(async () => (await menu.boundingBox())?.x ?? -1, { message: "Settings sheet settled" })
+ .toBeGreaterThanOrEqual(0);
+ const menuBox = await menu.boundingBox();
+ expect(menuBox).not.toBeNull();
+ expect(menuBox!.x).toBeGreaterThanOrEqual(0);
+ expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(390);
+
+ const navigation = menu.locator("nav");
+ expect(await navigation.evaluate((element) => getComputedStyle(element).overflowY)).toBe(
+ "auto",
+ );
+ expect(
+ await navigation.locator("*").evaluateAll(
+ (elements) =>
+ elements.filter((element) => {
+ const overflow = getComputedStyle(element).overflowY;
+ return overflow === "auto" || overflow === "scroll";
+ }).length,
+ ),
+ ).toBe(0);
+
+ const search = menu.getByRole("searchbox", { name: "Search settings" });
+ await search.fill("terminal font size");
+ const searchBox = await search.boundingBox();
+ expect(searchBox).not.toBeNull();
+ expect(searchBox!.height).toBeGreaterThanOrEqual(44);
+
+ const clear = menu.getByRole("button", { name: "Clear settings search" });
+ const clearBox = await clear.boundingBox();
+ expect(clearBox).not.toBeNull();
+ expect(clearBox!.width).toBeGreaterThanOrEqual(44);
+ expect(clearBox!.height).toBeGreaterThanOrEqual(44);
+
+ const result = settingsResult(menu, "Terminal Font Size");
+ await expect(result).toBeVisible();
+ const resultBox = await result.boundingBox();
+ expect(resultBox).not.toBeNull();
+ expect(resultBox!.height).toBeGreaterThanOrEqual(44);
+ await result.click();
+
+ await expect(menu).not.toBeVisible();
+ await expect(testPage).toHaveURL(/\/settings\/general\/terminal#setting-terminal-font-size$/);
+ const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
+ await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
+ await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
+ expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
+ await testPage.evaluate(() => document.documentElement.clientWidth),
+ );
+ expect(
+ await testPage
+ .getByTestId("settings-scroll-container")
+ .evaluate((element) => getComputedStyle(element).overflowY),
+ ).toBe("auto");
+ });
+});
diff --git a/apps/web/e2e/tests/settings/settings-discovery.spec.ts b/apps/web/e2e/tests/settings/settings-discovery.spec.ts
new file mode 100644
index 0000000000..d2d6c9fcd5
--- /dev/null
+++ b/apps/web/e2e/tests/settings/settings-discovery.spec.ts
@@ -0,0 +1,159 @@
+import type { Locator, Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+
+const APPEARANCE_PATH = "/settings/general/appearance";
+
+function settingsSidebar(page: Page) {
+ return page.getByTestId("app-sidebar-settings-mode");
+}
+
+function settingsResult(scope: Locator, label: string) {
+ return scope.getByRole("link").filter({ hasText: new RegExp(`^${label}`) });
+}
+
+test.describe("Settings discovery", () => {
+ test("gently reflows matching rows and respects reduced motion", async ({ testPage }) => {
+ await testPage.goto(APPEARANCE_PATH);
+ const sidebar = settingsSidebar(testPage);
+ const search = sidebar.getByRole("searchbox", { name: "Search settings" });
+ await search.fill("font");
+
+ const motionRows = sidebar.locator('[data-settings-search-motion-key^="item:"]');
+ await expect(motionRows).toHaveCount(2);
+ await waitForMotionToSettle(motionRows);
+
+ const animatedReorder = await changeSearchAndSampleMotion(search, "terminal font");
+ expect(animatedReorder.map((row) => row.key)).toEqual([
+ "item:terminal-font-family",
+ "item:terminal-font-size",
+ ]);
+ // FLIP first paints the surviving rows at their old visual positions even though the DOM is
+ // already in ranked order. This inversion is the continuity we are testing.
+ expect(animatedReorder[0].top).toBeGreaterThan(animatedReorder[1].top);
+ expect(animatedReorder.every((row) => row.animations.includes(160))).toBe(true);
+ await waitForMotionToSettle(motionRows);
+ const settledTops = await motionRows.evaluateAll((rows) =>
+ rows.map((row) => row.getBoundingClientRect().top),
+ );
+ expect(settledTops[0]).toBeLessThan(settledTops[1]);
+
+ await testPage.emulateMedia({ reducedMotion: "reduce" });
+ await search.fill("font");
+ const reducedMotionReorder = await changeSearchAndSampleMotion(search, "terminal font");
+ expect(reducedMotionReorder.every((row) => row.animations.length === 0)).toBe(true);
+ });
+
+ test("filters the tree, reveals an exact control, and preserves browser history", async ({
+ testPage,
+ }) => {
+ await testPage.goto(APPEARANCE_PATH);
+ const sidebar = settingsSidebar(testPage);
+ const search = sidebar.getByRole("searchbox", { name: "Search settings" });
+ await expect(search).toBeVisible();
+ const [searchBox, navigationRowBox] = await Promise.all([
+ search.boundingBox(),
+ sidebar.getByRole("link", { name: "Appearance" }).boundingBox(),
+ ]);
+ expect(searchBox).not.toBeNull();
+ expect(navigationRowBox).not.toBeNull();
+ expect(Math.abs(searchBox!.height - navigationRowBox!.height)).toBeLessThanOrEqual(2);
+
+ await search.fill("no such setting exists");
+ await expect(sidebar.getByText("No matching settings", { exact: true })).toBeVisible();
+ await search.press("Escape");
+ await expect(search).toHaveValue("");
+ await expect(sidebar.getByRole("link", { name: "Appearance" })).toBeVisible();
+
+ await search.fill("terminal font size");
+ const result = settingsResult(sidebar, "Terminal Font Size");
+ await expect(result).toBeVisible();
+ await expect(result).toContainText("General › Terminal");
+ await expect(sidebar.getByRole("link", { name: "Appearance" })).toHaveCount(0);
+ await result.click();
+
+ await expect(testPage).toHaveURL(/\/settings\/general\/terminal#setting-terminal-font-size$/);
+ const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
+ await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
+ await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
+
+ await testPage.goBack();
+ await expect(testPage).toHaveURL(new RegExp(`${APPEARANCE_PATH}$`));
+ await expect(testPage.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible();
+ });
+
+ test("targets the current dirty page without opening the leave guard", async ({
+ testPage,
+ apiClient,
+ }) => {
+ const initial = await apiClient.getUserSettings();
+ const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
+ const nextLayout = initialLayout === "tree" ? "Flat list" : "Tree";
+
+ await testPage.goto(APPEARANCE_PATH);
+ await testPage.getByTestId("changes-panel-layout-select").click();
+ await testPage.getByRole("option", { name: nextLayout }).click();
+ await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
+
+ const sidebar = settingsSidebar(testPage);
+ await sidebar.getByRole("searchbox", { name: "Search settings" }).fill("resource metrics");
+ await settingsResult(sidebar, "Resource Metrics").click();
+
+ await expect(testPage).toHaveURL(/\/settings\/general\/appearance#setting-resource-metrics$/);
+ await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
+ const target = testPage.locator('[data-settings-target="setting-resource-metrics"]');
+ await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
+ expect(await target.evaluate((element) => element.contains(document.activeElement))).toBe(true);
+ await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
+ });
+
+ test("uses the existing leave guard for a dirty cross-page result", async ({
+ testPage,
+ apiClient,
+ }) => {
+ const initial = await apiClient.getUserSettings();
+ const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
+ const nextLayout = initialLayout === "tree" ? "Flat list" : "Tree";
+
+ await testPage.goto(APPEARANCE_PATH);
+ await testPage.getByTestId("changes-panel-layout-select").click();
+ await testPage.getByRole("option", { name: nextLayout }).click();
+
+ const sidebar = settingsSidebar(testPage);
+ await sidebar.getByRole("searchbox", { name: "Search settings" }).fill("terminal font size");
+ await settingsResult(sidebar, "Terminal Font Size").click();
+
+ const guard = testPage.getByRole("alertdialog", { name: "Save changes before leaving?" });
+ await expect(guard).toBeVisible();
+ await guard.getByRole("button", { name: "Continue editing" }).click();
+ await expect(testPage).toHaveURL(new RegExp(`${APPEARANCE_PATH}$`));
+ await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
+ });
+});
+
+async function waitForMotionToSettle(rows: Locator) {
+ await rows.evaluateAll(async (elements) => {
+ await Promise.all(
+ elements.flatMap((element) => element.getAnimations().map((animation) => animation.finished)),
+ );
+ });
+}
+
+async function changeSearchAndSampleMotion(search: Locator, query: string) {
+ return search.evaluate(async (input, nextQuery) => {
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ valueSetter?.call(input, nextQuery);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+
+ return [...document.querySelectorAll('[data-settings-search-motion-key^="item:"]')]
+ .map((row) => ({
+ key: row.dataset.settingsSearchMotionKey,
+ top: row.getBoundingClientRect().top,
+ animations: row.getAnimations().map((animation) => animation.effect?.getTiming().duration),
+ }))
+ .filter(
+ (row): row is { key: string; top: number; animations: Array } =>
+ Boolean(row.key),
+ );
+ }, query);
+}
diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-cancel-completion.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-cancel-completion.spec.ts
index f61189ea0d..5d7ffca06d 100644
--- a/apps/web/e2e/tests/workflow/mobile-workflow-cancel-completion.spec.ts
+++ b/apps/web/e2e/tests/workflow/mobile-workflow-cancel-completion.spec.ts
@@ -15,6 +15,10 @@ async function tapCancelButton(session: SessionPage) {
// cancelling state is observable; the two-second responsiveness assertion
// starts only after that state proves the request was accepted.
await expect(async () => {
+ // A successful request can finish between toPass attempts. In that case the
+ // button is already gone; the assertions below still prove the idle state
+ // and completion-action transition rather than trying to tap a stale locator.
+ if (!(await button.isVisible())) return;
await button.scrollIntoViewIfNeeded();
await button.tap();
await expect
diff --git a/apps/web/hooks/domains/settings/use-settings-discovery.test.ts b/apps/web/hooks/domains/settings/use-settings-discovery.test.ts
new file mode 100644
index 0000000000..bdd90e97dc
--- /dev/null
+++ b/apps/web/hooks/domains/settings/use-settings-discovery.test.ts
@@ -0,0 +1,113 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ authEnabled: false,
+ state: {
+ auth: {
+ mode: "disabled" as "disabled" | "enabled" | "setup",
+ user: null as { role: string } | null,
+ },
+ workspaces: {
+ items: [] as Array<{ id: string; name: string }>,
+ },
+ settingsAgents: {
+ items: [] as Array<{
+ name: string;
+ profiles: Array<{ id: string; name: string; agentDisplayName?: string }>;
+ }>,
+ },
+ executors: {
+ items: [] as Array<{
+ type: string;
+ profiles?: Array<{ id: string; name: string }>;
+ }>,
+ },
+ },
+ t: vi.fn((key: string) => key),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: mocks.t }),
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
+}));
+
+vi.mock("@/hooks/domains/features/use-feature", () => ({
+ useFeature: () => mocks.authEnabled,
+}));
+
+import { useSettingsDiscovery } from "./use-settings-discovery";
+
+describe("useSettingsDiscovery hook", () => {
+ beforeEach(() => {
+ mocks.authEnabled = false;
+ mocks.state.auth.mode = "disabled";
+ mocks.state.auth.user = null;
+ mocks.state.workspaces.items = [];
+ mocks.state.settingsAgents.items = [];
+ mocks.state.executors.items = [];
+ });
+
+ it("follows the auth feature and mode when exposing account settings", () => {
+ const { result, rerender } = renderHook(() => useSettingsDiscovery());
+
+ expect(result.current.some((entry) => entry.groupId === "account")).toBe(false);
+
+ mocks.authEnabled = true;
+ rerender();
+
+ expect(result.current.some((entry) => entry.groupId === "account")).toBe(false);
+
+ mocks.state.auth.mode = "enabled";
+ rerender();
+
+ expect(result.current.some((entry) => entry.groupId === "account")).toBe(true);
+ });
+
+ it("exposes user administration only to admins", () => {
+ mocks.authEnabled = true;
+ mocks.state.auth.mode = "enabled";
+ mocks.state.auth.user = { role: "member" };
+ const { result, rerender } = renderHook(() => useSettingsDiscovery());
+
+ expect(result.current.some((entry) => entry.id === "system-users")).toBe(false);
+
+ mocks.state.auth.user = { role: "admin" };
+ rerender();
+
+ expect(result.current.some((entry) => entry.id === "system-users")).toBe(true);
+ });
+
+ it("propagates dynamic workspace, agent, and executor store updates", () => {
+ const { result, rerender } = renderHook(() => useSettingsDiscovery());
+
+ expect(result.current.some((entry) => entry.id.startsWith("workspace:"))).toBe(false);
+ expect(result.current.some((entry) => entry.id.startsWith("agent-profile:"))).toBe(false);
+ expect(result.current.some((entry) => entry.id.startsWith("executor-profile:"))).toBe(false);
+
+ mocks.state.workspaces.items = [{ id: "workspace-1", name: "Product" }];
+ mocks.state.settingsAgents.items = [
+ {
+ name: "codex",
+ profiles: [{ id: "agent-1", name: "Careful", agentDisplayName: "Codex" }],
+ },
+ ];
+ mocks.state.executors.items = [
+ { type: "docker", profiles: [{ id: "executor-1", name: "Local Docker" }] },
+ ];
+ rerender();
+
+ expect(result.current.find((entry) => entry.id === "workspace:workspace-1")?.label).toBe(
+ "Product",
+ );
+ expect(result.current.find((entry) => entry.id === "agent-profile:agent-1")?.label).toBe(
+ "Codex • Careful",
+ );
+ expect(result.current.find((entry) => entry.id === "executor-profile:executor-1")?.label).toBe(
+ "Local Docker",
+ );
+ });
+});
diff --git a/apps/web/hooks/domains/settings/use-settings-discovery.ts b/apps/web/hooks/domains/settings/use-settings-discovery.ts
new file mode 100644
index 0000000000..cf82818b61
--- /dev/null
+++ b/apps/web/hooks/domains/settings/use-settings-discovery.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { useAppStore } from "@/components/state-provider";
+import { useFeature } from "@/hooks/domains/features/use-feature";
+import { resolveSettingsDiscovery } from "@/lib/settings-discovery/resolve";
+
+export function useSettingsDiscovery() {
+ const { t } = useTranslation();
+ const authEnabled = useFeature("auth");
+ const authMode = useAppStore((state) => state.auth.mode);
+ const role = useAppStore((state) => state.auth.user?.role);
+ const workspaces = useAppStore((state) => state.workspaces.items);
+ const agents = useAppStore((state) => state.settingsAgents.items);
+ const executors = useAppStore((state) => state.executors.items);
+ const showAccount = authEnabled && authMode === "enabled";
+ const showUsers = authEnabled && (role === undefined || role === "admin");
+
+ return useMemo(
+ () =>
+ resolveSettingsDiscovery({
+ t,
+ showAccount,
+ showUsers,
+ workspaces,
+ agents,
+ executors,
+ }),
+ [agents, executors, showAccount, showUsers, t, workspaces],
+ );
+}
diff --git a/apps/web/lib/commands/types.ts b/apps/web/lib/commands/types.ts
index bf9fd5f164..1f64cce2ad 100644
--- a/apps/web/lib/commands/types.ts
+++ b/apps/web/lib/commands/types.ts
@@ -15,6 +15,10 @@ export type CommandItem = {
icon?: ReactNode;
shortcut?: KeyboardShortcut;
keywords?: string[];
+ /** Secondary display text. Deliberately excluded from command search terms. */
+ context?: string;
+ /** Hidden from the idle palette; becomes eligible after the user types. */
+ searchOnly?: boolean;
/** For level-2 transitions: set the mode instead of running an action */
enterMode?: CommandPanelMode;
/** Standard action — close panel and execute */
diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts
new file mode 100644
index 0000000000..fb30152d32
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog.test.ts
@@ -0,0 +1,256 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { t } from "@/lib/i18n";
+import { SETTINGS_DISCOVERY_DEFINITIONS, SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS } from "./catalog";
+import { resolveSettingsDiscovery } from "./resolve";
+
+const GITHUB_CONNECTION_ID = "integration-github-connection";
+
+const translate = (key: string) =>
+ (
+ ({
+ "common:agents": "Agents",
+ "common:executors": "Executors",
+ "common:workspaces": "Workspaces",
+ "settings:general": "General",
+ "sidebar:repositories": "Repositories",
+ "workflows:workflows": "Workflows",
+ }) as Record
+ )[key] ?? key;
+
+describe("settings discovery catalog invariants", () => {
+ it("uses unique ids, valid parents, and targets for every control", () => {
+ const ids = new Set(SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.id));
+ const targets = SETTINGS_DISCOVERY_DEFINITIONS.flatMap((entry) =>
+ entry.targetId ? [entry.targetId] : [],
+ );
+
+ expect(ids.size).toBe(SETTINGS_DISCOVERY_DEFINITIONS.length);
+ expect(new Set(targets).size).toBe(targets.length);
+ for (const entry of SETTINGS_DISCOVERY_DEFINITIONS) {
+ if (entry.parentId) {
+ expect(ids.has(entry.parentId), `${entry.id} has missing parent ${entry.parentId}`).toBe(
+ true,
+ );
+ }
+ if (entry.kind === "control") {
+ expect(entry.targetId, `${entry.id} has no exact target`).toMatch(/^setting-/);
+ }
+ }
+ });
+
+ it("indexes stable controls across general and standalone settings", () => {
+ const ids = SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.id);
+
+ expect(ids).toEqual(
+ expect.arrayContaining([
+ "appearance-color-theme",
+ "appearance-startup-page",
+ "appearance-display-language",
+ "terminal-preferred-shell",
+ "notifications-desktop",
+ "notifications-sound",
+ "keyboard-submit-shortcut",
+ "keyboard-command-shortcuts",
+ "task-actions-agent-profile",
+ "task-actions-archive-confirmation",
+ "task-actions-transcript-navigation",
+ "layouts-profiles",
+ "sprites-connection",
+ "sprites-instances",
+ "voice-enable",
+ "voice-engine",
+ "voice-behavior",
+ "utility-default-model",
+ "utility-actions",
+ "external-mcp-endpoints",
+ "external-mcp-snippets",
+ ]),
+ );
+ });
+
+ it("indexes stable integration, system, and account sections", () => {
+ const ids = SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.id);
+
+ expect(ids).toEqual(
+ expect.arrayContaining([
+ GITHUB_CONNECTION_ID,
+ "integration-jira-connection",
+ "system-storage-actions",
+ "system-storage-schedule",
+ "system-storage-workspaces",
+ "system-storage-go-cache",
+ "system-storage-docker",
+ "system-storage-quarantine",
+ "account-password",
+ "account-sessions",
+ ]),
+ );
+ });
+
+ it("has no parent cycles", () => {
+ const byId = new Map(SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => [entry.id, entry]));
+
+ for (const entry of SETTINGS_DISCOVERY_DEFINITIONS) {
+ const visited = new Set();
+ let current = entry;
+ while (current.parentId) {
+ expect(visited.has(current.id), `cycle includes ${current.id}`).toBe(false);
+ visited.add(current.id);
+ const parent = byId.get(current.parentId);
+ if (!parent) break;
+ current = parent;
+ }
+ }
+ });
+
+ it("covers every static settings route or records an explicit reason", () => {
+ const here = path.dirname(fileURLToPath(import.meta.url));
+ const source = readFileSync(path.join(here, "../../src/settings-routes.tsx"), "utf8");
+ const routes = [...source.matchAll(/^\s*"([^"]+)":\s*\(\)\s*=>/gm)].map((match) => match[1]);
+ expect(routes.length).toBeGreaterThan(20);
+
+ const covered = new Set(SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.href));
+ const missing = routes.filter(
+ (route) => !covered.has(route) && !(route in SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS),
+ );
+
+ expect(missing).toEqual([]);
+ for (const reason of Object.values(SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS)) {
+ expect(reason.length).toBeGreaterThan(12);
+ }
+ });
+});
+
+describe("resolveSettingsDiscovery dynamic entries", () => {
+ it("uses the capitalized Settings navigation labels for workspace pages", () => {
+ const resolved = resolveSettingsDiscovery({
+ t: (key) => t(key),
+ showAccount: false,
+ showUsers: false,
+ workspaces: [{ id: "workspace-1", name: "Main Workspace" }],
+ agents: [],
+ executors: [],
+ });
+
+ expect(
+ ["repositories", "workflows"].map(
+ (suffix) => resolved.find((entry) => entry.id === `workspace:workspace-1:${suffix}`)?.label,
+ ),
+ ).toEqual(["Repositories", "Workflows"]);
+ });
+
+ it("encodes dynamic workspace, agent profile, and executor profile routes", () => {
+ const resolved = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: true,
+ showUsers: true,
+ workspaces: [{ id: "workspace / one", name: "Main Workspace" }],
+ agents: [
+ {
+ name: "Claude Code",
+ profiles: [{ id: "profile / one", name: "Sonnet", agentDisplayName: "Claude" }],
+ },
+ ],
+ executors: [
+ {
+ type: "docker",
+ profiles: [{ id: "executor / one", name: "Docker Local" }],
+ },
+ ],
+ });
+
+ expect(resolved.find((entry) => entry.id === "workspace:workspace / one")?.href).toBe(
+ "/settings/workspace/workspace%20%2F%20one",
+ );
+ expect(resolved.find((entry) => entry.id === "agent-profile:profile / one")?.href).toBe(
+ "/settings/agents/Claude%20Code/profiles/profile%20%2F%20one",
+ );
+ expect(resolved.find((entry) => entry.id === "executor-profile:executor / one")?.href).toBe(
+ "/settings/executors/executor%20%2F%20one",
+ );
+ expect(resolved.find((entry) => entry.id === "workspace:workspace / one:name")?.href).toBe(
+ "/settings/workspace/workspace%20%2F%20one#setting-workspace-workspace%20%2F%20one-name",
+ );
+ expect(
+ resolved.find((entry) => entry.id === "agent-profile:profile / one:environment-variables")
+ ?.href,
+ ).toBe(
+ "/settings/agents/Claude%20Code/profiles/profile%20%2F%20one#setting-agent-profile-profile%20%2F%20one-environment-variables",
+ );
+ expect(
+ resolved.find((entry) => entry.id === "executor-profile:executor / one:prepare-script")?.href,
+ ).toBe(
+ "/settings/executors/executor%20%2F%20one#setting-executor-profile-executor%20%2F%20one-prepare-script",
+ );
+ });
+
+ it("uses safe dynamic names as labels and translated ancestors as breadcrumbs", () => {
+ const resolved = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: false,
+ showUsers: false,
+ workspaces: [{ id: "workspace-1", name: "Personal value" }],
+ agents: [],
+ executors: [],
+ });
+ const workspace = resolved.find((entry) => entry.id === "workspace:workspace-1");
+ const repository = resolved.find((entry) => entry.id === "workspace:workspace-1:repositories");
+
+ expect(workspace?.label).toBe("Personal value");
+ expect(workspace?.breadcrumb).toEqual(["Workspaces"]);
+ expect(repository?.label).toBe("Repositories");
+ expect(repository?.breadcrumb).toEqual(["Workspaces", "Personal value"]);
+ });
+});
+
+describe("resolveSettingsDiscovery visibility", () => {
+ it("omits account and user-management entries when their rendering gates are closed", () => {
+ const hidden = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: false,
+ showUsers: false,
+ workspaces: [],
+ agents: [],
+ executors: [],
+ });
+ const visible = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: true,
+ showUsers: true,
+ workspaces: [],
+ agents: [],
+ executors: [],
+ });
+
+ expect(hidden.some((entry) => entry.groupId === "account")).toBe(false);
+ expect(hidden.some((entry) => entry.id === "system-users")).toBe(false);
+ expect(hidden.some((entry) => entry.id === GITHUB_CONNECTION_ID)).toBe(false);
+ expect(visible.some((entry) => entry.groupId === "account")).toBe(true);
+ expect(visible.some((entry) => entry.id === "system-users")).toBe(true);
+ });
+
+ it("shows integration connection sections only when a workspace can render them", () => {
+ const withoutWorkspace = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: false,
+ showUsers: false,
+ workspaces: [],
+ agents: [],
+ executors: [],
+ });
+ const withWorkspace = resolveSettingsDiscovery({
+ t: translate,
+ showAccount: false,
+ showUsers: false,
+ workspaces: [{ id: "workspace-1", name: "Main" }],
+ agents: [],
+ executors: [],
+ });
+
+ expect(withoutWorkspace.some((entry) => entry.id === GITHUB_CONNECTION_ID)).toBe(false);
+ expect(withWorkspace.some((entry) => entry.id === GITHUB_CONNECTION_ID)).toBe(true);
+ });
+});
diff --git a/apps/web/lib/settings-discovery/catalog.ts b/apps/web/lib/settings-discovery/catalog.ts
new file mode 100644
index 0000000000..aacde4087b
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog.ts
@@ -0,0 +1,4 @@
+export {
+ SETTINGS_DISCOVERY_DEFINITIONS,
+ SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS,
+} from "./catalog/index";
diff --git a/apps/web/lib/settings-discovery/catalog/account.ts b/apps/web/lib/settings-discovery/catalog/account.ts
new file mode 100644
index 0000000000..c73055f365
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/account.ts
@@ -0,0 +1,62 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const ACCOUNT_SETTINGS_HREF = "/settings/account";
+export const ACCOUNT_SECURITY_SETTINGS_HREF = `${ACCOUNT_SETTINGS_HREF}/security`;
+export const ACCOUNT_SETTINGS_TARGETS = {
+ password: "setting-account-password",
+ sessions: "setting-account-sessions",
+} as const;
+
+export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "account",
+ kind: "page",
+ labelKey: "sidebar:account",
+ groupId: "account",
+ href: ACCOUNT_SECURITY_SETTINGS_HREF,
+ order: 800,
+ requires: "account",
+ },
+ {
+ id: "account-security",
+ kind: "page",
+ labelKey: "sidebar:profileAndPassword",
+ parentId: "account",
+ groupId: "account",
+ href: ACCOUNT_SECURITY_SETTINGS_HREF,
+ order: 810,
+ requires: "account",
+ },
+ {
+ id: "account-tokens",
+ kind: "page",
+ labelKey: "sidebar:apiTokens",
+ parentId: "account",
+ groupId: "account",
+ href: "/settings/account/tokens",
+ order: 820,
+ requires: "account",
+ },
+ {
+ id: "account-password",
+ kind: "control",
+ labelKey: "settings:password",
+ parentId: "account-security",
+ groupId: "account",
+ href: ACCOUNT_SECURITY_SETTINGS_HREF,
+ targetId: ACCOUNT_SETTINGS_TARGETS.password,
+ order: 811,
+ requires: "account",
+ },
+ {
+ id: "account-sessions",
+ kind: "section",
+ labelKey: "settings:activeSessions",
+ parentId: "account-security",
+ groupId: "account",
+ href: ACCOUNT_SECURITY_SETTINGS_HREF,
+ targetId: ACCOUNT_SETTINGS_TARGETS.sessions,
+ order: 812,
+ requires: "account",
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/agents.ts b/apps/web/lib/settings-discovery/catalog/agents.ts
new file mode 100644
index 0000000000..586d369f42
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/agents.ts
@@ -0,0 +1,15 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const AGENTS_SETTINGS_HREF = "/settings/agents";
+
+export const AGENT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "agents",
+ kind: "page",
+ labelKey: "common:agents",
+ aliasesKey: "common:commandAgentsSettingsKeywords",
+ groupId: "agents",
+ href: AGENTS_SETTINGS_HREF,
+ order: 300,
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/executors.ts b/apps/web/lib/settings-discovery/catalog/executors.ts
new file mode 100644
index 0000000000..7aa6fd5cff
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/executors.ts
@@ -0,0 +1,15 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const EXECUTORS_SETTINGS_HREF = "/settings/executors";
+
+export const EXECUTOR_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "executors",
+ kind: "page",
+ labelKey: "common:executors",
+ aliasesKey: "common:commandExecutorsSettingsKeywords",
+ groupId: "executors",
+ href: EXECUTORS_SETTINGS_HREF,
+ order: 400,
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/general.ts b/apps/web/lib/settings-discovery/catalog/general.ts
new file mode 100644
index 0000000000..ef7b08d550
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/general.ts
@@ -0,0 +1,395 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const TERMINAL_FONT_SIZE_TARGET = "setting-terminal-font-size";
+export const TERMINAL_FONT_FAMILY_TARGET = "setting-terminal-font-family";
+export const TERMINAL_LINKS_TARGET = "setting-terminal-links";
+export const GENERAL_SETTINGS_HREF = "/settings/general";
+const GENERAL_APPEARANCE_ID = "general-appearance";
+const GENERAL_APPEARANCE_HREF = `${GENERAL_SETTINGS_HREF}/appearance`;
+const GENERAL_TERMINAL_ID = "general-terminal";
+const GENERAL_TERMINAL_HREF = `${GENERAL_SETTINGS_HREF}/terminal`;
+const GENERAL_NOTIFICATIONS_ID = "general-notifications";
+const GENERAL_NOTIFICATIONS_HREF = `${GENERAL_SETTINGS_HREF}/notifications`;
+const GENERAL_TASK_ACTIONS_ID = "general-task-actions";
+const GENERAL_TASK_ACTIONS_HREF = `${GENERAL_SETTINGS_HREF}/task-actions`;
+export const GENERAL_SETTINGS_TARGETS = {
+ colorTheme: "setting-color-theme",
+ startupPage: "setting-startup-page",
+ displayLanguage: "setting-display-language",
+ changesPanelLayout: "setting-changes-panel-layout",
+ resourceMetrics: "setting-resource-metrics",
+ layoutProfiles: "setting-layout-profiles",
+ preferredShell: "setting-preferred-shell",
+ desktopNotifications: "setting-desktop-notifications",
+ notificationSound: "setting-notification-sound",
+ notificationProviders: "setting-notification-providers",
+ notificationEvents: "setting-notification-events",
+ fileEditor: "setting-file-editor",
+ submitShortcut: "setting-submit-shortcut",
+ keyboardShortcuts: "setting-keyboard-shortcuts",
+ agentTaskProfile: "setting-agent-task-profile",
+ agentGeneratedTitles: "setting-agent-generated-task-titles",
+ archiveConfirmation: "setting-archive-confirmation",
+ unreadMessages: "setting-unread-messages",
+ transcriptNavigation: "setting-transcript-navigation",
+ spritesConnection: "setting-sprites-connection",
+ spritesInstances: "setting-sprites-instances",
+} as const;
+
+export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "general",
+ kind: "page",
+ labelKey: "settings:general",
+ groupId: "general",
+ href: GENERAL_SETTINGS_HREF,
+ order: 0,
+ },
+ {
+ id: GENERAL_APPEARANCE_ID,
+ kind: "page",
+ labelKey: "settings:appearance",
+ parentId: "general",
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ order: 10,
+ },
+ {
+ id: "appearance-color-theme",
+ kind: "control",
+ labelKey: "settings:colorTheme",
+ aliasesKey: "settings:discoveryAliasesColorTheme",
+ parentId: GENERAL_APPEARANCE_ID,
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.colorTheme,
+ order: 11,
+ },
+ {
+ id: "appearance-startup-page",
+ kind: "control",
+ labelKey: "settings:startupPage",
+ aliasesKey: "settings:discoveryAliasesStartupPage",
+ parentId: GENERAL_APPEARANCE_ID,
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.startupPage,
+ order: 12,
+ },
+ {
+ id: "appearance-display-language",
+ kind: "control",
+ labelKey: "settings:displayLanguage",
+ aliasesKey: "settings:discoveryAliasesDisplayLanguage",
+ parentId: GENERAL_APPEARANCE_ID,
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.displayLanguage,
+ order: 13,
+ },
+ {
+ id: "appearance-changes-panel-layout",
+ kind: "control",
+ labelKey: "settings:changesPanelLayout",
+ parentId: GENERAL_APPEARANCE_ID,
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.changesPanelLayout,
+ order: 14,
+ },
+ {
+ id: "appearance-resource-metrics",
+ kind: "control",
+ labelKey: "settings:resourceMetrics",
+ aliasesKey: "settings:discoveryAliasesResourceMetrics",
+ parentId: GENERAL_APPEARANCE_ID,
+ groupId: "general",
+ href: GENERAL_APPEARANCE_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.resourceMetrics,
+ order: 15,
+ },
+ {
+ id: "general-layouts",
+ kind: "page",
+ labelKey: "settings:layouts",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/layouts",
+ order: 20,
+ },
+ {
+ id: "layouts-profiles",
+ kind: "section",
+ labelKey: "settings:layoutProfiles",
+ aliasesKey: "settings:discoveryAliasesLayoutProfiles",
+ parentId: "general-layouts",
+ groupId: "general",
+ href: "/settings/general/layouts",
+ targetId: GENERAL_SETTINGS_TARGETS.layoutProfiles,
+ order: 21,
+ },
+ {
+ id: GENERAL_TERMINAL_ID,
+ kind: "page",
+ labelKey: "settings:terminal",
+ parentId: "general",
+ groupId: "general",
+ href: GENERAL_TERMINAL_HREF,
+ order: 30,
+ },
+ {
+ id: "terminal-preferred-shell",
+ kind: "control",
+ labelKey: "settings:preferredShell",
+ aliasesKey: "settings:discoveryAliasesPreferredShell",
+ parentId: GENERAL_TERMINAL_ID,
+ groupId: "general",
+ href: GENERAL_TERMINAL_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.preferredShell,
+ order: 31,
+ },
+ {
+ id: "terminal-font-size",
+ kind: "control",
+ labelKey: "settings:terminalFontSize",
+ aliasesKey: "settings:discoveryAliasesTerminalFontSize",
+ parentId: GENERAL_TERMINAL_ID,
+ groupId: "general",
+ href: GENERAL_TERMINAL_HREF,
+ targetId: TERMINAL_FONT_SIZE_TARGET,
+ order: 32,
+ },
+ {
+ id: "terminal-font-family",
+ kind: "control",
+ labelKey: "settings:terminalFont",
+ aliasesKey: "settings:discoveryAliasesTerminalFont",
+ parentId: GENERAL_TERMINAL_ID,
+ groupId: "general",
+ href: GENERAL_TERMINAL_HREF,
+ targetId: TERMINAL_FONT_FAMILY_TARGET,
+ order: 33,
+ },
+ {
+ id: "terminal-links",
+ kind: "control",
+ labelKey: "settings:terminalLinks",
+ aliasesKey: "settings:discoveryAliasesTerminalLinks",
+ parentId: GENERAL_TERMINAL_ID,
+ groupId: "general",
+ href: GENERAL_TERMINAL_HREF,
+ targetId: TERMINAL_LINKS_TARGET,
+ order: 34,
+ },
+ {
+ id: GENERAL_NOTIFICATIONS_ID,
+ kind: "page",
+ labelKey: "settings:notifications",
+ parentId: "general",
+ groupId: "general",
+ href: GENERAL_NOTIFICATIONS_HREF,
+ order: 40,
+ },
+ {
+ id: "notifications-desktop",
+ kind: "control",
+ labelKey: "settings:desktopNotifications",
+ aliasesKey: "settings:discoveryAliasesDesktopNotifications",
+ parentId: GENERAL_NOTIFICATIONS_ID,
+ groupId: "general",
+ href: GENERAL_NOTIFICATIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.desktopNotifications,
+ order: 41,
+ },
+ {
+ id: "notifications-sound",
+ kind: "control",
+ labelKey: "settings:notificationSound",
+ aliasesKey: "settings:discoveryAliasesNotificationSound",
+ parentId: GENERAL_NOTIFICATIONS_ID,
+ groupId: "general",
+ href: GENERAL_NOTIFICATIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.notificationSound,
+ order: 42,
+ },
+ {
+ id: "notifications-providers",
+ kind: "section",
+ labelKey: "settings:externalProviders",
+ parentId: GENERAL_NOTIFICATIONS_ID,
+ groupId: "general",
+ href: GENERAL_NOTIFICATIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.notificationProviders,
+ order: 43,
+ },
+ {
+ id: "notifications-events",
+ kind: "section",
+ labelKey: "settings:notificationEvents",
+ parentId: GENERAL_NOTIFICATIONS_ID,
+ groupId: "general",
+ href: GENERAL_NOTIFICATIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.notificationEvents,
+ order: 44,
+ },
+ {
+ id: "general-editors",
+ kind: "page",
+ labelKey: "settings:editors",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/editors",
+ order: 50,
+ },
+ {
+ id: "editors-file-editor",
+ kind: "section",
+ labelKey: "settings:fileEditor",
+ aliasesKey: "settings:discoveryAliasesFileEditor",
+ parentId: "general-editors",
+ groupId: "general",
+ href: "/settings/general/editors",
+ targetId: GENERAL_SETTINGS_TARGETS.fileEditor,
+ order: 51,
+ },
+ {
+ id: "general-keyboard-shortcuts",
+ kind: "page",
+ labelKey: "settings:keyboardShortcuts",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/keyboard-shortcuts",
+ order: 60,
+ },
+ {
+ id: "keyboard-submit-shortcut",
+ kind: "control",
+ labelKey: "settings:submitShortcut",
+ aliasesKey: "settings:discoveryAliasesSubmitShortcut",
+ parentId: "general-keyboard-shortcuts",
+ groupId: "general",
+ href: "/settings/general/keyboard-shortcuts",
+ targetId: GENERAL_SETTINGS_TARGETS.submitShortcut,
+ order: 61,
+ },
+ {
+ id: "keyboard-command-shortcuts",
+ kind: "control",
+ labelKey: "settings:keyboardShortcuts",
+ aliasesKey: "settings:discoveryAliasesKeyboardShortcuts",
+ parentId: "general-keyboard-shortcuts",
+ groupId: "general",
+ href: "/settings/general/keyboard-shortcuts",
+ targetId: GENERAL_SETTINGS_TARGETS.keyboardShortcuts,
+ order: 62,
+ },
+ {
+ id: GENERAL_TASK_ACTIONS_ID,
+ kind: "page",
+ labelKey: "settings:taskActions",
+ parentId: "general",
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ order: 70,
+ },
+ {
+ id: "task-actions-agent-profile",
+ kind: "control",
+ labelKey: "settings:profileForTasksCreatedByAgents",
+ aliasesKey: "settings:discoveryAliasesAgentTaskProfile",
+ parentId: GENERAL_TASK_ACTIONS_ID,
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.agentTaskProfile,
+ order: 71,
+ },
+ {
+ id: "task-actions-generated-titles",
+ kind: "control",
+ labelKey: "settings:agentGeneratedTaskTitles",
+ parentId: GENERAL_TASK_ACTIONS_ID,
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.agentGeneratedTitles,
+ order: 72,
+ },
+ {
+ id: "task-actions-archive-confirmation",
+ kind: "control",
+ labelKey: "settings:archiveConfirmation",
+ aliasesKey: "settings:discoveryAliasesArchiveConfirmation",
+ parentId: GENERAL_TASK_ACTIONS_ID,
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.archiveConfirmation,
+ order: 73,
+ },
+ {
+ id: "task-actions-unread-messages",
+ kind: "control",
+ labelKey: "settings:unreadMessages",
+ parentId: GENERAL_TASK_ACTIONS_ID,
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.unreadMessages,
+ order: 74,
+ },
+ {
+ id: "task-actions-transcript-navigation",
+ kind: "control",
+ labelKey: "settings:transcriptNavigation",
+ aliasesKey: "settings:discoveryAliasesTranscriptNavigation",
+ parentId: GENERAL_TASK_ACTIONS_ID,
+ groupId: "general",
+ href: GENERAL_TASK_ACTIONS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.transcriptNavigation,
+ order: 75,
+ },
+ {
+ id: "general-message-queue",
+ kind: "page",
+ labelKey: "system:messageQueueTitle",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/message-queue",
+ order: 80,
+ },
+ {
+ id: "general-secrets",
+ kind: "page",
+ labelKey: "settings:secrets",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/secrets",
+ order: 90,
+ },
+ {
+ id: "general-sprites",
+ kind: "page",
+ labelKey: "settings:sprites",
+ parentId: "general",
+ groupId: "general",
+ href: "/settings/general/sprites",
+ order: 100,
+ },
+ {
+ id: "sprites-connection",
+ kind: "section",
+ labelKey: "settings:connection",
+ parentId: "general-sprites",
+ groupId: "general",
+ href: "/settings/general/sprites",
+ targetId: GENERAL_SETTINGS_TARGETS.spritesConnection,
+ order: 101,
+ },
+ {
+ id: "sprites-instances",
+ kind: "section",
+ labelKey: "settings:runningSprites",
+ parentId: "general-sprites",
+ groupId: "general",
+ href: "/settings/general/sprites",
+ targetId: GENERAL_SETTINGS_TARGETS.spritesInstances,
+ order: 102,
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/groups.ts b/apps/web/lib/settings-discovery/catalog/groups.ts
new file mode 100644
index 0000000000..a8d1d36609
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/groups.ts
@@ -0,0 +1,11 @@
+import type { SettingsDiscoveryGroupDefinition } from "../types";
+
+export const SETTINGS_DISCOVERY_GROUPS: SettingsDiscoveryGroupDefinition[] = [
+ { id: "general", labelKey: "settings:general", order: 0 },
+ { id: "workspaces", labelKey: "common:workspaces", order: 1 },
+ { id: "agents", labelKey: "common:agents", order: 2 },
+ { id: "executors", labelKey: "common:executors", order: 3 },
+ { id: "tools", labelKey: "common:settings", order: 4 },
+ { id: "system", labelKey: "common:system", order: 5 },
+ { id: "account", labelKey: "sidebar:account", order: 6 },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/index.ts b/apps/web/lib/settings-discovery/catalog/index.ts
new file mode 100644
index 0000000000..d2fb38ed2f
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/index.ts
@@ -0,0 +1,41 @@
+import { ACCOUNT_DISCOVERY_DEFINITIONS } from "./account";
+import { AGENT_DISCOVERY_DEFINITIONS } from "./agents";
+import { EXECUTOR_DISCOVERY_DEFINITIONS } from "./executors";
+import { GENERAL_DISCOVERY_DEFINITIONS } from "./general";
+import { INTEGRATION_DISCOVERY_DEFINITIONS } from "./integrations";
+import { STANDALONE_DISCOVERY_DEFINITIONS } from "./standalone";
+import { SYSTEM_DISCOVERY_DEFINITIONS } from "./system";
+import { WORKSPACE_DISCOVERY_DEFINITIONS } from "./workspaces";
+
+export const SETTINGS_DISCOVERY_DEFINITIONS = [
+ ...GENERAL_DISCOVERY_DEFINITIONS,
+ ...WORKSPACE_DISCOVERY_DEFINITIONS,
+ ...AGENT_DISCOVERY_DEFINITIONS,
+ ...EXECUTOR_DISCOVERY_DEFINITIONS,
+ ...STANDALONE_DISCOVERY_DEFINITIONS,
+ ...INTEGRATION_DISCOVERY_DEFINITIONS,
+ ...SYSTEM_DISCOVERY_DEFINITIONS,
+ ...ACCOUNT_DISCOVERY_DEFINITIONS,
+];
+
+export const SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS: Record = {
+ "/settings": "Owned by the top-level Go to Settings destination.",
+ "/settings/general/changes-panel": "Redirects to the canonical Appearance page.",
+ "/settings/general/chat-input": "Redirects to the canonical Keyboard Shortcuts page.",
+ "/settings/general/resource-metrics": "Redirects to the canonical Appearance page.",
+ "/settings/general/shell": "Redirects to the canonical Terminal page.",
+ "/settings/executor/new": "Transient executor creation flow, not a stable setting.",
+ "/settings/system": "Redirects to the canonical System Status page.",
+ "/settings/system/message-queue": "Redirects to the canonical General Message Queue page.",
+ "/settings/changelog": "Redirects to the canonical System Updates page.",
+};
+
+export * from "./account";
+export * from "./agents";
+export * from "./executors";
+export * from "./general";
+export * from "./groups";
+export * from "./integrations";
+export * from "./standalone";
+export * from "./system";
+export * from "./workspaces";
diff --git a/apps/web/lib/settings-discovery/catalog/integrations.ts b/apps/web/lib/settings-discovery/catalog/integrations.ts
new file mode 100644
index 0000000000..1daff3b25c
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/integrations.ts
@@ -0,0 +1,62 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+const INTEGRATIONS = [
+ ["azure-devops", "Azure DevOps"],
+ ["github", "GitHub"],
+ ["gitlab", "GitLab"],
+ ["jira", "Jira"],
+ ["linear", "Linear"],
+ ["sentry", "Sentry"],
+ ["slack", "Slack"],
+] as const;
+
+export const INTEGRATION_SETTINGS_TARGETS = {
+ "azure-devops": "setting-integration-azure-devops-connection",
+ github: "setting-integration-github-connection",
+ gitlab: "setting-integration-gitlab-connection",
+ jira: "setting-integration-jira-connection",
+ linear: "setting-integration-linear-connection",
+ sentry: "setting-integration-sentry-connection",
+ slack: "setting-integration-slack-connection",
+} as const;
+
+export const INTEGRATION_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "integrations",
+ kind: "page",
+ labelKey: "common:integrations",
+ parentId: "workspaces",
+ groupId: "workspaces",
+ href: "/settings/integrations",
+ order: 220,
+ },
+ ...INTEGRATIONS.flatMap(([slug, label], index): SettingsDiscoveryDefinition[] => {
+ const id = `integration-${slug}`;
+ const href = `/settings/integrations/${slug}`;
+ const order = 221 + index * 10;
+ return [
+ {
+ id,
+ kind: "page",
+ label,
+ parentId: "integrations",
+ groupId: "workspaces",
+ href,
+ order,
+ },
+ {
+ id: `${id}-connection`,
+ kind: "section",
+ labelKey: "settings:connection",
+ parentId: id,
+ groupId: "workspaces",
+ href,
+ targetId: INTEGRATION_SETTINGS_TARGETS[slug],
+ order: order + 1,
+ requires: "workspace",
+ },
+ ];
+ }),
+];
+
+export const WORKSPACE_INTEGRATIONS = INTEGRATIONS;
diff --git a/apps/web/lib/settings-discovery/catalog/standalone.ts b/apps/web/lib/settings-discovery/catalog/standalone.ts
new file mode 100644
index 0000000000..2cf7ff61d0
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/standalone.ts
@@ -0,0 +1,172 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const PROMPTS_SETTINGS_HREF = "/settings/prompts";
+export const VOICE_MODE_SETTINGS_HREF = "/settings/voice-mode";
+export const UTILITY_AGENTS_SETTINGS_HREF = "/settings/utility-agents";
+export const EXTERNAL_MCP_SETTINGS_HREF = "/settings/external-mcp";
+export const PLUGINS_SETTINGS_HREF = "/settings/plugins";
+const VOICE_MODE_DISCOVERY_ID = "voice-mode";
+const UTILITY_AGENTS_DISCOVERY_ID = "utility-agents";
+export const STANDALONE_SETTINGS_TARGETS = {
+ voiceEnable: "setting-voice-enable",
+ voiceEngine: "setting-voice-engine",
+ voiceBehavior: "setting-voice-behavior",
+ voiceModel: "setting-voice-model",
+ voiceShortcut: "setting-voice-shortcut",
+ utilityDefaultModel: "setting-utility-default-model",
+ utilityActions: "setting-utility-actions",
+ utilityCustomAgents: "setting-utility-custom-agents",
+ externalMcpEndpoints: "setting-external-mcp-endpoints",
+ externalMcpSnippets: "setting-external-mcp-snippets",
+} as const;
+
+export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "prompts",
+ kind: "page",
+ labelKey: "common:prompts",
+ aliasesKey: "common:commandPromptsSettingsKeywords",
+ groupId: "tools",
+ href: PROMPTS_SETTINGS_HREF,
+ order: 500,
+ },
+ {
+ id: VOICE_MODE_DISCOVERY_ID,
+ kind: "page",
+ labelKey: "settings:voiceMode",
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ order: 510,
+ },
+ {
+ id: "voice-enable",
+ kind: "control",
+ labelKey: "settings:voiceEnableTitle",
+ aliasesKey: "settings:discoveryAliasesVoiceEnable",
+ parentId: VOICE_MODE_DISCOVERY_ID,
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.voiceEnable,
+ order: 511,
+ },
+ {
+ id: "voice-engine",
+ kind: "control",
+ labelKey: "settings:voiceEngineTitle",
+ aliasesKey: "settings:discoveryAliasesVoiceEngine",
+ parentId: VOICE_MODE_DISCOVERY_ID,
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.voiceEngine,
+ order: 512,
+ },
+ {
+ id: "voice-behavior",
+ kind: "control",
+ labelKey: "settings:voiceBehavior",
+ aliasesKey: "settings:discoveryAliasesVoiceBehavior",
+ parentId: VOICE_MODE_DISCOVERY_ID,
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.voiceBehavior,
+ order: 513,
+ },
+ {
+ id: "voice-model",
+ kind: "control",
+ labelKey: "settings:voiceWhisperModelTitle",
+ parentId: VOICE_MODE_DISCOVERY_ID,
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.voiceModel,
+ order: 514,
+ },
+ {
+ id: "voice-shortcut",
+ kind: "control",
+ labelKey: "settings:discoveryVoiceShortcut",
+ aliasesKey: "settings:discoveryAliasesVoiceShortcut",
+ parentId: VOICE_MODE_DISCOVERY_ID,
+ groupId: "tools",
+ href: VOICE_MODE_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.voiceShortcut,
+ order: 515,
+ },
+ {
+ id: UTILITY_AGENTS_DISCOVERY_ID,
+ kind: "page",
+ labelKey: "settings:utilityAgents",
+ groupId: "tools",
+ href: UTILITY_AGENTS_SETTINGS_HREF,
+ order: 520,
+ },
+ {
+ id: "utility-default-model",
+ kind: "control",
+ labelKey: "settings:utilityDefaultModelTitle",
+ aliasesKey: "settings:discoveryAliasesUtilityDefaultModel",
+ parentId: UTILITY_AGENTS_DISCOVERY_ID,
+ groupId: "tools",
+ href: UTILITY_AGENTS_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.utilityDefaultModel,
+ order: 521,
+ },
+ {
+ id: "utility-actions",
+ kind: "section",
+ labelKey: "settings:utilityActionsTitle",
+ aliasesKey: "settings:discoveryAliasesUtilityActions",
+ parentId: UTILITY_AGENTS_DISCOVERY_ID,
+ groupId: "tools",
+ href: UTILITY_AGENTS_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.utilityActions,
+ order: 522,
+ },
+ {
+ id: "utility-custom-agents",
+ kind: "section",
+ labelKey: "settings:utilityCustomAgentsTitle",
+ parentId: UTILITY_AGENTS_DISCOVERY_ID,
+ groupId: "tools",
+ href: UTILITY_AGENTS_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.utilityCustomAgents,
+ order: 523,
+ },
+ {
+ id: "external-mcp",
+ kind: "page",
+ labelKey: "common:externalMcp",
+ groupId: "tools",
+ href: EXTERNAL_MCP_SETTINGS_HREF,
+ order: 530,
+ },
+ {
+ id: "external-mcp-endpoints",
+ kind: "section",
+ labelKey: "settings:externalMcpEndpoints",
+ aliasesKey: "settings:discoveryAliasesExternalMcpEndpoints",
+ parentId: "external-mcp",
+ groupId: "tools",
+ href: EXTERNAL_MCP_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.externalMcpEndpoints,
+ order: 531,
+ },
+ {
+ id: "external-mcp-snippets",
+ kind: "section",
+ labelKey: "settings:externalMcpSnippets",
+ parentId: "external-mcp",
+ groupId: "tools",
+ href: EXTERNAL_MCP_SETTINGS_HREF,
+ targetId: STANDALONE_SETTINGS_TARGETS.externalMcpSnippets,
+ order: 532,
+ },
+ {
+ id: "plugins",
+ kind: "page",
+ labelKey: "common:plugins",
+ groupId: "tools",
+ href: PLUGINS_SETTINGS_HREF,
+ order: 540,
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts
new file mode 100644
index 0000000000..816282899c
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/system.ts
@@ -0,0 +1,188 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const SYSTEM_SETTINGS_HREF = "/settings/system";
+export const SYSTEM_STATUS_SETTINGS_HREF = `${SYSTEM_SETTINGS_HREF}/status`;
+const SYSTEM_STORAGE_DISCOVERY_ID = "system-storage";
+const SYSTEM_STORAGE_SETTINGS_HREF = `${SYSTEM_SETTINGS_HREF}/storage`;
+export const SYSTEM_SETTINGS_TARGETS = {
+ storageActions: "setting-system-storage-actions",
+ storageSchedule: "setting-system-storage-schedule",
+ storageWorkspaces: "setting-system-storage-workspaces",
+ storageGoCache: "setting-system-storage-go-cache",
+ storageDocker: "setting-system-storage-docker",
+ storageQuarantine: "setting-system-storage-quarantine",
+} as const;
+
+const STORAGE_SECTION_TARGETS: Record = {
+ schedule: SYSTEM_SETTINGS_TARGETS.storageSchedule,
+ workspaces: SYSTEM_SETTINGS_TARGETS.storageWorkspaces,
+ "go-cache": SYSTEM_SETTINGS_TARGETS.storageGoCache,
+ docker: SYSTEM_SETTINGS_TARGETS.storageDocker,
+ quarantine: SYSTEM_SETTINGS_TARGETS.storageQuarantine,
+};
+
+export function storageSettingsTarget(sectionId: string): string | undefined {
+ return STORAGE_SECTION_TARGETS[sectionId];
+}
+
+export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "system",
+ kind: "page",
+ labelKey: "common:system",
+ groupId: "system",
+ href: SYSTEM_STATUS_SETTINGS_HREF,
+ order: 600,
+ },
+ {
+ id: "system-status",
+ kind: "page",
+ labelKey: "common:status",
+ parentId: "system",
+ groupId: "system",
+ href: SYSTEM_STATUS_SETTINGS_HREF,
+ order: 610,
+ },
+ {
+ id: "system-feature-toggles",
+ kind: "page",
+ labelKey: "system:navFeatureToggles",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/feature-toggles",
+ order: 620,
+ },
+ {
+ id: "system-database",
+ kind: "page",
+ labelKey: "system:navDatabase",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/database",
+ order: 630,
+ },
+ {
+ id: "system-backups",
+ kind: "page",
+ labelKey: "system:navBackups",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/backups",
+ order: 640,
+ },
+ {
+ id: SYSTEM_STORAGE_DISCOVERY_ID,
+ kind: "page",
+ labelKey: "system:navStorage",
+ parentId: "system",
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ order: 650,
+ },
+ {
+ id: "system-storage-actions",
+ kind: "section",
+ labelKey: "system:storageActionsTitle",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageActions,
+ order: 651,
+ },
+ {
+ id: "system-storage-schedule",
+ kind: "section",
+ labelKey: "system:storageScheduleTitle",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageSchedule,
+ order: 652,
+ },
+ {
+ id: "system-storage-workspaces",
+ kind: "section",
+ labelKey: "system:storageWorkspacesTitle",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageWorkspaces,
+ order: 653,
+ },
+ {
+ id: "system-storage-go-cache",
+ kind: "section",
+ labelKey: "system:storageGoBuildCache",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageGoCache,
+ order: 654,
+ },
+ {
+ id: "system-storage-docker",
+ kind: "section",
+ labelKey: "system:storageDockerTitle",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageDocker,
+ order: 655,
+ },
+ {
+ id: "system-storage-quarantine",
+ kind: "section",
+ labelKey: "system:storageQuarantineSafetyTitle",
+ parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ groupId: "system",
+ href: SYSTEM_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.storageQuarantine,
+ order: 656,
+ },
+ {
+ id: "system-logs",
+ kind: "page",
+ labelKey: "system:navLogs",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/logs",
+ order: 660,
+ },
+ {
+ id: "system-updates",
+ kind: "page",
+ labelKey: "system:navUpdates",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/updates",
+ order: 670,
+ },
+ {
+ id: "system-about",
+ kind: "page",
+ labelKey: "system:navAbout",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/about",
+ order: 680,
+ },
+ {
+ id: "system-licenses",
+ kind: "page",
+ labelKey: "system:navLicenses",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/licenses",
+ order: 690,
+ },
+ {
+ id: "system-users",
+ kind: "page",
+ labelKey: "system:navUsers",
+ parentId: "system",
+ groupId: "system",
+ href: "/settings/system/users",
+ order: 700,
+ requires: "users",
+ },
+];
diff --git a/apps/web/lib/settings-discovery/catalog/workspaces.ts b/apps/web/lib/settings-discovery/catalog/workspaces.ts
new file mode 100644
index 0000000000..7a7f20f124
--- /dev/null
+++ b/apps/web/lib/settings-discovery/catalog/workspaces.ts
@@ -0,0 +1,24 @@
+import type { SettingsDiscoveryDefinition } from "../types";
+
+export const WORKSPACES_SETTINGS_HREF = "/settings/workspace";
+
+export const WORKSPACE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+ {
+ id: "workspaces",
+ kind: "page",
+ labelKey: "common:workspaces",
+ aliasesKey: "common:commandWorkspaceSettingsKeywords",
+ groupId: "workspaces",
+ href: WORKSPACES_SETTINGS_HREF,
+ order: 200,
+ },
+ {
+ id: "automations",
+ kind: "page",
+ labelKey: "common:automations",
+ parentId: "workspaces",
+ groupId: "workspaces",
+ href: "/settings/automations",
+ order: 210,
+ },
+];
diff --git a/apps/web/lib/settings-discovery/dynamic-targets.ts b/apps/web/lib/settings-discovery/dynamic-targets.ts
new file mode 100644
index 0000000000..34cbd4d151
--- /dev/null
+++ b/apps/web/lib/settings-discovery/dynamic-targets.ts
@@ -0,0 +1,34 @@
+export type WorkspaceDiscoveryTarget = "name" | "default-executor" | "default-agent-profile";
+
+export type AgentProfileDiscoveryTarget =
+ | "profile-settings"
+ | "cli-flags"
+ | "environment-variables"
+ | "command-preview";
+
+export type ExecutorProfileDiscoveryTarget =
+ | "profile-details"
+ | "environment-variables"
+ | "prepare-script"
+ | "mcp-policy";
+
+export function workspaceDiscoveryTarget(
+ workspaceId: string,
+ target: WorkspaceDiscoveryTarget,
+): string {
+ return `setting-workspace-${workspaceId}-${target}`;
+}
+
+export function agentProfileDiscoveryTarget(
+ profileId: string,
+ target: AgentProfileDiscoveryTarget,
+): string {
+ return `setting-agent-profile-${profileId}-${target}`;
+}
+
+export function executorProfileDiscoveryTarget(
+ profileId: string,
+ target: ExecutorProfileDiscoveryTarget,
+): string {
+ return `setting-executor-profile-${profileId}-${target}`;
+}
diff --git a/apps/web/lib/settings-discovery/navigation.test.ts b/apps/web/lib/settings-discovery/navigation.test.ts
new file mode 100644
index 0000000000..bd9db89655
--- /dev/null
+++ b/apps/web/lib/settings-discovery/navigation.test.ts
@@ -0,0 +1,62 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ clearNavigationBlockerForTests,
+ setNavigationBlocker,
+} from "@/lib/routing/navigation-guard";
+import { SETTINGS_TARGET_REQUEST_EVENT, type SettingsTargetRequestDetail } from "./target";
+import { navigateToSettingsDiscovery } from "./navigation";
+
+const FONT_SIZE_TARGET_ID = "font-size";
+const TERMINAL_FONT_SIZE_HREF = `/settings/general/terminal#${FONT_SIZE_TARGET_ID}`;
+
+afterEach(() => {
+ clearNavigationBlockerForTests();
+ window.history.replaceState({}, "", "/");
+ vi.restoreAllMocks();
+});
+
+describe("navigateToSettingsDiscovery", () => {
+ it("uses guarded app routing when the owning page changes", () => {
+ window.history.replaceState({}, "", "/settings/general/appearance");
+ const push = vi.fn();
+
+ navigateToSettingsDiscovery(TERMINAL_FONT_SIZE_HREF, push);
+
+ expect(push).toHaveBeenCalledWith(TERMINAL_FONT_SIZE_HREF);
+ });
+
+ it("updates and requests a same-page fragment without invoking the leave blocker", () => {
+ window.history.replaceState({}, "", "/settings/general/terminal");
+ const push = vi.fn();
+ const blocker = vi.fn();
+ setNavigationBlocker(blocker);
+ const requests: string[] = [];
+ const listener = (event: Event) => {
+ requests.push((event as CustomEvent).detail.targetId);
+ };
+ window.addEventListener(SETTINGS_TARGET_REQUEST_EVENT, listener);
+
+ navigateToSettingsDiscovery(TERMINAL_FONT_SIZE_HREF, push);
+
+ expect(push).not.toHaveBeenCalled();
+ expect(blocker).not.toHaveBeenCalled();
+ expect(window.location.hash).toBe(`#${FONT_SIZE_TARGET_ID}`);
+ expect(requests).toEqual([FONT_SIZE_TARGET_ID]);
+ window.removeEventListener(SETTINGS_TARGET_REQUEST_EVENT, listener);
+ });
+
+ it("emits another request when the current fragment is selected again", () => {
+ window.history.replaceState({}, "", TERMINAL_FONT_SIZE_HREF);
+ const requests: string[] = [];
+ const listener = (event: Event) => {
+ requests.push((event as CustomEvent).detail.targetId);
+ };
+ window.addEventListener(SETTINGS_TARGET_REQUEST_EVENT, listener);
+
+ navigateToSettingsDiscovery(TERMINAL_FONT_SIZE_HREF, vi.fn());
+ navigateToSettingsDiscovery(TERMINAL_FONT_SIZE_HREF, vi.fn());
+
+ expect(requests).toEqual([FONT_SIZE_TARGET_ID, FONT_SIZE_TARGET_ID]);
+ window.removeEventListener(SETTINGS_TARGET_REQUEST_EVENT, listener);
+ });
+});
diff --git a/apps/web/lib/settings-discovery/navigation.ts b/apps/web/lib/settings-discovery/navigation.ts
new file mode 100644
index 0000000000..be51725bd4
--- /dev/null
+++ b/apps/web/lib/settings-discovery/navigation.ts
@@ -0,0 +1,25 @@
+import { emitSettingsTargetRequest, settingsTargetFromHash } from "./target";
+
+export type SettingsDiscoveryPush = (href: string) => void;
+
+export function navigateToSettingsDiscovery(href: string, push: SettingsDiscoveryPush): void {
+ if (typeof window === "undefined") {
+ push(href);
+ return;
+ }
+
+ const destination = new URL(href, window.location.href);
+ const samePage =
+ destination.origin === window.location.origin &&
+ destination.pathname === window.location.pathname &&
+ destination.search === window.location.search;
+ if (!samePage) {
+ push(href);
+ return;
+ }
+
+ const relativeHref = `${destination.pathname}${destination.search}${destination.hash}`;
+ window.history.pushState(window.history.state, "", relativeHref);
+ const targetId = settingsTargetFromHash(destination.hash);
+ if (targetId) emitSettingsTargetRequest(targetId);
+}
diff --git a/apps/web/lib/settings-discovery/resolve.ts b/apps/web/lib/settings-discovery/resolve.ts
new file mode 100644
index 0000000000..85fa1cf19b
--- /dev/null
+++ b/apps/web/lib/settings-discovery/resolve.ts
@@ -0,0 +1,219 @@
+import type {
+ ResolvedSettingsDiscoveryItem,
+ SettingsDiscoveryContext,
+ SettingsDiscoveryDefinition,
+} from "./types";
+import { SETTINGS_DISCOVERY_DEFINITIONS } from "./catalog";
+import { SETTINGS_DISCOVERY_GROUPS } from "./catalog/groups";
+import {
+ agentProfileDiscoveryTarget,
+ executorProfileDiscoveryTarget,
+ workspaceDiscoveryTarget,
+} from "./dynamic-targets";
+
+function workspaceDefinitions(
+ workspaces: SettingsDiscoveryContext["workspaces"],
+): SettingsDiscoveryDefinition[] {
+ const entries: SettingsDiscoveryDefinition[] = [];
+ for (const [workspaceIndex, workspace] of workspaces.entries()) {
+ const workspaceId = `workspace:${workspace.id}`;
+ const href = `/settings/workspace/${encodeURIComponent(workspace.id)}`;
+ const order = 10_000 + workspaceIndex * 100;
+ entries.push({
+ id: workspaceId,
+ kind: "page",
+ label: workspace.name,
+ parentId: "workspaces",
+ groupId: "workspaces",
+ href,
+ order,
+ });
+ for (const [index, [suffix, labelKey]] of [
+ ["name", "workspaces:name"],
+ ["default-executor", "workspaces:defaultExecutor"],
+ ["default-agent-profile", "workspaces:defaultAgentProfile"],
+ ].entries()) {
+ entries.push({
+ id: `${workspaceId}:${suffix}`,
+ kind: "control",
+ labelKey,
+ parentId: workspaceId,
+ groupId: "workspaces",
+ href,
+ targetId: workspaceDiscoveryTarget(
+ workspace.id,
+ suffix as "name" | "default-executor" | "default-agent-profile",
+ ),
+ order: order + index + 1,
+ });
+ }
+ for (const [index, [suffix, labelKey]] of [
+ ["repositories", "sidebar:repositories"],
+ ["workflows", "workflows:workflows"],
+ ["automations", "common:automations"],
+ ["integrations", "common:integrations"],
+ ].entries()) {
+ entries.push({
+ id: `${workspaceId}:${suffix}`,
+ kind: "page",
+ labelKey,
+ parentId: workspaceId,
+ groupId: "workspaces",
+ href: `${href}/${suffix}`,
+ order: order + index + 10,
+ });
+ }
+ }
+ return entries;
+}
+
+function agentProfileDefinitions(
+ agents: SettingsDiscoveryContext["agents"],
+): SettingsDiscoveryDefinition[] {
+ const entries: SettingsDiscoveryDefinition[] = [];
+ for (const [agentIndex, agent] of agents.entries()) {
+ for (const [profileIndex, profile] of agent.profiles.entries()) {
+ const profileId = `agent-profile:${profile.id}`;
+ const href = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${encodeURIComponent(profile.id)}`;
+ const order = 20_000 + agentIndex * 100 + profileIndex * 10;
+ entries.push({
+ id: profileId,
+ kind: "page",
+ label: `${profile.agentDisplayName || agent.name} • ${profile.name}`,
+ parentId: "agents",
+ groupId: "agents",
+ href,
+ order,
+ });
+ for (const [index, [suffix, labelKey]] of [
+ ["profile-settings", "agents:profileSettings"],
+ ["cli-flags", "agents:agentCliFlags"],
+ ["environment-variables", "executors:environmentVariables"],
+ ["command-preview", "agents:commandPreview"],
+ ].entries()) {
+ entries.push({
+ id: `${profileId}:${suffix}`,
+ kind: "section",
+ labelKey,
+ parentId: profileId,
+ groupId: "agents",
+ href,
+ targetId: agentProfileDiscoveryTarget(
+ profile.id,
+ suffix as
+ | "profile-settings"
+ | "cli-flags"
+ | "environment-variables"
+ | "command-preview",
+ ),
+ order: order + index + 1,
+ });
+ }
+ }
+ }
+ return entries;
+}
+
+function executorProfileDefinitions(
+ executors: SettingsDiscoveryContext["executors"],
+): SettingsDiscoveryDefinition[] {
+ const entries: SettingsDiscoveryDefinition[] = [];
+ 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 order = 30_000 + executorIndex * 100 + profileIndex * 10;
+ entries.push({
+ id: profileId,
+ kind: "page",
+ label: profile.name,
+ parentId: "executors",
+ groupId: "executors",
+ href,
+ order,
+ });
+ for (const [index, [suffix, labelKey]] of [
+ ["profile-details", "executors:profileDetails"],
+ ["environment-variables", "executors:environmentVariables"],
+ ["prepare-script", "executors:prepareScript"],
+ ["mcp-policy", "executors:mcpPolicy"],
+ ].entries()) {
+ entries.push({
+ id: `${profileId}:${suffix}`,
+ kind: "section",
+ labelKey,
+ parentId: profileId,
+ groupId: "executors",
+ href,
+ targetId: executorProfileDiscoveryTarget(
+ profile.id,
+ suffix as "profile-details" | "environment-variables" | "prepare-script" | "mcp-policy",
+ ),
+ order: order + index + 1,
+ });
+ }
+ }
+ }
+ return entries;
+}
+
+function dynamicDefinitions(context: SettingsDiscoveryContext): SettingsDiscoveryDefinition[] {
+ return [
+ ...workspaceDefinitions(context.workspaces),
+ ...agentProfileDefinitions(context.agents),
+ ...executorProfileDefinitions(context.executors),
+ ];
+}
+
+function isVisible(entry: SettingsDiscoveryDefinition, context: SettingsDiscoveryContext) {
+ if (entry.requires === "account") return context.showAccount;
+ if (entry.requires === "users") return context.showUsers;
+ if (entry.requires === "workspace") return context.workspaces.length > 0;
+ return true;
+}
+
+function aliasesFor(entry: SettingsDiscoveryDefinition, context: SettingsDiscoveryContext) {
+ if (!entry.aliasesKey) return [];
+ return context
+ .t(entry.aliasesKey)
+ .split(",")
+ .map((alias) => alias.trim())
+ .filter(Boolean);
+}
+
+export function resolveSettingsDiscovery(
+ context: SettingsDiscoveryContext,
+): ResolvedSettingsDiscoveryItem[] {
+ const definitions = [...SETTINGS_DISCOVERY_DEFINITIONS, ...dynamicDefinitions(context)].filter(
+ (entry) => isVisible(entry, context),
+ );
+ const byId = new Map(definitions.map((entry) => [entry.id, entry]));
+ const groups = new Map(SETTINGS_DISCOVERY_GROUPS.map((group) => [group.id, group]));
+ const labelFor = (entry: SettingsDiscoveryDefinition) =>
+ entry.label ?? context.t(entry.labelKey ?? entry.id);
+
+ return definitions.map((entry) => {
+ const breadcrumb: string[] = [];
+ let parent = entry.parentId ? byId.get(entry.parentId) : undefined;
+ while (parent) {
+ breadcrumb.unshift(labelFor(parent));
+ parent = parent.parentId ? byId.get(parent.parentId) : undefined;
+ }
+ const group = groups.get(entry.groupId);
+ const href = entry.targetId
+ ? `${entry.href}#${encodeURIComponent(entry.targetId)}`
+ : entry.href;
+ return {
+ id: entry.id,
+ kind: entry.kind,
+ label: labelFor(entry),
+ aliases: aliasesFor(entry, context),
+ breadcrumb,
+ groupId: entry.groupId,
+ groupLabel: context.t(group?.labelKey ?? entry.groupId),
+ href,
+ targetId: entry.targetId,
+ order: (group?.order ?? 99) * 100_000 + entry.order,
+ };
+ });
+}
diff --git a/apps/web/lib/settings-discovery/search.test.ts b/apps/web/lib/settings-discovery/search.test.ts
new file mode 100644
index 0000000000..4be90b9901
--- /dev/null
+++ b/apps/web/lib/settings-discovery/search.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import type { ResolvedSettingsDiscoveryItem } from "./types";
+import { searchSettingsDiscovery } from "./search";
+
+function item(
+ id: string,
+ label: string,
+ options: Partial = {},
+): ResolvedSettingsDiscoveryItem {
+ return {
+ id,
+ kind: "control",
+ label,
+ aliases: [],
+ breadcrumb: ["General", "Terminal"],
+ groupId: "general",
+ groupLabel: "General",
+ href: `/settings/general/terminal#setting-${id}`,
+ targetId: `setting-${id}`,
+ order: 0,
+ ...options,
+ };
+}
+
+describe("searchSettingsDiscovery", () => {
+ const entries = [
+ item("general", "General", {
+ kind: "page",
+ breadcrumb: [],
+ href: "/settings/general",
+ targetId: undefined,
+ order: 0,
+ }),
+ item("terminal-font-size", "Terminal font size", {
+ aliases: ["text size"],
+ order: 1,
+ }),
+ item("terminal-font-family", "Terminal font family", {
+ aliases: ["typeface"],
+ order: 2,
+ }),
+ item("appearance-language", "Language", {
+ aliases: ["locale"],
+ breadcrumb: ["General", "Appearance"],
+ order: 3,
+ }),
+ ];
+
+ it("requires a direct label or alias match instead of flooding ancestor results", () => {
+ expect(searchSettingsDiscovery(entries, "General").map((entry) => entry.id)).toEqual([
+ "general",
+ ]);
+ expect(searchSettingsDiscovery(entries, "General font").map((entry) => entry.id)).toEqual([
+ "terminal-font-size",
+ "terminal-font-family",
+ ]);
+ });
+
+ it("ranks exact labels above exact aliases and prefixes", () => {
+ const aliasMatch = item("alias", "Text scale", {
+ aliases: ["font size"],
+ order: 0,
+ });
+ const prefixMatch = item("prefix", "Font size behavior", { order: 1 });
+ const exactMatch = item("exact", "Font size", { order: 2 });
+
+ expect(
+ searchSettingsDiscovery([aliasMatch, prefixMatch, exactMatch], "font size").map(
+ (entry) => entry.id,
+ ),
+ ).toEqual(["exact", "alias", "prefix"]);
+ });
+
+ it("normalizes case, whitespace, and diacritics with Unicode terms", () => {
+ const localized = item("localized", "Tamaño de fuente", {
+ aliases: ["tipografía"],
+ });
+
+ expect(
+ searchSettingsDiscovery([localized], " TAMANO fuente ").map((entry) => entry.id),
+ ).toEqual(["localized"]);
+ expect(searchSettingsDiscovery([localized], "tipografia").map((entry) => entry.id)).toEqual([
+ "localized",
+ ]);
+ });
+});
diff --git a/apps/web/lib/settings-discovery/search.ts b/apps/web/lib/settings-discovery/search.ts
new file mode 100644
index 0000000000..a6e3b7e4da
--- /dev/null
+++ b/apps/web/lib/settings-discovery/search.ts
@@ -0,0 +1,58 @@
+import type { ResolvedSettingsDiscoveryItem } from "./types";
+
+type ScoredItem = {
+ item: ResolvedSettingsDiscoveryItem;
+ score: number;
+};
+
+function normalize(value: string): string {
+ return value
+ .normalize("NFKD")
+ .replace(/\p{M}+/gu, "")
+ .toLocaleLowerCase()
+ .trim()
+ .replace(/\s+/g, " ");
+}
+
+function tokenize(value: string): string[] {
+ return normalize(value).match(/[\p{L}\p{N}]+/gu) ?? [];
+}
+
+function containsToken(values: string[], token: string): boolean {
+ return values.some((value) => value.includes(token));
+}
+
+function scoreItem(item: ResolvedSettingsDiscoveryItem, query: string): number | null {
+ const normalizedLabel = normalize(item.label);
+ const normalizedAliases = item.aliases.map(normalize);
+ const directValues = [normalizedLabel, ...normalizedAliases];
+ const contextValues = item.breadcrumb.map(normalize);
+ const tokens = tokenize(query);
+ if (tokens.length === 0) return null;
+ if (!tokens.every((token) => containsToken([...directValues, ...contextValues], token))) {
+ return null;
+ }
+ if (!tokens.some((token) => containsToken(directValues, token))) return null;
+
+ const normalizedQuery = normalize(query);
+ if (normalizedLabel === normalizedQuery) return 0;
+ if (normalizedAliases.includes(normalizedQuery)) return 10;
+ if (normalizedLabel.startsWith(normalizedQuery)) return 20;
+ if (normalizedLabel.includes(normalizedQuery)) return 25;
+ if (normalizedAliases.some((alias) => alias.startsWith(normalizedQuery))) return 30;
+ return 40;
+}
+
+export function searchSettingsDiscovery(
+ items: ResolvedSettingsDiscoveryItem[],
+ query: string,
+): ResolvedSettingsDiscoveryItem[] {
+ return items
+ .map((item): ScoredItem | null => {
+ const score = scoreItem(item, query);
+ return score === null ? null : { item, score };
+ })
+ .filter((entry): entry is ScoredItem => entry !== null)
+ .sort((a, b) => a.score - b.score || a.item.order - b.item.order)
+ .map(({ item }) => item);
+}
diff --git a/apps/web/lib/settings-discovery/target.test.ts b/apps/web/lib/settings-discovery/target.test.ts
new file mode 100644
index 0000000000..6d3ea8bc21
--- /dev/null
+++ b/apps/web/lib/settings-discovery/target.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ SETTINGS_TARGET_ATTRIBUTE,
+ SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE,
+ createSettingsTargetRegistry,
+ revealSettingsTarget,
+ settingsTargetFromHash,
+ settingsTargetSelector,
+} from "./target";
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("settings target identity", () => {
+ it("decodes valid fragments and ignores empty or malformed fragments", () => {
+ expect(settingsTargetFromHash("#setting-terminal-font-size")).toBe(
+ "setting-terminal-font-size",
+ );
+ expect(settingsTargetFromHash("#setting%3Aterminal")).toBe("setting:terminal");
+ expect(settingsTargetFromHash("")).toBeNull();
+ expect(settingsTargetFromHash("#")).toBeNull();
+ expect(settingsTargetFromHash("#%E0%A4%A")).toBeNull();
+ });
+
+ it("escapes target ids used in DOM selectors", () => {
+ expect(settingsTargetSelector("setting:terminal")).toBe(
+ `[${SETTINGS_TARGET_ATTRIBUTE}="setting\\:terminal"]`,
+ );
+ });
+});
+
+describe("settings target registry", () => {
+ it("reveals a target already registered", () => {
+ const reveal = vi.fn();
+ const registry = createSettingsTargetRegistry(reveal);
+ const target = document.createElement("div");
+
+ registry.register("font-size", target);
+
+ expect(registry.request("font-size")).toBe(true);
+ expect(reveal).toHaveBeenCalledWith(target);
+ });
+
+ it("keeps a missing request pending until asynchronous content registers", () => {
+ const reveal = vi.fn();
+ const registry = createSettingsTargetRegistry(reveal);
+ const target = document.createElement("div");
+
+ expect(registry.request("late-control")).toBe(false);
+ registry.register("late-control", target);
+
+ expect(reveal).toHaveBeenCalledWith(target);
+ });
+
+ it("re-reveals a target when the same fragment is requested twice", () => {
+ const reveal = vi.fn();
+ const registry = createSettingsTargetRegistry(reveal);
+ const target = document.createElement("div");
+ registry.register("font-size", target);
+
+ registry.request("font-size");
+ registry.request("font-size");
+
+ expect(reveal).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("revealSettingsTarget", () => {
+ it("centers, focuses the first control, then removes its one-shot highlight", () => {
+ vi.useFakeTimers();
+ const target = document.createElement("div");
+ const input = document.createElement("input");
+ target.appendChild(input);
+ target.scrollIntoView = vi.fn();
+ document.body.appendChild(target);
+
+ revealSettingsTarget(target, { highlightDurationMs: 900, reducedMotion: false });
+
+ expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
+ expect(document.activeElement).toBe(input);
+ expect(target.getAttribute(SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE)).toBe("true");
+
+ vi.advanceTimersByTime(900);
+ expect(target.hasAttribute(SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE)).toBe(false);
+ });
+
+ it("honors an explicit focus marker and reduced motion", () => {
+ const target = document.createElement("div");
+ const first = document.createElement("button");
+ const marked = document.createElement("button");
+ marked.setAttribute("data-settings-target-focus", "");
+ target.append(first, marked);
+ target.scrollIntoView = vi.fn();
+ document.body.appendChild(target);
+
+ revealSettingsTarget(target, { reducedMotion: true });
+
+ expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" });
+ expect(document.activeElement).toBe(marked);
+ });
+});
diff --git a/apps/web/lib/settings-discovery/target.ts b/apps/web/lib/settings-discovery/target.ts
new file mode 100644
index 0000000000..efb300aa40
--- /dev/null
+++ b/apps/web/lib/settings-discovery/target.ts
@@ -0,0 +1,118 @@
+export const SETTINGS_TARGET_ATTRIBUTE = "data-settings-target";
+export const SETTINGS_TARGET_FOCUS_ATTRIBUTE = "data-settings-target-focus";
+export const SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE = "data-settings-target-highlight";
+export const SETTINGS_TARGET_REQUEST_EVENT = "kandev:settings-target";
+
+export type SettingsTargetRequestDetail = { targetId: string };
+
+export type SettingsTargetRegistry = {
+ register: (targetId: string, element: HTMLElement) => () => void;
+ request: (targetId: string) => boolean;
+};
+
+type RevealOptions = {
+ highlightDurationMs?: number;
+ reducedMotion?: boolean;
+};
+
+const DEFAULT_HIGHLIGHT_DURATION_MS = 1400;
+const highlightTimers = new WeakMap();
+
+export function settingsTargetFromHash(hash: string): string | null {
+ if (!hash.startsWith("#") || hash.length === 1) return null;
+ try {
+ return decodeURIComponent(hash.slice(1)) || null;
+ } catch {
+ return null;
+ }
+}
+
+export function settingsTargetSelector(targetId: string): string {
+ return `[${SETTINGS_TARGET_ATTRIBUTE}="${CSS.escape(targetId)}"]`;
+}
+
+export function emitSettingsTargetRequest(targetId: string): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(
+ new CustomEvent(SETTINGS_TARGET_REQUEST_EVENT, {
+ detail: { targetId },
+ }),
+ );
+}
+
+export function createSettingsTargetRegistry(
+ reveal: (element: HTMLElement) => void = revealSettingsTarget,
+): SettingsTargetRegistry {
+ const targets = new Map();
+ let pendingTargetId: string | null = null;
+
+ return {
+ register(targetId, element) {
+ targets.set(targetId, element);
+ element.setAttribute(SETTINGS_TARGET_ATTRIBUTE, targetId);
+ if (pendingTargetId === targetId) {
+ pendingTargetId = null;
+ reveal(element);
+ }
+ return () => {
+ if (targets.get(targetId) === element) targets.delete(targetId);
+ if (element.getAttribute(SETTINGS_TARGET_ATTRIBUTE) === targetId) {
+ element.removeAttribute(SETTINGS_TARGET_ATTRIBUTE);
+ }
+ };
+ },
+ request(targetId) {
+ const element = targets.get(targetId);
+ if (!element) {
+ pendingTargetId = targetId;
+ return false;
+ }
+ pendingTargetId = null;
+ reveal(element);
+ return true;
+ },
+ };
+}
+
+export function revealSettingsTarget(element: HTMLElement, options: RevealOptions = {}): void {
+ const reducedMotion = options.reducedMotion ?? prefersReducedMotion();
+ element.scrollIntoView?.({
+ behavior: reducedMotion ? "auto" : "smooth",
+ block: "center",
+ });
+
+ focusTargetWithin(element);
+ restartTargetHighlight(element, options.highlightDurationMs ?? DEFAULT_HIGHLIGHT_DURATION_MS);
+}
+
+function focusTargetWithin(element: HTMLElement): void {
+ const focusTarget =
+ element.querySelector(`[${SETTINGS_TARGET_FOCUS_ATTRIBUTE}]`) ??
+ element.querySelector(
+ `input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])`,
+ );
+ const target = focusTarget ?? element;
+ if (!focusTarget && !element.hasAttribute("tabindex")) element.tabIndex = -1;
+ target.focus({ preventScroll: true });
+}
+
+function restartTargetHighlight(element: HTMLElement, durationMs: number): void {
+ const previousTimer = highlightTimers.get(element);
+ if (previousTimer !== undefined) window.clearTimeout(previousTimer);
+ element.removeAttribute(SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE);
+ void element.offsetWidth;
+ element.setAttribute(SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE, "true");
+ const timer = window.setTimeout(() => {
+ element.removeAttribute(SETTINGS_TARGET_HIGHLIGHT_ATTRIBUTE);
+ highlightTimers.delete(element);
+ }, durationMs);
+ highlightTimers.set(element, timer);
+}
+
+function prefersReducedMotion(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
diff --git a/apps/web/lib/settings-discovery/types.ts b/apps/web/lib/settings-discovery/types.ts
new file mode 100644
index 0000000000..e808e7409c
--- /dev/null
+++ b/apps/web/lib/settings-discovery/types.ts
@@ -0,0 +1,49 @@
+export type SettingsDiscoveryKind = "page" | "section" | "control";
+
+export type SettingsDiscoveryDefinition = {
+ id: string;
+ kind: SettingsDiscoveryKind;
+ labelKey?: string;
+ label?: string;
+ aliasesKey?: string;
+ parentId?: string;
+ groupId: string;
+ href: string;
+ targetId?: string;
+ order: number;
+ requires?: "account" | "users" | "workspace";
+};
+
+export type SettingsDiscoveryGroupDefinition = {
+ id: string;
+ labelKey: string;
+ order: number;
+};
+
+export type ResolvedSettingsDiscoveryItem = {
+ id: string;
+ kind: SettingsDiscoveryKind;
+ label: string;
+ aliases: string[];
+ breadcrumb: string[];
+ groupId: string;
+ groupLabel: string;
+ href: string;
+ targetId?: string;
+ order: number;
+};
+
+export type SettingsDiscoveryContext = {
+ t: (key: string) => string;
+ showAccount: boolean;
+ showUsers: boolean;
+ workspaces: Array<{ id: string; name: string }>;
+ agents: Array<{
+ name: string;
+ profiles: Array<{ id: string; name: string; agentDisplayName?: string }>;
+ }>;
+ executors: Array<{
+ type: string;
+ profiles?: Array<{ id: string; name: string }>;
+ }>;
+};
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index efea002494..2e4a1ca908 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -5,11 +5,9 @@
"backToTaskOverview": "Back to task overview",
"cancel": "Cancel",
"changelog": "Changelog",
- "commandAgentsSettings": "Agents Settings",
"commandAgentsSettingsKeywords": "agents, agent settings, agent profiles, installed agents, claude",
"breadcrumb": "breadcrumb",
"commandConfigChatKeywords": "config chat, config mode, configure kandev, workflow configuration, mcp configuration",
- "commandExecutorsSettings": "Executors Settings",
"commandExecutorsSettingsKeywords": "executors, executor profiles, execution environment, environment variables, runtime, compute",
"commandGoToAllTasks": "Go to All Tasks",
"commandGoToAllTasksKeywords": "tasks, list, all",
@@ -22,17 +20,16 @@
"commandGoToStats": "Go to Stats",
"commandGoToStatsKeywords": "stats, statistics, analytics, metrics",
"commandGroupActions": "Actions",
+ "commandGroupCommands": "Commands",
"commandGroupNavigation": "Navigation",
"commandGroupPreferences": "Preferences",
"commandGroupSettings": "Settings",
- "commandPromptsSettings": "Prompts Settings",
"commandPromptsSettingsKeywords": "prompts, prompt settings, custom prompts, prompt snippets, prompt templates",
"commandQuickChat": "Quick Chat",
"commandQuickChatKeywords": "quick chat, new quick chat, quick question, ask agent",
"commandSwitchToDarkMode": "Switch to Dark Mode",
"commandSwitchToLightMode": "Switch to Light Mode",
"commandThemeKeywords": "theme, color theme, appearance",
- "commandWorkspaceSettings": "Workspace Settings",
"commandWorkspaceSettingsKeywords": "workspace, workspaces",
"configurationChat": "Configuration Chat",
"contextCompactionCountHelp": "Kandev infers this count from observed context usage drops. Missing samples or provider resets can make it approximate.",
diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json
index 174fe925da..c8bb95e5f5 100644
--- a/apps/web/src/locales/en/settings.json
+++ b/apps/web/src/locales/en/settings.json
@@ -2,6 +2,7 @@
"aboutAffectedKandevMcpTools": "About affected Kandev MCP tools",
"actionsFor": "Actions for {{target}}",
"activeKandevSpritesSpritesAreDestroyed": "Active Kandev sprites. Sprites are destroyed when agents stop.",
+ "activeSessions": "Active sessions",
"activeSprites_one": "{{count}} active sprite.",
"activeSprites_other": "{{count}} active sprites.",
"addAMissingToolTabBeside": "Add a missing tool tab beside the selected split.",
@@ -41,6 +42,7 @@
"chooseWhatOpensWhenKandevStarts": "Choose what opens when Kandev starts or you visit its bare home page",
"chooseWhichTranscriptNavigationControls": "Choose which transcript navigation controls appear, including the desktop-only sticky prompt bar",
"clearShortcut": "Clear shortcut",
+ "clearSettingsSearch": "Clear settings search",
"clickAShortcutToRecordA": "Click a shortcut to record a new key combination.",
"clickAUrlInTheTerminal": "Click a URL in the terminal to open it.",
"cmdCtrlEnterToSend": "Cmd/Ctrl+Enter to send",
@@ -93,6 +95,31 @@
"desktopNotifications": "Desktop Notifications",
"desktopNotificationsDescription": "Notify this device when a selected agent turn, question, or Office event occurs.",
"desktopOnlyWhileYouScrollPast": "Desktop only. While you scroll past your last prompt, stick a shortened copy to the top of the transcript with an expand toggle.",
+ "discoveryAliasesAgentTaskProfile": "MCP task profile,agent task default",
+ "discoveryAliasesArchiveConfirmation": "archive warning,confirm archive",
+ "discoveryAliasesColorTheme": "theme,light mode,dark mode,appearance",
+ "discoveryAliasesDesktopNotifications": "desktop alerts,system notifications",
+ "discoveryAliasesDisplayLanguage": "locale,translation,interface language",
+ "discoveryAliasesExternalMcpEndpoints": "MCP URL,HTTP endpoint,SSE endpoint",
+ "discoveryAliasesFileEditor": "language servers,LSP,default editor",
+ "discoveryAliasesKeyboardShortcuts": "hotkeys,keybindings,keyboard commands",
+ "discoveryAliasesLayoutProfiles": "workspace layout,panels,task workbench",
+ "discoveryAliasesNotificationSound": "notification audio,chime,alert sound",
+ "discoveryAliasesPreferredShell": "shell,zsh,bash,terminal shell",
+ "discoveryAliasesResourceMetrics": "metrics,CPU,memory,disk,status bar",
+ "discoveryAliasesStartupPage": "home page,start page,launch page",
+ "discoveryAliasesSubmitShortcut": "send key,enter to send,command enter",
+ "discoveryAliasesTerminalFont": "font family,typeface,monospace",
+ "discoveryAliasesTerminalFontSize": "text size,terminal text",
+ "discoveryAliasesTerminalLinks": "URLs,open links,browser",
+ "discoveryAliasesTranscriptNavigation": "anchored prompt bar,scroll to prompt,transcript auto scroll",
+ "discoveryAliasesUtilityActions": "title generation,branch naming,commit message,PR description",
+ "discoveryAliasesUtilityDefaultModel": "utility model,inference model,default AI model",
+ "discoveryAliasesVoiceBehavior": "voice language,push to talk,auto send,voice activation",
+ "discoveryAliasesVoiceEnable": "voice input,microphone,speech input",
+ "discoveryAliasesVoiceEngine": "speech recognition,transcription,Whisper Web",
+ "discoveryAliasesVoiceShortcut": "voice hotkey,microphone shortcut,push to talk shortcut",
+ "discoveryVoiceShortcut": "Voice input shortcut",
"destroyAll": "Destroy All",
"discardAndLeave": "Discard and leave",
"discarding": "Discarding...",
@@ -206,6 +233,7 @@
"nerdFonts": "Nerd Fonts",
"new": "New",
"newBrowserTab": "New browser tab",
+ "noMatchingSettings": "No matching settings",
"newCustomEditor": "New custom editor",
"newLayout": "New layout",
"newSecret": "New secret",
@@ -245,6 +273,7 @@
"notificationType": "Notification type",
"notifyWhenThisEventOccurs": "Notify when this event occurs.",
"notInstalled": "Not installed",
+ "password": "Password",
"openKandevTo": "Open Kandev to",
"openLinksIn": "Open links in",
"opensAVsCodeRemoteSsh": "Opens a VS Code Remote SSH URL using the scheme (example: {{example}}).",
@@ -289,6 +318,7 @@
"saving": "Saving...",
"savingChanges": "Saving changes",
"searchEditors": "Search editors...",
+ "searchSettings": "Search settings",
"secrets": "Secrets",
"secretValue": "Secret value",
"security": "Security",
@@ -299,6 +329,8 @@
"serverConfiguration": "Server Configuration",
"setTheFontSizeForThe": "Set the font size for the terminal. Default is 13px.",
"settings": "Settings",
+ "settingsSearchResults_one": "{{count}} matching setting",
+ "settingsSearchResults_other": "{{count}} matching settings",
"shellOptionsUnavailable": "Shell options unavailable",
"shellTerminalFontsAndLinkBehavior": "Shell, terminal fonts, and link behavior",
"showAnchoredPromptBar": "Show anchored prompt bar",
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index f4439231a2..e23a43cd6e 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -5,11 +5,9 @@
"backToTaskOverview": "Ɓàćķ ţō ţàśķ ōvēŕvĩēŵ",
"cancel": "Ćàńćēĺ",
"changelog": "Ćĥàńĝēĺōĝ",
- "commandAgentsSettings": "Àĝēńţś Śēţţĩńĝś",
"commandAgentsSettingsKeywords": "àĝēńţś, àĝēńţ śēţţĩńĝś, àĝēńţ ƥŕōƒĩĺēś, ĩńśţàĺĺēď àĝēńţś, ćĺàũďē",
"breadcrumb": "ƀŕēàďćŕũḿƀ",
"commandConfigChatKeywords": "ćōńƒĩĝ ćĥàţ, ćōńƒĩĝ ḿōďē, ćōńƒĩĝũŕē ķàńďēv, ŵōŕķƒĺōŵ ćōńƒĩĝũŕàţĩōń, ḿćƥ ćōńƒĩĝũŕàţĩōń",
- "commandExecutorsSettings": "Ēxēćũţōŕś Śēţţĩńĝś",
"commandExecutorsSettingsKeywords": "ēxēćũţōŕś, ēxēćũţōŕ ƥŕōƒĩĺēś, ēxēćũţĩōń ēńvĩŕōńḿēńţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, ŕũńţĩḿē, ćōḿƥũţē",
"commandGoToAllTasks": "Ĝō ţō Àĺĺ Ţàśķś",
"commandGoToAllTasksKeywords": "ţàśķś, ĺĩśţ, àĺĺ",
@@ -22,17 +20,16 @@
"commandGoToStats": "Ĝō ţō Śţàţś",
"commandGoToStatsKeywords": "śţàţś, śţàţĩśţĩćś, àńàĺŷţĩćś, ḿēţŕĩćś",
"commandGroupActions": "Àćţĩōńś",
+ "commandGroupCommands": "Ćōmmàńďś",
"commandGroupNavigation": "Ńàvĩĝàţĩōń",
"commandGroupPreferences": "Ƥŕēƒēŕēńćēś",
"commandGroupSettings": "Śēţţĩńĝś",
- "commandPromptsSettings": "Ƥŕōḿƥţś Śēţţĩńĝś",
"commandPromptsSettingsKeywords": "ƥŕōḿƥţś, ƥŕōḿƥţ śēţţĩńĝś, ćũśţōḿ ƥŕōḿƥţś, ƥŕōḿƥţ śńĩƥƥēţś, ƥŕōḿƥţ ţēḿƥĺàţēś",
"commandQuickChat": "Qũĩćķ Ćĥàţ",
"commandQuickChatKeywords": "qũĩćķ ćĥàţ, ńēŵ qũĩćķ ćĥàţ, qũĩćķ qũēśţĩōń, àśķ àĝēńţ",
"commandSwitchToDarkMode": "Śŵĩţćĥ ţō Ďàŕķ Ḿōďē",
"commandSwitchToLightMode": "Śŵĩţćĥ ţō Ĺĩĝĥţ Ḿōďē",
"commandThemeKeywords": "ţĥēḿē, ćōĺōŕ ţĥēḿē, àƥƥēàŕàńćē",
- "commandWorkspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś",
"commandWorkspaceSettingsKeywords": "ŵōŕķśƥàćē, ŵōŕķśƥàćēś",
"configurationChat": "Ćōńƒĩĝũŕàţĩōń Ćĥàţ",
"contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.",
diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json
index 1d124398b3..6ec388526c 100644
--- a/apps/web/src/locales/pseudo/settings.json
+++ b/apps/web/src/locales/pseudo/settings.json
@@ -2,6 +2,7 @@
"aboutAffectedKandevMcpTools": "Àƀōũţ àƒƒēćţēď Ķàńďēv ḾĆƤ ţōōĺś",
"actionsFor": "Àćţĩōńś ƒōŕ {{target}}",
"activeKandevSpritesSpritesAreDestroyed": "Àćţĩvē Ķàńďēv śƥŕĩţēś. Śƥŕĩţēś àŕē ďēśţŕōŷēď ŵĥēń àĝēńţś śţōƥ.",
+ "activeSessions": "Àćţĩvē śēśśĩōńś",
"activeSprites_one": "{{count}} àćţĩvē śƥŕĩţē.",
"activeSprites_other": "{{count}} àćţĩvē śƥŕĩţēś.",
"addAMissingToolTabBeside": "Àďď à ḿĩśśĩńĝ ţōōĺ ţàƀ ƀēśĩďē ţĥē śēĺēćţēď śƥĺĩţ.",
@@ -41,6 +42,7 @@
"chooseWhatOpensWhenKandevStarts": "Ćĥōōśē ŵĥàţ ōƥēńś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ vĩśĩţ ĩţś ƀàŕē ĥōḿē ƥàĝē",
"chooseWhichTranscriptNavigationControls": "Ćĥōōśē ŵĥĩćĥ ţŕàńśćŕĩƥţ ńàvĩĝàţĩōń ćōńţŕōĺś àƥƥēàŕ, ĩńćĺũďĩńĝ ţĥē ďēśķţōƥ-ōńĺŷ śţĩćķŷ ƥŕōḿƥţ ƀàŕ",
"clearShortcut": "Ćĺēàŕ śĥōŕţćũţ",
+ "clearSettingsSearch": "Ćĺēàŕ śēţţĩńĝś śēàŕćĥ",
"clickAShortcutToRecordA": "Ćĺĩćķ à śĥōŕţćũţ ţō ŕēćōŕď à ńēŵ ķēŷ ćōḿƀĩńàţĩōń.",
"clickAUrlInTheTerminal": "Ćĺĩćķ à ŨŔĹ ĩń ţĥē ţēŕḿĩńàĺ ţō ōƥēń ĩţ.",
"cmdCtrlEnterToSend": "Ćḿď/Ćţŕĺ+Ēńţēŕ ţō śēńď",
@@ -93,6 +95,31 @@
"desktopNotifications": "Ďēśķţōƥ Ńōţĩƒĩćàţĩōńś",
"desktopNotificationsDescription": "Ńōţĩƒŷ ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.",
"desktopOnlyWhileYouScrollPast": "Ďēśķţōƥ ōńĺŷ. Ŵĥĩĺē ŷōũ śćŕōĺĺ ƥàśţ ŷōũŕ ĺàśţ ƥŕōḿƥţ, śţĩćķ à śĥōŕţēńēď ćōƥŷ ţō ţĥē ţōƥ ōƒ ţĥē ţŕàńśćŕĩƥţ ŵĩţĥ àń ēxƥàńď ţōĝĝĺē.",
+ "discoveryAliasesAgentTaskProfile": "ḾĆƤ ţàśķ ƥŕōƒĩĺē,àĝēńţ ţàśķ ďēƒàũĺţ",
+ "discoveryAliasesArchiveConfirmation": "àŕćĥĩvē ŵàŕńĩńĝ,ćōńƒĩŕḿ àŕćĥĩvē",
+ "discoveryAliasesColorTheme": "ţĥēḿē,ĺĩĝĥţ ḿōďē,ďàŕķ ḿōďē,àƥƥēàŕàńćē",
+ "discoveryAliasesDesktopNotifications": "ďēśķţōƥ àĺēŕţś,śŷśţēḿ ńōţĩƒĩćàţĩōńś",
+ "discoveryAliasesDisplayLanguage": "ĺōćàĺē,ţŕàńśĺàţĩōń,ĩńţēŕƒàćē ĺàńĝũàĝē",
+ "discoveryAliasesExternalMcpEndpoints": "ḾĆƤ ŨŔĹ,ĤŢŢƤ ēńďƥōĩńţ,ŚŚĒ ēńďƥōĩńţ",
+ "discoveryAliasesFileEditor": "ĺàńĝũàĝē śēŕvēŕś,ĹŚƤ,ďēƒàũĺţ ēďĩţōŕ",
+ "discoveryAliasesKeyboardShortcuts": "ĥōţķēŷś,ķēŷƀĩńďĩńĝś,ķēŷƀōàŕď ćōḿḿàńďś",
+ "discoveryAliasesLayoutProfiles": "ŵōŕķśƥàćē ĺàŷōũţ,ƥàńēĺś,ţàśķ ŵōŕķƀēńćĥ",
+ "discoveryAliasesNotificationSound": "ńōţĩƒĩćàţĩōń àũďĩō,ćĥĩḿē,àĺēŕţ śōũńď",
+ "discoveryAliasesPreferredShell": "śĥēĺĺ,źśĥ,ƀàśĥ,ţēŕḿĩńàĺ śĥēĺĺ",
+ "discoveryAliasesResourceMetrics": "ḿēţŕĩćś,ĆƤŨ,ḿēḿōŕŷ,ďĩśķ,śţàţũś ƀàŕ",
+ "discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē",
+ "discoveryAliasesSubmitShortcut": "śēńď ķēŷ,ēńţēŕ ţō śēńď,ćōḿḿàńď ēńţēŕ",
+ "discoveryAliasesTerminalFont": "ƒōńţ ƒàḿĩĺŷ,ţŷƥēƒàćē,ḿōńōśƥàćē",
+ "discoveryAliasesTerminalFontSize": "ţēxţ śĩźē,ţēŕḿĩńàĺ ţēxţ",
+ "discoveryAliasesTerminalLinks": "ŨŔĹś,ōƥēń ĺĩńķś,ƀŕōŵśēŕ",
+ "discoveryAliasesTranscriptNavigation": "àńćĥōŕēď ƥŕōḿƥţ ƀàŕ,śćŕōĺĺ ţō ƥŕōḿƥţ,ţŕàńśćŕĩƥţ àũţō śćŕōĺĺ",
+ "discoveryAliasesUtilityActions": "ţĩţĺē ĝēńēŕàţĩōń,ƀŕàńćĥ ńàḿĩńĝ,ćōḿḿĩţ ḿēśśàĝē,ƤŔ ďēśćŕĩƥţĩōń",
+ "discoveryAliasesUtilityDefaultModel": "ũţĩĺĩţŷ ḿōďēĺ,ĩńƒēŕēńćē ḿōďēĺ,ďēƒàũĺţ ÀĨ ḿōďēĺ",
+ "discoveryAliasesVoiceBehavior": "vōĩćē ĺàńĝũàĝē,ƥũśĥ ţō ţàĺķ,àũţō śēńď,vōĩćē àćţĩvàţĩōń",
+ "discoveryAliasesVoiceEnable": "vōĩćē ĩńƥũţ,ḿĩćŕōƥĥōńē,śƥēēćĥ ĩńƥũţ",
+ "discoveryAliasesVoiceEngine": "śƥēēćĥ ŕēćōĝńĩţĩōń,ţŕàńśćŕĩƥţĩōń,Ŵĥĩśƥēŕ Ŵēƀ",
+ "discoveryAliasesVoiceShortcut": "vōĩćē ĥōţķēŷ,ḿĩćŕōƥĥōńē śĥōŕţćũţ,ƥũśĥ ţō ţàĺķ śĥōŕţćũţ",
+ "discoveryVoiceShortcut": "Vōĩćē ĩńƥũţ śĥōŕţćũţ",
"destroyAll": "Ďēśţŕōŷ Àĺĺ",
"discardAndLeave": "Ďĩśćàŕď àńď ĺēàvē",
"discarding": "Ďĩśćàŕďĩńĝ...",
@@ -206,6 +233,7 @@
"nerdFonts": "Ńēŕď Ƒōńţś",
"new": "Ńēŵ",
"newBrowserTab": "Ńēŵ ƀŕōŵśēŕ ţàƀ",
+ "noMatchingSettings": "Ńō ḿàţćĥĩńĝ śēţţĩńĝś",
"newCustomEditor": "Ńēŵ ćũśţōḿ ēďĩţōŕ",
"newLayout": "Ńēŵ ĺàŷōũţ",
"newSecret": "Ńēŵ śēćŕēţ",
@@ -245,6 +273,7 @@
"notificationType": "Ńōţĩƒĩćàţĩōń ţŷƥē",
"notifyWhenThisEventOccurs": "Ńōţĩƒŷ ŵĥēń ţĥĩś ēvēńţ ōććũŕś.",
"notInstalled": "Ńōţ ĩńśţàĺĺēď",
+ "password": "Ƥàśśŵōŕď",
"openKandevTo": "Ōƥēń Ķàńďēv ţō",
"openLinksIn": "Ōƥēń ĺĩńķś ĩń",
"opensAVsCodeRemoteSsh": "Ōƥēńś à VŚ Ćōďē Ŕēḿōţē ŚŚĤ ŨŔĹ ũśĩńĝ ţĥē śćĥēḿē (ēxàḿƥĺē: {{example}}).",
@@ -289,6 +318,7 @@
"saving": "Śàvĩńĝ...",
"savingChanges": "Śàvĩńĝ ćĥàńĝēś",
"searchEditors": "Śēàŕćĥ ēďĩţōŕś...",
+ "searchSettings": "Śēàŕćĥ śēţţĩńĝś",
"secrets": "Śēćŕēţś",
"secretValue": "Śēćŕēţ vàĺũē",
"security": "Śēćũŕĩţŷ",
@@ -299,6 +329,8 @@
"serverConfiguration": "Śēŕvēŕ Ćōńƒĩĝũŕàţĩōń",
"setTheFontSizeForThe": "Śēţ ţĥē ƒōńţ śĩźē ƒōŕ ţĥē ţēŕḿĩńàĺ. Ďēƒàũĺţ ĩś 13ƥx.",
"settings": "Śēţţĩńĝś",
+ "settingsSearchResults_one": "{{count}} ḿàţćĥĩńĝ śēţţĩńĝ",
+ "settingsSearchResults_other": "{{count}} ḿàţćĥĩńĝ śēţţĩńĝś",
"shellOptionsUnavailable": "Śĥēĺĺ ōƥţĩōńś ũńàvàĩĺàƀĺē",
"shellTerminalFontsAndLinkBehavior": "Śĥēĺĺ, ţēŕḿĩńàĺ ƒōńţś, àńď ĺĩńķ ƀēĥàvĩōŕ",
"showAnchoredPromptBar": "Śĥōŵ àńćĥōŕēď ƥŕōḿƥţ ƀàŕ",
diff --git a/docs/decisions/2026-08-04-navigation-manifest-boundaries.md b/docs/decisions/2026-08-04-navigation-manifest-boundaries.md
index 4ad3fc5fb1..3e22d9643c 100644
--- a/docs/decisions/2026-08-04-navigation-manifest-boundaries.md
+++ b/docs/decisions/2026-08-04-navigation-manifest-boundaries.md
@@ -30,6 +30,11 @@ destination, to collide.
the raw item ID separately only for compatibility selectors.
- Keep availability-gated and static consumers distinct. Static consumers must
not mount integration polling merely to render ungated links.
+- Keep deep settings discovery in a dedicated settings catalog rather than
+ expanding `APP_DESTINATIONS`. The Settings tree and command palette resolve
+ that catalog, while stable target IDs connect entries to rendered controls.
+ Plugin-authored control discovery remains out of scope until the plugin
+ contract supplies explicit labels, aliases, routes, and target metadata.
The following concerns remain explicit follow-up decisions rather than fields or
validation added before a concrete requirement exists:
@@ -52,6 +57,8 @@ validation added before a concrete requirement exists:
- Plugin destination React keys are globally stable across owners without
changing existing plugin test IDs.
- New navigation consumers must choose the gated or static hook deliberately.
+- Deep-settings consumers share one independently testable catalog without
+ turning top-level navigation entries into a settings schema.
- Future ordering, path ownership, and typed-route work has a discoverable
decision boundary instead of relying on comments in one implementation file.
diff --git a/docs/plans/settings-discovery/plan.md b/docs/plans/settings-discovery/plan.md
new file mode 100644
index 0000000000..27bc93653f
--- /dev/null
+++ b/docs/plans/settings-discovery/plan.md
@@ -0,0 +1,105 @@
+---
+spec: docs/specs/ui/settings-discovery.md
+created: 2026-08-05
+status: completed
+---
+
+# Implementation Plan: Settings Discovery
+
+## Overview
+
+Build a dedicated first-party settings catalog, then consume it from tree search and search-only
+Cmd+K commands. Stable hash targets connect both surfaces to rendered controls. Work proceeds from
+pure catalog/search contracts through UI integration, coverage, localization, and production-build
+desktop/mobile E2E.
+
+## Frontend
+
+### Discovery catalog and search
+
+Create `apps/web/lib/settings-discovery/` with typed definitions, domain-split catalogs, dynamic
+resolution, deterministic Unicode-aware search, exclusions, and target/navigation helpers. Reuse
+catalog route metadata from `general-nav.ts` and Settings tree groups rather than retaining parallel
+deep-link lists.
+
+### Settings tree and exact targets
+
+Add the sticky search/result UI to `SettingsTree`. Add a provider/registration protocol under
+`components/settings/` that handles initial, delayed, repeated, soft-navigation, and browser-history
+targets independently of `usePathname`, whose snapshot deliberately excludes fragments. Cross-page
+links retain `navigation-guard`; same-page fragments bypass leave confirmation.
+
+### Command palette
+
+Extend `CommandItem` with display context and search-only visibility. Generate granular commands
+from the catalog, preserve the existing top-level destination and legacy aliases, and load dynamic
+settings data only while the Commands palette is open. When a query is present, keep regular
+matches in Commands and render search-only catalog matches under their localized Settings group.
+
+### Coverage and localization
+
+Index every canonical first-party settings page and stable inline control, with explicit exclusions
+for transient/generated/plugin-owned UI. Resolve all copy at render time, regenerate pseudo locale,
+and keep current values out of searchable terms.
+
+### Mobile design contract
+
+Desktop outcome: search the permanent Settings tree and land on an exact control. Phone entry point:
+the existing `SettingsMobileMenu` full-height Sheet. Nearest shipped exemplar:
+`components/kanban/mobile-menu-sheet.tsx` contributes fixed-header/internal-scroll geometry, while
+the existing Settings Sheet remains because this is dense hierarchical navigation rather than a
+short temporary choice. Shared catalog/filter/target logic serves both. Navigation Sheet and page
+content each retain one existing scroll owner; controls are at least 44 px; the Sheet closes when
+selection completes. `mobile-settings-discovery.spec.ts` proves equivalent value and containment.
+
+## Tests
+
+- Pure catalog/search unit tests cover IDs, parents, targets, canonical routes, permissions,
+ dynamic encoding, exact/prefix/alias/context ranking, Unicode, and exclusions.
+- Target-navigation tests cover initial/delayed/repeated hashes, same/cross-page routing, focus,
+ highlight cleanup, history, and reduced motion.
+- Existing Settings-tree and command-panel tests cover unchanged normal state, search-only density,
+ breadcrumbs, ranking, and stable late registration.
+
+## E2E tests
+
+- `apps/web/e2e/tests/settings/settings-discovery.spec.ts`: desktop tree filtering, exact landing,
+ Escape/clear, history, and unsaved-navigation guard.
+- `apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts`: phone Sheet search, exact landing,
+ 44 px targets, containment, one scroll owner, and no horizontal overflow.
+- `apps/web/e2e/tests/command-panel.spec.ts`: search-only absence at rest and exact setting command
+ landing after typing.
+
+## Verification results
+
+- Focused unit/integration suite: 12 files and 85 tests passed.
+- Broad settings and integration-domain suites passed.
+- `pnpm run lint`, `pnpm run typecheck`, `pnpm run i18n:check`, and
+ `pnpm run i18n:ratchet` passed.
+- Public-doc validation passed after documenting Settings and Cmd+K discovery in Get Started.
+- `pnpm run build` completed a production Vite build.
+- Desktop Settings discovery E2E: 3 tests passed.
+- Mobile Settings discovery E2E: 1 test passed.
+- Cmd+K discovery and legacy-alias E2E: 2 tests passed.
+- Cmd+K sectioning follow-up: 14 focused component tests and 1 fresh-build browser test passed;
+ typecheck, focused ESLint, i18n checks, and the new-code ratchet passed.
+- Settings-search polish follow-up: desktop and mobile production-browser tests passed after a
+ fresh build; visual checks confirmed compact desktop density and retained 44 px phone targets.
+- Settings-result motion follow-up: focused unit coverage and fresh-build desktop/mobile browser
+ tests passed; the browser test proves a 160 ms row reflow and an animation-free reduced-motion
+ update.
+
+## Implementation waves
+
+- [x] [Task 01 — catalog and search](task-01-catalog-and-search.md)
+- [x] [Task 02 — tree and target navigation](task-02-tree-and-target-navigation.md)
+- [x] [Task 03 — command palette](task-03-command-palette.md)
+- [x] [Task 04 — general and standalone coverage](task-04-general-target-coverage.md)
+- [x] [Task 05 — dynamic, integration, system, and account coverage](task-05-domain-target-coverage.md)
+- [x] [Task 06 — browser and mobile E2E](task-06-browser-mobile-e2e.md)
+- [x] [Task 07 — capitalized dynamic labels](task-07-capitalized-dynamic-labels.md)
+- [x] [Task 08 — separate Cmd+K Settings section](task-08-command-palette-settings-group.md)
+- [x] [Task 09 — compact Settings search field](task-09-compact-settings-search.md)
+- [x] [Task 10 — subtle Settings result motion](task-10-settings-result-motion.md)
+
+All tasks execute sequentially in this conversation. No subagents are authorized.
diff --git a/docs/plans/settings-discovery/task-01-catalog-and-search.md b/docs/plans/settings-discovery/task-01-catalog-and-search.md
new file mode 100644
index 0000000000..93674d3a16
--- /dev/null
+++ b/docs/plans/settings-discovery/task-01-catalog-and-search.md
@@ -0,0 +1,46 @@
+---
+id: "01-catalog-and-search"
+title: "Settings discovery catalog and search"
+status: completed
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 01: Settings discovery catalog and search
+
+## Acceptance
+
+- Typed domain catalogs resolve translated static and dynamic entries with stable IDs, canonical
+ URLs, target IDs, safe aliases, and current permission gates.
+- Search deterministically ranks exact/prefix/alias/context matches, supports Unicode/diacritics,
+ and prevents ancestor-only result floods.
+- Every control has a target; every first-party canonical page is covered or explicitly excluded.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery`
+
+## Files likely touched
+
+- `apps/web/lib/settings-discovery/**`
+- `apps/web/components/settings/general-nav.ts`
+- `apps/web/components/app-sidebar/sections/settings/{general,workspaces,agents,executors,system,account}-group.tsx`
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+Sequential; it defines shared contracts for every later task.
+
+## Results
+
+- Added domain-split, typed static and dynamic catalogs with route exclusions and permission gates.
+- Added runtime translation/breadcrumb resolution and URL-safe dynamic paths.
+- Added deterministic Unicode-aware search with direct-term gating and ranked label/alias matches.
+- Reused catalog route metadata from existing Settings tree groups to prevent route drift.
+- Passed: `cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery components/app-sidebar/sections/settings/settings-tree-render.test.tsx components/app-sidebar/sections/settings/settings-tree.test.ts` (27 tests).
+- Passed: `cd apps/web && pnpm run typecheck`.
diff --git a/docs/plans/settings-discovery/task-02-tree-and-target-navigation.md b/docs/plans/settings-discovery/task-02-tree-and-target-navigation.md
new file mode 100644
index 0000000000..62dc717057
--- /dev/null
+++ b/docs/plans/settings-discovery/task-02-tree-and-target-navigation.md
@@ -0,0 +1,52 @@
+---
+id: "02-tree-and-target-navigation"
+title: "Settings tree search and target navigation"
+status: completed
+wave: 2
+depends_on: ["01-catalog-and-search"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 02: Settings tree search and target navigation
+
+## Acceptance
+
+- Empty Settings search preserves the current tree; a query renders only matching grouped results
+ with labels, breadcrumbs, clear/Escape, and an accessible empty/status state.
+- Cross-page targets use guarded routing; same-page, delayed, repeated, and history targets scroll,
+ focus, and briefly highlight without changing values.
+- Desktop and phone reuse the same logic, existing scroll owners, and at least 44 px phone actions.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run components/app-sidebar/sections/settings components/settings/settings-target-provider.test.tsx`
+
+## Files likely touched
+
+- `apps/web/components/app-sidebar/sections/settings/settings-tree.tsx`
+- `apps/web/components/app-sidebar/sections/settings/settings-search.tsx`
+- `apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx`
+- `apps/web/components/settings/settings-target-provider.tsx`
+- `apps/web/components/settings/settings-target.tsx`
+- `apps/web/components/settings/settings-layout-client.tsx`
+- `apps/web/components/settings/settings-card.tsx`
+- `apps/web/components/settings/settings-section.tsx`
+- `apps/web/app/globals.css`
+
+## Dependencies
+
+Task 01.
+
+## Parallelism
+
+Sequential; tree and target behavior share navigation contracts.
+
+## Results
+
+- Added persistent Settings-tree search with grouped ranked hits, breadcrumbs, clear/Escape, and accessible result status.
+- Added guarded cross-page and blocker-free same-page fragment navigation with repeat/history events.
+- Added registry-backed async target resolution, focus, centered scroll, reduced-motion handling, and one-shot highlight.
+- Added wrapper-free target support to shared settings cards and sections; wired initial terminal targets.
+- Increased all phone Settings Sheet navigation actions to at least 44 px.
+- Passed target, tree, and typecheck suites.
diff --git a/docs/plans/settings-discovery/task-03-command-palette.md b/docs/plans/settings-discovery/task-03-command-palette.md
new file mode 100644
index 0000000000..ebce79ce93
--- /dev/null
+++ b/docs/plans/settings-discovery/task-03-command-palette.md
@@ -0,0 +1,46 @@
+---
+id: "03-command-palette"
+title: "Search-only settings commands"
+status: completed
+wave: 3
+depends_on: ["01-catalog-and-search", "02-tree-and-target-navigation"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 03: Search-only settings commands
+
+## Acceptance
+
+- Granular settings commands are absent with no query and appear after typing with muted hierarchy.
+- Catalog commands retain legacy page aliases, deterministic ranking, stable dynamic IDs, and exact
+ target navigation.
+- Dynamic settings data loads only while the Commands palette is open.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run lib/commands/search.test.ts components/command-panel-content-search.test.tsx components/global-commands.test.tsx`
+
+## Files likely touched
+
+- `apps/web/lib/commands/types.ts`
+- `apps/web/lib/commands/search.ts`
+- `apps/web/components/command-panel-footer.tsx`
+- `apps/web/components/settings/settings-discovery-commands.tsx`
+- `apps/web/components/global-commands.tsx`
+
+## Dependencies
+
+Tasks 01 and 02.
+
+## Parallelism
+
+Sequential; it consumes catalog and target navigation.
+
+## Results
+
+- Registered catalog-backed settings commands with stable legacy IDs and aliases.
+- Kept granular entries search-only while retaining the top-level Go to Settings destination.
+- Added two-line result presentation with non-searchable owning context.
+- Reused exact target navigation and gated dynamic settings loads to an open Commands palette.
+- Passed command search, panel, global-command, builder, and typecheck suites.
diff --git a/docs/plans/settings-discovery/task-04-general-target-coverage.md b/docs/plans/settings-discovery/task-04-general-target-coverage.md
new file mode 100644
index 0000000000..bea55e73a6
--- /dev/null
+++ b/docs/plans/settings-discovery/task-04-general-target-coverage.md
@@ -0,0 +1,42 @@
+---
+id: "04-general-target-coverage"
+title: "General and standalone target coverage"
+status: completed
+wave: 4
+depends_on: ["02-tree-and-target-navigation"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 04: General and standalone target coverage
+
+## Acceptance
+
+- General, terminal, appearance, layouts, notifications, editors, shortcuts, task actions, prompts,
+ voice, utilities, secrets, sprites, and external MCP expose stable inline targets.
+- Catalog labels and aliases resolve at render time; pseudo locale remains complete.
+- Dialog-only, destructive, generated, and value-bearing fields remain explicitly excluded.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery components/settings`
+
+## Files likely touched
+
+- General and standalone files named in `plan.md`
+- `apps/web/src/locales/en/settings.json`
+- `apps/web/src/locales/pseudo/settings.json`
+
+## Dependencies
+
+Task 02.
+
+## Parallelism
+
+Sequential because catalog files are shared.
+
+## Results
+
+- `cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery components/settings`
+ — passed.
+- `cd apps/web && pnpm run typecheck` — passed after all Task 04 targets were wired.
diff --git a/docs/plans/settings-discovery/task-05-domain-target-coverage.md b/docs/plans/settings-discovery/task-05-domain-target-coverage.md
new file mode 100644
index 0000000000..b651376002
--- /dev/null
+++ b/docs/plans/settings-discovery/task-05-domain-target-coverage.md
@@ -0,0 +1,43 @@
+---
+id: "05-domain-target-coverage"
+title: "Dynamic, integration, system, and account target coverage"
+status: completed
+wave: 5
+depends_on: ["04-general-target-coverage"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 05: Dynamic, integration, system, and account target coverage
+
+## Acceptance
+
+- Workspace, agent, executor, integration, system, and account pages resolve safe page/section/control
+ entries with dynamic names and encoded canonical routes.
+- Auth/admin/feature/capability gates match rendering behavior and do not reveal inaccessible pages.
+- Generated resource rows, secret values, and plugin-authored internals remain unindexed.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery components/settings components/{azure-devops,github,gitlab,jira,linear,sentry,slack}`
+
+## Files likely touched
+
+- Dynamic, integration, system, and account files named in `plan.md`
+- Relevant English and generated pseudo locale namespaces
+
+## Dependencies
+
+Task 04.
+
+## Parallelism
+
+Sequential because catalog and locale files are shared.
+
+## Results
+
+- `cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery components/settings components/azure-devops components/github components/gitlab components/jira components/linear components/sentry components/slack`
+ — passed.
+- `cd apps/web && pnpm run typecheck` — passed.
+- `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet` — passed; pseudo locale
+ synchronized.
diff --git a/docs/plans/settings-discovery/task-06-browser-mobile-e2e.md b/docs/plans/settings-discovery/task-06-browser-mobile-e2e.md
new file mode 100644
index 0000000000..b4e55349c5
--- /dev/null
+++ b/docs/plans/settings-discovery/task-06-browser-mobile-e2e.md
@@ -0,0 +1,53 @@
+---
+id: "06-browser-mobile-e2e"
+title: "Desktop and mobile browser coverage"
+status: completed
+wave: 6
+depends_on: ["03-command-palette", "05-domain-target-coverage"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 06: Desktop and mobile browser coverage
+
+## Acceptance
+
+- Production-build desktop E2E proves tree filtering, exact target focus/highlight, history, and
+ guarded unsaved cross-page navigation.
+- Cmd+K E2E proves granular entries are search-only and exact selection lands on its control.
+- Mobile E2E proves equivalent search/landing value, Sheet dismissal, 44 px actions, internal
+ scrolling, viewport containment, and no document horizontal overflow.
+
+## Verification
+
+- `cd apps/web && pnpm e2e:run tests/settings/settings-discovery.spec.ts`
+- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-settings-discovery.spec.ts`
+- `cd apps/web && pnpm e2e:run tests/command-panel.spec.ts -- --grep "settings discovery"`
+- `cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet`
+
+## Files likely touched
+
+- `apps/web/e2e/tests/settings/settings-discovery.spec.ts`
+- `apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts`
+- `apps/web/e2e/tests/command-panel.spec.ts`
+- Focused selectors/page helpers required by those scenarios
+
+## Dependencies
+
+Tasks 03 and 05.
+
+## Parallelism
+
+Sequential; it validates the integrated feature against one production build.
+
+## Results
+
+- `pnpm run build` — passed; production Vite bundle generated.
+- `pnpm e2e:run --host --no-build tests/settings/settings-discovery.spec.ts` — 3 passed.
+- `pnpm e2e:run --host --no-build --project mobile-chrome tests/settings/mobile-settings-discovery.spec.ts`
+ — 1 passed.
+- `pnpm e2e:run --host --no-build tests/command-panel.spec.ts -- --grep
+ "settings discovery|common aliases find home commands"` — 2 passed.
+- Final lint, typecheck, i18n checks, focused unit/integration suites, and `git diff --check`
+ passed.
+- Public docs now explain Settings-tree and Cmd+K discovery; both public-doc validators passed.
diff --git a/docs/plans/settings-discovery/task-07-capitalized-dynamic-labels.md b/docs/plans/settings-discovery/task-07-capitalized-dynamic-labels.md
new file mode 100644
index 0000000000..e8db85b450
--- /dev/null
+++ b/docs/plans/settings-discovery/task-07-capitalized-dynamic-labels.md
@@ -0,0 +1,46 @@
+---
+id: "07-capitalized-dynamic-labels"
+title: "Capitalized dynamic Settings discovery labels"
+status: completed
+wave: 7
+depends_on: ["06-browser-mobile-e2e"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 07: Capitalized dynamic Settings discovery labels
+
+## Root cause
+
+Dynamic workspace pages request nonexistent `common:repositories` and `common:workflows`
+translations. i18next returns each raw key tail, so search results render lowercase even though
+the Settings tree uses capitalized labels from the owning namespaces.
+
+## Acceptance
+
+- Dynamic repository and workflow results reuse the same translated labels as Settings navigation.
+- A regression test fails on the raw-key fallback and passes after the key correction.
+- Shared catalog resolution keeps desktop, phone, and Cmd+K output identical.
+
+## Verification
+
+`cd apps && pnpm --filter @kandev/web test -- --run lib/settings-discovery/catalog.test.ts`
+
+## Files
+
+- `apps/web/lib/settings-discovery/resolve.ts`
+- `apps/web/lib/settings-discovery/catalog.test.ts`
+
+## Parallelism
+
+Sequential; test and implementation change the same discovery contract.
+
+## Results
+
+- RED: focused catalog test received `repositories` and `workflows` instead of the capitalized
+ navigation labels.
+- GREEN: reused `sidebar:repositories` and `workflows:workflows`; 10/10 focused tests passed.
+- Typecheck, focused ESLint, i18n checks, and the discovery translation-key audit passed.
+- The isolated Tailscale test instance served the updated module and Settings route with HTTP 200.
+- No separate mobile E2E was needed: this is shared catalog data normalization with no layout,
+ touch, scrolling, navigation, or viewport-specific behavior change.
diff --git a/docs/plans/settings-discovery/task-08-command-palette-settings-group.md b/docs/plans/settings-discovery/task-08-command-palette-settings-group.md
new file mode 100644
index 0000000000..7962555a9b
--- /dev/null
+++ b/docs/plans/settings-discovery/task-08-command-palette-settings-group.md
@@ -0,0 +1,58 @@
+---
+id: "08-command-palette-settings-group"
+title: "Separate Cmd+K Settings section"
+status: completed
+wave: 8
+depends_on: ["07-capitalized-dynamic-labels"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 08: Separate Cmd+K Settings section
+
+## Root cause
+
+`CommandsListContent` ranks typed matches correctly but renders every command inside one hardcoded
+Commands group. Search-only catalog entries therefore lose their Settings information hierarchy
+once they become visible.
+
+## Acceptance
+
+- Regular typed matches remain under Commands.
+- Search-only settings matches render under their existing localized Settings group.
+- A group is omitted when it has no matching results.
+- Ranking, secondary context, selection, and exact-target navigation remain unchanged.
+
+## Verification
+
+- `cd apps && pnpm --filter @kandev/web test -- --run components/command-panel-content-search.test.tsx`
+- `cd apps/web && pnpm e2e:run --host tests/command-panel.spec.ts --grep "settings discovery"`
+
+## Files
+
+- `apps/web/components/command-panel-footer.tsx`
+- `apps/web/components/command-panel-content-search.test.tsx`
+- `apps/web/e2e/tests/command-panel.spec.ts`
+
+## Mobile parity
+
+The command list renderer is shared by desktop and phone. This change adds no viewport branch,
+touch target, overlay, navigation, or scroll-owner behavior; the existing shared component contract
+is sufficient for this presentation-only hierarchy correction.
+
+## Parallelism
+
+Sequential; the regression and implementation modify the same command-list contract.
+
+## Results
+
+- RED: 2 focused component assertions failed because Settings results rendered under Commands.
+- RED E2E: the fresh production build exposed `Commands` as the sole typed-result heading.
+- GREEN: regular matches render under localized Commands while search-only matches retain their
+ localized group; empty groups are omitted and ranked order is preserved within each group.
+- Focused component tests passed (14/14), and the fresh-build browser test passed (1/1).
+- Typecheck, focused ESLint, i18n checks, the new-code i18n ratchet, and `git diff --check` passed.
+- The isolated local and Tailscale Settings URLs returned HTTP 200 and served the updated module;
+ the main Kandev process on `:9998` remained untouched.
+- No separate mobile E2E was needed because the same command-list component renders at every
+ viewport and no touch, overlay, navigation, or scrolling behavior changed.
diff --git a/docs/plans/settings-discovery/task-09-compact-settings-search.md b/docs/plans/settings-discovery/task-09-compact-settings-search.md
new file mode 100644
index 0000000000..71a49f38ac
--- /dev/null
+++ b/docs/plans/settings-discovery/task-09-compact-settings-search.md
@@ -0,0 +1,58 @@
+---
+id: "09-compact-settings-search"
+title: "Compact Settings search field"
+status: completed
+wave: 9
+depends_on: ["08-command-palette-settings-group"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 09: Compact Settings search field
+
+## UX diagnosis
+
+The search input is 44 px tall at every viewport while desktop Settings rows are roughly 32 px.
+That mismatch makes the utility control dominate the compact sidebar hierarchy.
+
+## Acceptance
+
+- The desktop search input aligns vertically with neighboring Settings navigation rows.
+- Desktop icon, padding, clear control, and trailing spacing scale down together.
+- The phone Settings Sheet retains at least 44 px search and clear touch targets.
+- Search behavior, focus styling, sticky positioning, and result layout remain unchanged.
+
+## Verification
+
+- `cd apps/web && pnpm e2e:run --host tests/settings/settings-discovery.spec.ts --grep "filters the tree"`
+- `cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/settings/mobile-settings-discovery.spec.ts`
+
+## Files
+
+- `apps/web/components/app-sidebar/sections/settings/settings-search.tsx`
+- `apps/web/e2e/tests/settings/settings-discovery.spec.ts`
+
+## Mobile parity
+
+The existing full-height Settings Sheet remains the phone entry point and navigation remains its
+single scroll owner. Responsive classes compact only the desktop presentation; the phone input and
+clear action remain 44 px, with shared search state and behavior.
+
+## Parallelism
+
+Sequential; the geometry regression defines the responsive implementation.
+
+## Results
+
+- RED: the production-browser geometry assertion measured a 12.5 px height mismatch between the
+ 44 px desktop search input and a neighboring Settings row.
+- GREEN: the desktop input is 32 px, with a 14 px search icon, 32 px clear control, tighter
+ horizontal padding, and 6 px trailing space; the surrounding row-height difference is at most
+ 2 px.
+- Phone values remain unchanged at 44 px for both the search input and clear action.
+- Fresh-build desktop E2E passed (1/1); the unchanged production build then passed the Pixel 5
+ mobile search, containment, touch-target, navigation, and focus scenario (1/1).
+- Desktop and phone Chromium screenshots were inspected against the isolated dev instance.
+- Typecheck, focused ESLint, and `git diff --check` passed.
+- The Tailscale test endpoint returned HTTP 200 and served the updated module; the main Kandev
+ process on `:9998` remained untouched.
diff --git a/docs/plans/settings-discovery/task-10-settings-result-motion.md b/docs/plans/settings-discovery/task-10-settings-result-motion.md
new file mode 100644
index 0000000000..714a61b9ed
--- /dev/null
+++ b/docs/plans/settings-discovery/task-10-settings-result-motion.md
@@ -0,0 +1,43 @@
+---
+id: "10-settings-result-motion"
+title: "Subtle Settings result motion"
+status: completed
+created: 2026-08-05
+wave: 10
+depends_on: ["09-compact-settings-search"]
+plan: "plan.md"
+spec: "../../specs/ui/settings-discovery.md"
+---
+
+# Task 10: Subtle Settings result motion
+
+## Goal
+
+Make Settings-tree search results feel continuous as filtering changes their positions, without
+adding conspicuous motion or weakening reduced-motion behavior.
+
+## Implementation
+
+- Add a small FLIP-style layout transition to result rows and persistent group headings in
+ `apps/web/components/app-sidebar/sections/settings/settings-search.tsx`.
+- Fade newly matching entries in over only a few pixels; do not animate the initial Settings tree
+ or delay interaction.
+- Cancel superseded animations so rapid typing remains responsive.
+- Skip all result motion when `prefers-reduced-motion: reduce` is active.
+- Keep the existing grouping, ranking, navigation, focus, and 44 px phone targets unchanged.
+
+## Validation
+
+- Run focused TypeScript and ESLint checks for the Settings search component.
+- Exercise changing queries in desktop Chromium and confirm a surviving row moves continuously.
+- Exercise the same flow with reduced motion and confirm results update immediately.
+- Re-run the desktop and phone Settings-discovery Playwright coverage.
+
+## Result
+
+- `settings-search.test.tsx`: 2 motion-contract tests passed.
+- `settings-tree-render.test.tsx`: 16 existing Settings-tree tests passed.
+- Focused ESLint and frontend typecheck passed.
+- Fresh production build passed.
+- Desktop Settings discovery E2E: 4 tests passed, including live layout and reduced-motion checks.
+- Mobile Settings discovery E2E: 1 test passed with the existing 44 px and containment assertions.
diff --git a/docs/public/use-kandev.md b/docs/public/use-kandev.md
index b65bf3affa..6d28689129 100644
--- a/docs/public/use-kandev.md
+++ b/docs/public/use-kandev.md
@@ -85,6 +85,18 @@ The first-run dialog scans supported agent CLIs, lets you inspect detected profi
+## Find a setting
+
+Open **Settings** and use **Search settings** at the top of its navigation tree. Search results are
+grouped by area; selecting a specific control opens its owning page, scrolls it into view, focuses
+it, and briefly highlights it without changing the saved value. The same search is available in
+the Settings menu on phones.
+
+From anywhere, press `Cmd/Ctrl+K` and begin typing a setting name or familiar alias. Individual
+settings appear only after typing, while **Go to Settings** remains in the command menu at rest.
+Discovery searches setting names and curated aliases, never saved values, secrets, paths, or other
+configuration content.
+
## Add a local repository
1. Open **Settings > Workspaces > Default Workspace > Repositories**. If you created or renamed the workspace, choose that workspace instead.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 8d3ebf9c5b..9724d731c0 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -151,6 +151,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [message-queue-management](ui/message-queue-management.md) | shipped |
| [message-queue-merge](ui/message-queue-merge.md) | shipped |
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
+| [settings-discovery](ui/settings-discovery.md) | shipped |
| [executor-settings-card-spacing](ui/executor-settings-card-spacing.md) | shipped |
| [quick-chat-elevation](ui/quick-chat-elevation.md) | building |
| [transcript-navigation-settings](ui/transcript-navigation-settings.md) | shipped |
diff --git a/docs/specs/ui/settings-discovery.md b/docs/specs/ui/settings-discovery.md
new file mode 100644
index 0000000000..9d44062cef
--- /dev/null
+++ b/docs/specs/ui/settings-discovery.md
@@ -0,0 +1,122 @@
+---
+status: shipped
+created: 2026-08-05
+owner: kandev
+---
+
+# Settings Discovery
+
+## Why
+
+Kandev has enough settings that users cannot reliably infer which page owns a control. The
+settings tree and command palette should help users find a setting by the language they remember,
+without requiring them to browse every section or making the default command palette noisy.
+
+## What
+
+- The Settings tree shows a search field above its existing navigation on desktop and phone.
+- On desktop, the search field follows the compact density of the surrounding navigation instead
+ of using phone-sized vertical chrome.
+- An empty query preserves the existing tree, ordering, active route, accordion state, badges, and
+ plugin navigation.
+- A non-empty query shows only matching first-party pages, sections, and controls, grouped by their
+ settings area. Each result shows its own label and an owning-page breadcrumb.
+- As the query changes, matching rows use a brief layout transition instead of snapping between
+ positions. Newly matching rows enter softly, while reduced-motion users get immediate updates.
+- Search matches translated labels and curated translated aliases. It never indexes saved values,
+ secrets, paths, URLs, prompts, or other user-entered configuration.
+- At least one query token must match the result's own label or aliases. Other tokens may narrow by
+ breadcrumb. A parent name by itself does not flood results with every descendant.
+- Exact labels rank above prefixes, aliases, and contextual matches; ties retain catalog order.
+- Every indexed control has a canonical settings URL and stable fragment. Selecting it opens the
+ owning page, scrolls the control into view, focuses it without changing its value, and briefly
+ highlights it.
+- Same-page targeting does not trigger the unsaved-settings leave confirmation. Cross-page
+ targeting uses the normal guarded router.
+- Cmd+K exposes the same settings catalog only after the user types. The default palette retains
+ its compact top-level Go to Settings destination.
+- Cmd+K setting results show Settings plus the owning hierarchy as secondary context. Selecting a
+ result navigates to the same exact target as Settings-tree search; it never edits the value.
+- Typed Cmd+K results keep regular actions under Commands and place granular setting results in a
+ separate localized Settings section. Empty sections are omitted.
+- Discovery respects the same auth, admin, feature, capability, and resource availability rules as
+ the rendered settings surface.
+- Stable first-party pages and inline controls are indexed. Transient dialog fields, destructive
+ confirmations, generated collection rows, and opaque plugin-authored controls are explicitly
+ excluded.
+
+## Permissions
+
+Discovery does not grant access. Account, user-management, feature-gated, and capability-gated
+entries appear only when the current user and runtime could render the destination. Installed
+plugin pages may be discoverable from host-known metadata, but plugin-authored form internals are
+not indexed without a plugin discovery contract.
+
+## Failure modes
+
+- Dynamic resource loading failures leave static settings searchable and omit unresolved dynamic
+ profile or workspace entries; no error replaces the command palette or settings tree.
+- A stale or manually entered target fragment that has no registered control leaves the page
+ usable and does not move focus.
+- A target rendered after asynchronous data arrives is revealed when it registers; discovery does
+ not use fixed-delay retries.
+- Canceling the existing unsaved-settings navigation confirmation leaves the current page and draft
+ unchanged.
+
+## Mobile contract
+
+- The existing full-height Settings Sheet remains the phone entry point because the hierarchy is
+ dense and frequently searched; this feature does not introduce another overlay.
+- Desktop and phone share catalog, filtering, ranking, navigation, and target state.
+- The Sheet keeps one navigation scroll owner. The settings page keeps its existing content scroll
+ owner. Search does not add nested vertical or document horizontal scrolling.
+- Search and clear controls have at least 44 px touch targets. Selecting a result closes the Sheet
+ and leaves the destination control focused.
+
+## Scenarios
+
+- **GIVEN** an empty Settings search, **WHEN** the tree renders, **THEN** its current groups, active
+ route, badges, and plugin slot remain unchanged below the search field.
+- **GIVEN** the desktop Settings tree, **WHEN** the search field renders, **THEN** its visible height
+ aligns with a neighboring navigation row rather than the 44 px phone control height.
+- **GIVEN** the query `font size`, **WHEN** results render, **THEN** Terminal font size appears under
+ General › Terminal and unrelated sections are absent.
+- **GIVEN** visible Settings search results, **WHEN** the query changes and surviving results move,
+ **THEN** rows settle into their new positions with restrained motion rather than snapping.
+- **GIVEN** reduced motion, **WHEN** Settings search results change, **THEN** rows update immediately
+ without layout or presence animation.
+- **GIVEN** the query `General`, **WHEN** results render, **THEN** the General page can match without
+ every General descendant appearing solely because of its breadcrumb.
+- **GIVEN** the query `workflows`, **WHEN** a dynamic workspace result renders, **THEN** its label is
+ the same translated `Workflows` navigation label, never a lowercase raw-key fallback.
+- **GIVEN** a matching control on another settings page, **WHEN** the user selects it, **THEN** the
+ guarded router opens its canonical URL and the control is scrolled, focused, and highlighted.
+- **GIVEN** a matching control on the current page with unsaved changes, **WHEN** the user selects
+ it, **THEN** the fragment changes and the control is revealed without a leave confirmation.
+- **GIVEN** Cmd+K with no query, **WHEN** the palette opens, **THEN** granular settings are absent and
+ Go to Settings remains available.
+- **GIVEN** Cmd+K with `terminal font size`, **WHEN** results render, **THEN** the exact setting and
+ Settings › General › Terminal context appear and Enter reveals the input.
+- **GIVEN** a Cmd+K query matches both a regular action and a granular setting, **WHEN** results
+ render, **THEN** the action appears under Commands and the granular result appears under Settings.
+- **GIVEN** a delayed agent or executor profile list, **WHEN** matching commands register, **THEN**
+ stable command IDs prevent the current matching selection from jumping.
+- **GIVEN** reduced motion, **WHEN** an exact target opens, **THEN** scrolling is instant and the
+ highlight does not pulse.
+- **GIVEN** a 390 px phone viewport, **WHEN** the user searches and selects a setting, **THEN** the
+ Sheet remains contained, closes after selection, and the destination is usable without horizontal
+ overflow.
+
+## Out of scope
+
+- Editing or toggling settings directly from Cmd+K.
+- Server-side, semantic, or saved-value search.
+- A broader Settings Sheet/Drawer or settings-layout redesign.
+- Direct targets inside transient dialogs, confirmations, or individual generated rows.
+- A public discovery metadata contract for plugin-authored controls.
+- Backend, database, or persistence changes.
+
+## Implementation plan
+
+See [the implementation plan](../../plans/settings-discovery/plan.md) and
+[ADR-2026-08-04-navigation-manifest-boundaries](../../decisions/2026-08-04-navigation-manifest-boundaries.md).