diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 5832295936..765db9920f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -210,6 +210,10 @@ } @layer components { + [data-settings-target-highlight="true"] { + animation: settings-target-highlight 1400ms ease-out; + } + [data-settings-dirty="true"] { border-color: color-mix(in oklab, var(--success) 65%, var(--border)) !important; } @@ -235,6 +239,26 @@ } } +@keyframes settings-target-highlight { + 0%, + 35% { + background-color: color-mix(in oklab, var(--primary) 10%, transparent); + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--primary) 55%, transparent); + } + 100% { + background-color: transparent; + box-shadow: inset 0 0 0 2px transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-settings-target-highlight="true"] { + animation: none; + background-color: color-mix(in oklab, var(--primary) 10%, transparent); + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--primary) 55%, transparent); + } +} + /* WebKit can leave a fixed dialog's transform animation on a separate layer, which makes the Create Task surface look soft or briefly sit below its overlay. Keep the workaround scoped to the dialog opt-in and the engine diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx index 07ce869256..d486eee57a 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx @@ -3,12 +3,13 @@ import { useEffect, useState, useMemo, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; import { IconCopy, IconCheck, IconTerminal2 } from "@tabler/icons-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; import { Skeleton } from "@kandev/ui/skeleton"; import { previewAgentCommandAction, type CommandPreviewResponse } from "@/app/actions/agents"; import type { CLIFlag, ProfileEnvVar } from "@/lib/types/http"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { SettingsCard } from "@/components/settings/settings-card"; type CommandPreviewCardProps = { agentName: string; @@ -19,6 +20,7 @@ type CommandPreviewCardProps = { commandPrefix?: string; envVars?: ProfileEnvVar[]; secrets?: { id: string; name: string }[]; + discoveryTargetId?: string; }; // The placeholder the backend substitutes into the launch command. A token the @@ -141,6 +143,7 @@ export function CommandPreviewCard({ commandPrefix, envVars, secrets, + discoveryTargetId, }: CommandPreviewCardProps) { const { t } = useTranslation(); const envPrefix = useMemo(() => buildEnvPrefix(envVars, secrets), [envVars, secrets]); @@ -189,7 +192,7 @@ export function CommandPreviewCard({ }, [agentName, settingsKey]); return ( - + @@ -210,6 +213,6 @@ export function CommandPreviewCard({ )} {!loading && !error && !preview && } - + ); } diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index c2c912b3f8..53a68b565c 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -29,7 +29,7 @@ import { rowsToEnvVars, envVarsToRows, } from "@/components/settings/profile-edit/env-vars-card"; -import { ScriptCard } from "@/components/settings/profile-edit/script-card"; +import { ProfileScriptCards } from "@/components/settings/profile-edit/profile-script-cards"; import { SSHAgentReadinessCard } from "@/components/settings/ssh-agent-readiness-card"; import { type GitIdentityMode, @@ -60,6 +60,7 @@ import { } from "@/components/settings/profile-edit/executor-profile-baselines"; import type { Executor, ExecutorProfile, ExecutorType, ProfileEnvVar } from "@/lib/types/http"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; +import { executorProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; const EXECUTORS_ROUTE = "/settings/executors"; const SPRITES_TOKEN_KEY = "SPRITES_API_TOKEN"; @@ -417,15 +418,10 @@ type ProfileEditSectionsProps = { secrets: ReturnType["items"]; }; -function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) { +function ExecutorSpecificSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) { const gitIdentityBaseline = getGitIdentityBaseline(profile, form.localGitIdentity); return ( <> - {executor.type === "ssh" && ( )} + + ); +} + +function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) { + return ( + <> + + + - ); } -/** The prepare/cleanup script pair, split out to keep ProfileEditSections - * under the max-lines-per-function budget. */ -function ProfileScriptCards({ - executor, - profile, - form, -}: Omit) { - const { t } = useTranslation(); - return ( - <> - - {form.isRemote && ( - - )} - - ); -} - function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) { const { t } = useTranslation(); const router = useRouter(); diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 358ac697d2..62ec7c1f11 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -31,6 +31,8 @@ import { useToast } from "@/components/toast-provider"; import { useAppStore } from "@/components/state-provider"; import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { SettingsTarget } from "@/components/settings/settings-target"; +import { workspaceDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; type WorkspaceEditClientProps = { workspaceId: string; @@ -77,6 +79,7 @@ type SelectFieldProps = { options: { id: string; name: string }[]; emptyLabel: string; emptyValue: string; + discoveryTargetId: string; }; function SelectField({ @@ -88,10 +91,11 @@ function SelectField({ options, emptyLabel, emptyValue, + discoveryTargetId, }: SelectFieldProps) { const { t } = useTranslation(); return ( -
+ -
+ ); } type WorkspaceSettingsCardProps = { + workspaceId: string; workspaceNameDraft: string; nameIsDirty: boolean; onNameChange: (value: string) => void; @@ -131,6 +136,7 @@ type WorkspaceSettingsCardProps = { }; function WorkspaceSettingsCard({ + workspaceId, workspaceNameDraft, nameIsDirty, onNameChange, @@ -157,7 +163,10 @@ function WorkspaceSettingsCard({
-
+ onNameChange(e.target.value)} /> -
+
@@ -560,6 +571,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { = { + "account-security": IconShieldLock, + "account-tokens": IconKey, +}; -// `labelKey`, not `label`: this table is module scope, so a `t()` here would -// freeze at the boot locale (and a SCREAMING_CASE literal is invisible to lint). -const ITEMS = [ - { href: `${ROOT_HREF}/security`, labelKey: "sidebar:profileAndPassword", icon: IconShieldLock }, - { href: `${ROOT_HREF}/tokens`, labelKey: "sidebar:apiTokens", icon: IconKey }, -]; +const ITEMS = ACCOUNT_DISCOVERY_DEFINITIONS.filter( + (item) => item.parentId === "account" && item.labelKey, +).map((item) => ({ + ...item, + labelKey: item.labelKey as string, + icon: ACCOUNT_ITEM_ICONS[item.id], +})); type AccountGroupProps = { pathname: string; @@ -26,8 +35,8 @@ export function AccountGroup({ pathname, expanded, onToggle }: AccountGroupProps diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx index 0c3919be7d..b110bd4375 100644 --- a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx @@ -5,10 +5,9 @@ import { IconRobot } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { AGENTS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; -const ROOT_HREF = "/settings/agents"; - type AgentsGroupProps = { pathname: string; expanded?: boolean; @@ -24,15 +23,15 @@ export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) {agents.flatMap((agent) => agent.profiles.map((profile) => { const encodedAgent = encodeURIComponent(agent.name); - const profilePath = `${ROOT_HREF}/${encodedAgent}/profiles/${profile.id}`; + const profilePath = `${AGENTS_SETTINGS_HREF}/${encodedAgent}/profiles/${profile.id}`; const agentLabel = profile.agentDisplayName || agent.name; return ( {allProfiles.map((profile) => { const Icon = getExecutorIcon(profile.executorType); - const profilePath = `${ROOT_HREF}/${profile.id}`; + const profilePath = `${EXECUTORS_SETTINGS_HREF}/${profile.id}`; return ( diff --git a/apps/web/components/app-sidebar/sections/settings/settings-search.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-search.test.tsx new file mode 100644 index 0000000000..3c3b68ef69 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-search.test.tsx @@ -0,0 +1,130 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSettingsDiscoveryItem } from "@/lib/settings-discovery/types"; +import { SettingsSearch } from "./settings-search"; + +const EASE_OUT = "cubic-bezier(0.2, 0, 0, 1)"; + +function item(id: string, label: string, order: number): ResolvedSettingsDiscoveryItem { + return { + id, + kind: "control", + label, + aliases: [], + breadcrumb: ["General", "Appearance"], + groupId: "general", + groupLabel: "General", + href: `/settings/general/appearance#setting-${id}`, + targetId: `setting-${id}`, + order, + }; +} + +const ITEMS = [item("alpha-layout", "Alpha Layout", 0), item("beta-layout", "Beta Layout", 1)]; + +function installLayoutMocks(reducedMotion = false) { + const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate"); + const animate = vi.fn( + () => + ({ + addEventListener: vi.fn(), + cancel: vi.fn(), + }) as unknown as Animation, + ); + Object.defineProperty(HTMLElement.prototype, "animate", { + configurable: true, + value: animate, + }); + vi.stubGlobal( + "matchMedia", + vi.fn((media: string) => ({ + matches: reducedMotion, + media, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + const nodes = [...document.querySelectorAll("[data-settings-search-motion-key]")]; + const top = Math.max(0, nodes.indexOf(this)) * 44; + return { + x: 0, + y: top, + top, + bottom: top + 44, + left: 0, + right: 240, + width: 240, + height: 44, + toJSON: () => ({}), + }; + }); + return { + animate, + restore: () => { + if (originalAnimate) { + Object.defineProperty(HTMLElement.prototype, "animate", originalAnimate); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "animate"); + } + }, + }; +} + +describe("SettingsSearch result motion", () => { + let restoreAnimate: (() => void) | undefined; + + afterEach(() => { + cleanup(); + restoreAnimate?.(); + restoreAnimate = undefined; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("softly introduces results and moves a surviving row from its previous position", () => { + const mocks = installLayoutMocks(); + restoreAnimate = mocks.restore; + const props = { items: ITEMS, onQueryChange: vi.fn(), onSelect: vi.fn() }; + const { rerender } = render(); + + expect(mocks.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(2px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 140, easing: EASE_OUT }, + ); + + mocks.animate.mockClear(); + rerender(); + + expect(mocks.animate).toHaveBeenCalledWith( + [{ transform: "translateY(44px)" }, { transform: "translateY(0)" }], + { duration: 160, easing: EASE_OUT }, + ); + }); + + it("updates immediately when reduced motion is requested", () => { + const mocks = installLayoutMocks(true); + restoreAnimate = mocks.restore; + const props = { items: ITEMS, onQueryChange: vi.fn(), onSelect: vi.fn() }; + const { container, rerender } = render(); + + rerender(); + + expect(mocks.animate).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-settings-search-motion-key="item:alpha-layout"]'), + ).toBeNull(); + expect( + container.querySelector('[data-settings-search-motion-key="item:beta-layout"]'), + ).not.toBeNull(); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-search.tsx b/apps/web/components/app-sidebar/sections/settings/settings-search.tsx new file mode 100644 index 0000000000..52e9fdd5c7 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-search.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { IconSearch, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import type { ChangeEvent, KeyboardEvent, MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import Link from "@/components/routing/app-link"; +import { useRouter } from "@/lib/routing/client-router"; +import { navigateToSettingsDiscovery } from "@/lib/settings-discovery/navigation"; +import { searchSettingsDiscovery } from "@/lib/settings-discovery/search"; +import type { ResolvedSettingsDiscoveryItem } from "@/lib/settings-discovery/types"; +import { useSettingsSearchMotion } from "./use-settings-search-motion"; + +type SettingsSearchProps = { + items: ResolvedSettingsDiscoveryItem[]; + query: string; + onQueryChange: (query: string) => void; + onSelect: () => void; +}; + +type ResultGroup = { + id: string; + label: string; + items: ResolvedSettingsDiscoveryItem[]; +}; + +export function SettingsSearch({ items, query, onQueryChange, onSelect }: SettingsSearchProps) { + const { t } = useTranslation(); + const trimmedQuery = query.trim(); + const results = trimmedQuery ? searchSettingsDiscovery(items, trimmedQuery) : []; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || query.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + onQueryChange(""); + }; + + return ( + <> +
+
+
+
+ {trimmedQuery && } + + ); +} + +function SettingsSearchResults({ + results, + onSelect, +}: { + results: ResolvedSettingsDiscoveryItem[]; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const groups = groupResults(results); + const layoutKey = groups + .flatMap((group) => [`group:${group.id}`, ...group.items.map((item) => `item:${item.id}`)]) + .join("\0"); + const motionRef = useSettingsSearchMotion(layoutKey); + + if (results.length === 0) { + return ( +
+

+ {t("settings:noMatchingSettings")} +

+
+ ); + } + + return ( +
+

+ {t("settings:settingsSearchResults", { count: results.length })} +

+ {groups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((item) => ( + + ))} +
+
+ ))} +
+ ); +} + +function SettingsSearchResult({ + item, + onSelect, +}: { + item: ResolvedSettingsDiscoveryItem; + onSelect: () => void; +}) { + const router = useRouter(); + const handleClick = (event: MouseEvent) => { + if (!isPlainPrimaryClick(event)) return; + event.preventDefault(); + navigateToSettingsDiscovery(item.href, router.push); + onSelect(); + }; + + return ( + + {item.label} + {item.breadcrumb.length > 0 && ( + + {item.breadcrumb.join(" › ")} + + )} + + ); +} + +function groupResults(results: ResolvedSettingsDiscoveryItem[]): ResultGroup[] { + const groups = new Map(); + for (const item of results) { + const group = groups.get(item.groupId) ?? { + id: item.groupId, + label: item.groupLabel, + items: [], + }; + group.items.push(item); + groups.set(item.groupId, group); + } + return [...groups.values()]; +} + +function isPlainPrimaryClick(event: MouseEvent): boolean { + return ( + event.button === 0 && + !event.metaKey && + !event.altKey && + !event.ctrlKey && + !event.shiftKey && + (!event.currentTarget.target || event.currentTarget.target === "_self") + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index b5d14892bf..de5e54cae6 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -6,6 +6,7 @@ const MAIN_WORKSPACE_ID = "ws-1"; const ARCHIVE_WORKSPACE_ID = "ws-10"; const MAIN_WORKSPACE_NAME = "Main Workspace"; const ARCHIVE_WORKSPACE_NAME = "Archive Workspace"; +const VOICE_MODE_LABEL = "Voice Mode"; const state = { workspaces: { @@ -234,7 +235,7 @@ describe("SettingsTree standalone leaves", () => { it("keeps Voice Mode in the settings tree as a standalone active leaf", () => { render(); - expect(screen.getByRole("link", { name: "Voice Mode" }).getAttribute("href")).toBe( + expect(screen.getByRole("link", { name: VOICE_MODE_LABEL }).getAttribute("href")).toBe( "/settings/voice-mode", ); @@ -242,7 +243,7 @@ describe("SettingsTree standalone leaves", () => { render(); - expect(screen.getByRole("link", { name: "Voice Mode" }).className).toContain( + expect(screen.getByRole("link", { name: VOICE_MODE_LABEL }).className).toContain( "before:bg-primary", ); expect(screen.queryByRole("link", { name: "Appearance" })).toBeNull(); @@ -260,6 +261,49 @@ describe("SettingsTree standalone leaves", () => { }); }); +describe("SettingsTree search", () => { + afterEach(cleanup); + + it("preserves the normal tree until a query filters it to grouped hits", () => { + render(); + + const search = screen.getByRole("searchbox", { name: "Search settings" }); + expect(screen.getByRole("link", { name: VOICE_MODE_LABEL })).toBeTruthy(); + + fireEvent.change(search, { target: { value: "font size" } }); + + const result = screen.getByRole("link", { name: /Terminal Font Size/ }); + expect(result.getAttribute("href")).toBe( + "/settings/general/terminal#setting-terminal-font-size", + ); + expect(result.textContent).toContain("General"); + expect(result.textContent).toContain("Terminal"); + expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull(); + }); + + it("clears a query with Escape and restores the normal tree", () => { + render(); + const search = screen.getByRole("searchbox", { name: "Search settings" }); + + fireEvent.change(search, { target: { value: "font size" } }); + fireEvent.keyDown(search, { key: "Escape" }); + + expect((search as HTMLInputElement).value).toBe(""); + expect(screen.getByRole("link", { name: VOICE_MODE_LABEL })).toBeTruthy(); + }); + + it("announces an empty result without rendering the normal tree", () => { + render(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Search settings" }), { + target: { value: "definitely missing" }, + }); + + expect(screen.getByText("No matching settings")).toBeTruthy(); + expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull(); + }); +}); + describe("Message Queue settings navigation", () => { afterEach(cleanup); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx index 5cf39f8522..634a037dbf 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -13,20 +13,24 @@ import { import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useSettingsDiscovery } from "@/hooks/domains/settings/use-settings-discovery"; +import { + EXTERNAL_MCP_SETTINGS_HREF, + PLUGINS_SETTINGS_HREF, + PROMPTS_SETTINGS_HREF, + UTILITY_AGENTS_SETTINGS_HREF, + VOICE_MODE_SETTINGS_HREF, +} from "@/lib/settings-discovery/catalog/standalone"; import { AccountGroup } from "./account-group"; import { AgentsGroup } from "./agents-group"; import { ExecutorsGroup } from "./executors-group"; import { GeneralGroup } from "./general-group"; import { SettingsLeaf } from "./settings-nav-primitives"; +import { SettingsSearch } from "./settings-search"; import { SystemGroup } from "./system-group"; import { WorkspacesGroup } from "./workspaces-group"; -const PROMPTS_HREF = "/settings/prompts"; -const VOICE_MODE_HREF = "/settings/voice-mode"; -const UTILITY_HREF = "/settings/utility-agents"; const SECRETS_HREF = "/settings/general/secrets"; -const EXT_MCP_HREF = "/settings/external-mcp"; -const PLUGINS_HREF = "/settings/plugins"; const DEFAULT_OPEN_GROUP = "workspaces"; // Single-open accordion: each top-level group owns a route prefix. The group @@ -63,6 +67,8 @@ export function SettingsTree({ pathname }: { pathname: string }) { const authEnabled = useFeature("auth"); const authMode = useAppStore((s) => s.auth.mode); const showAccountGroup = authEnabled && authMode === "enabled"; + const discoveryItems = useSettingsDiscovery(); + const [query, setQuery] = useState(""); const [openGroup, setOpenGroup] = useState(() => settingsOpenGroupIdForPath(pathname), ); @@ -80,50 +86,60 @@ export function SettingsTree({ pathname }: { pathname: string }) { return ( <> - - - - setQuery("")} /> - - - - {/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */} - - - - - - {showAccountGroup && } + {query.trim() ? null : ( + <> + + + + + + + + {/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */} + + + + + + {showAccountGroup && } + + )} ); } diff --git a/apps/web/components/app-sidebar/sections/settings/system-group.tsx b/apps/web/components/app-sidebar/sections/settings/system-group.tsx index 2d22e8bd92..8f4c3484cb 100644 --- a/apps/web/components/app-sidebar/sections/settings/system-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/system-group.tsx @@ -17,33 +17,36 @@ import type { Icon as TablerIcon } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; +import { + SYSTEM_DISCOVERY_DEFINITIONS, + SYSTEM_SETTINGS_HREF, + SYSTEM_STATUS_SETTINGS_HREF, +} from "@/lib/settings-discovery/catalog/system"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; -const ROOT_HREF = "/settings/system"; -const DEFAULT_HREF = `${ROOT_HREF}/status`; +const SYSTEM_ITEM_ICONS: Record = { + "system-status": IconActivity, + "system-feature-toggles": IconFlask, + "system-database": IconDatabase, + "system-backups": IconArchive, + "system-storage": IconTrash, + "system-logs": IconFileText, + "system-updates": IconRefresh, + "system-about": IconInfoCircle, + "system-licenses": IconScale, + "system-users": IconUsers, +}; -/** - * `href` is the route and stays a value; only `labelKey` is copy, resolved at - * render so the nav follows a locale switch. Storing resolved labels here - * would freeze them at the boot locale — and because these are SCREAMING_CASE - * identifiers, `mode: "jsx-only"` skips them entirely, so lint reported none - * of the ten. - */ -const BASE_ITEMS: Array<{ href: string; labelKey: string; icon: TablerIcon }> = [ - { href: `${ROOT_HREF}/status`, labelKey: "common:status", icon: IconActivity }, - { href: `${ROOT_HREF}/feature-toggles`, labelKey: "system:navFeatureToggles", icon: IconFlask }, - { href: `${ROOT_HREF}/database`, labelKey: "system:navDatabase", icon: IconDatabase }, - { href: `${ROOT_HREF}/backups`, labelKey: "system:navBackups", icon: IconArchive }, - { href: `${ROOT_HREF}/storage`, labelKey: "system:navStorage", icon: IconTrash }, - { href: `${ROOT_HREF}/logs`, labelKey: "system:navLogs", icon: IconFileText }, - { href: `${ROOT_HREF}/updates`, labelKey: "system:navUpdates", icon: IconRefresh }, - { href: `${ROOT_HREF}/about`, labelKey: "system:navAbout", icon: IconInfoCircle }, - { href: `${ROOT_HREF}/licenses`, labelKey: "system:navLicenses", icon: IconScale }, -]; +const SYSTEM_ITEMS = SYSTEM_DISCOVERY_DEFINITIONS.filter( + (item) => item.parentId === "system" && item.labelKey, +).map((item) => ({ + ...item, + labelKey: item.labelKey as string, + icon: SYSTEM_ITEM_ICONS[item.id], +})); -const AUTH_ITEMS: Array<{ href: string; labelKey: string; icon: TablerIcon }> = [ - { href: `${ROOT_HREF}/users`, labelKey: "system:navUsers", icon: IconUsers }, -]; +const BASE_ITEMS = SYSTEM_ITEMS.filter((item) => item.requires !== "users"); +const AUTH_ITEMS = SYSTEM_ITEMS.filter((item) => item.requires === "users"); type SystemGroupProps = { pathname: string; @@ -67,8 +70,8 @@ export function SystemGroup({ pathname, expanded, onToggle }: SystemGroupProps) diff --git a/apps/web/components/app-sidebar/sections/settings/use-settings-search-motion.ts b/apps/web/components/app-sidebar/sections/settings/use-settings-search-motion.ts new file mode 100644 index 0000000000..de67ac0897 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/use-settings-search-motion.ts @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef } from "react"; + +const MOTION_SELECTOR = "[data-settings-search-motion-key]"; +const MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)"; +const ENTER_OPTIONS: KeyframeAnimationOptions = { + duration: 140, + easing: MOTION_EASING, +}; +const MOVE_OPTIONS: KeyframeAnimationOptions = { + duration: 160, + easing: MOTION_EASING, +}; + +type MotionEntry = { + key: string; + node: HTMLElement; + top: number; +}; + +export function useSettingsSearchMotion(layoutKey: string) { + const containerRef = useRef(null); + const previousPositionsRef = useRef(new Map()); + const animationsRef = useRef(new Map()); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const entries = collectMotionEntries(container); + const nextPositions = new Map(entries.map((entry) => [entry.key, entry.top])); + const animations = animationsRef.current; + cancelRemovedAnimations(animations, new Set(nextPositions.keys())); + + if (prefersReducedMotion()) { + cancelAnimations(animations); + previousPositionsRef.current = nextPositions; + return; + } + + for (const entry of entries) { + animations.get(entry.key)?.cancel(); + animations.delete(entry.key); + const previousTop = previousPositionsRef.current.get(entry.key); + const animation = animateEntry(entry, previousTop); + if (!animation) continue; + + animations.set(entry.key, animation); + animation.addEventListener( + "finish", + () => { + if (animations.get(entry.key) === animation) animations.delete(entry.key); + }, + { once: true }, + ); + } + + previousPositionsRef.current = nextPositions; + }, [layoutKey]); + + useEffect( + () => () => { + cancelAnimations(animationsRef.current); + }, + [], + ); + + return containerRef; +} + +function collectMotionEntries(container: HTMLElement): MotionEntry[] { + return [...container.querySelectorAll(MOTION_SELECTOR)].flatMap((node) => { + const key = node.dataset.settingsSearchMotionKey; + return key ? [{ key, node, top: node.getBoundingClientRect().top }] : []; + }); +} + +function animateEntry(entry: MotionEntry, previousTop: number | undefined): Animation | null { + if (typeof entry.node.animate !== "function") return null; + if (previousTop === undefined) { + return entry.node.animate( + [ + { opacity: 0, transform: "translateY(2px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + ENTER_OPTIONS, + ); + } + + const offset = previousTop - entry.top; + if (Math.abs(offset) < 0.5) return null; + return entry.node.animate( + [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0)" }], + MOVE_OPTIONS, + ); +} + +function cancelRemovedAnimations(animations: Map, activeKeys: Set) { + for (const [key, animation] of animations) { + if (activeKeys.has(key)) continue; + animation.cancel(); + animations.delete(key); + } +} + +function cancelAnimations(animations: Map) { + for (const animation of animations.values()) animation.cancel(); + animations.clear(); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index 7811edd6cd..3e584f8476 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -24,21 +24,21 @@ import { useJiraAuthed } from "@/hooks/domains/jira/use-jira-availability"; import { useLinearAuthed } from "@/hooks/domains/linear/use-linear-availability"; import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; import { useSlackAuthed } from "@/hooks/domains/slack/use-slack-availability"; +import { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations"; +import { WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/workspaces"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; -const ROOT_HREF = "/settings/workspace"; - type IntegrationIcon = ComponentType<{ className?: string }>; -const INTEGRATIONS: Array<{ slug: string; label: string; icon: IntegrationIcon }> = [ - { slug: "azure-devops", label: "Azure DevOps", icon: AzureDevOpsIcon }, - { slug: "github", label: "GitHub", icon: IconBrandGithub }, - { slug: "gitlab", label: "GitLab", icon: IconBrandGitlab }, - { slug: "jira", label: "Jira", icon: IconTicket }, - { slug: "linear", label: "Linear", icon: IconHexagon }, - { slug: "sentry", label: "Sentry", icon: IconBrandSentry }, - { slug: "slack", label: "Slack", icon: IconBrandSlack }, -]; +const INTEGRATION_ICONS: Record<(typeof WORKSPACE_INTEGRATIONS)[number][0], IntegrationIcon> = { + "azure-devops": AzureDevOpsIcon, + github: IconBrandGithub, + gitlab: IconBrandGitlab, + jira: IconTicket, + linear: IconHexagon, + sentry: IconBrandSentry, + slack: IconBrandSlack, +}; // Rendered as components, not module-scope JSX constants: `t()` must resolve at // render so a locale switch reaches them (a module-scope value freezes at boot). @@ -86,7 +86,7 @@ function WorkspaceIntegrationItems({ ...(slack ? ["slack"] : []), ]); - return INTEGRATIONS.map(({ slug, label, icon }) => { + return WORKSPACE_INTEGRATIONS.map(([slug, label]) => { const href = `${integrationsPath}/${slug}`; return ( : undefined} - icon={icon} + icon={INTEGRATION_ICONS[slug]} isActive={pathname === href} depth={3} /> @@ -109,7 +109,7 @@ type WorkspacesGroupProps = { }; function isWorkspaceRoute(pathname: string, workspaceId: string): boolean { - const workspacePath = `${ROOT_HREF}/${workspaceId}`; + const workspacePath = `${WORKSPACES_SETTINGS_HREF}/${workspaceId}`; return pathname === workspacePath || pathname.startsWith(`${workspacePath}/`); } @@ -155,13 +155,13 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou {orderedWorkspaces.map((workspace) => { - const workspacePath = `${ROOT_HREF}/${workspace.id}`; + const workspacePath = `${WORKSPACES_SETTINGS_HREF}/${workspace.id}`; const repositoriesPath = `${workspacePath}/repositories`; const workflowsPath = `${workspacePath}/workflows`; const automationsPath = `${workspacePath}/automations`; diff --git a/apps/web/components/azure-devops/azure-devops-settings.tsx b/apps/web/components/azure-devops/azure-devops-settings.tsx index 57e0d6b096..0ff8719612 100644 --- a/apps/web/components/azure-devops/azure-devops-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-settings.tsx @@ -41,6 +41,7 @@ import type { SetAzureDevOpsConfigRequest, TestAzureDevOpsConnectionResult, } from "@/lib/types/azure-devops"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; type FormState = { organizationUrl: string; @@ -486,6 +487,7 @@ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: str return ( } title={t("azuredevops:integrationTitle")} description={t("azuredevops:integrationDescription")} diff --git a/apps/web/components/command-panel-content-search.test.tsx b/apps/web/components/command-panel-content-search.test.tsx index bbcce49eb3..c204429ce9 100644 --- a/apps/web/components/command-panel-content-search.test.tsx +++ b/apps/web/components/command-panel-content-search.test.tsx @@ -23,8 +23,12 @@ vi.mock("@kandev/ui/command", () => ({ "data-testid"?: string; "data-repository"?: string; }) => ( -
- {heading &&

{heading}

} +
+ {heading && ( +
+ {heading} +
+ )} {children}
), @@ -88,6 +92,7 @@ vi.mock("./workspace-content-search", () => ({ import { CommandPanelView, + MODE_COMMANDS, MODE_SEARCH_CONTENT, type CommandPanelViewProps, } from "./command-panel-footer"; @@ -102,6 +107,7 @@ const result: WorkspaceContentSearchResult = { }; const ARIA_SELECTED_ATTRIBUTE = "aria-selected"; +const CMDK_GROUP_HEADING_SELECTOR = "[cmdk-group-heading]"; function viewProps(overrides: Partial = {}): CommandPanelViewProps { return { @@ -259,6 +265,93 @@ describe("CommandPanelView task content search mode", () => { }); }); +describe("CommandPanelView search-only commands", () => { + const FONT_SIZE_LABEL = "Terminal Font Size"; + const goToSettings = { + id: "nav-settings", + label: "Go to Settings", + group: "Navigation", + action: vi.fn(), + }; + const fontSize = { + id: "setting:terminal-font-size", + label: FONT_SIZE_LABEL, + group: "Settings", + context: "Settings › General › Terminal", + searchOnly: true, + action: vi.fn(), + }; + + it("keeps granular settings hidden before typing", () => { + render( + , + ); + + expect(screen.getByText("Go to Settings")).toBeTruthy(); + expect(screen.queryByText(FONT_SIZE_LABEL)).toBeNull(); + }); + + it("shows a matching granular setting with owning context after typing", () => { + render( + , + ); + + expect(screen.getByText(FONT_SIZE_LABEL)).toBeTruthy(); + expect(screen.getByText("Settings › General › Terminal")).toBeTruthy(); + expect(screen.getByText("Settings", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeTruthy(); + expect(screen.queryByText("Commands", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeNull(); + }); + + it("separates regular and granular matches into Commands and Settings", () => { + const fontSizeGuide = { + id: "help:terminal-font-size", + label: "Terminal Font Size Guide", + group: "Help", + action: vi.fn(), + }; + render( + , + ); + + expect( + screen + .getByText("Terminal Font Size Guide") + .closest("[cmdk-group]") + ?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent, + ).toBe("Commands"); + expect( + screen + .getByText(FONT_SIZE_LABEL, { exact: true }) + .closest("[cmdk-group]") + ?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent, + ).toBe("Settings"); + }); +}); + describe("CommandPanelView mode result safety", () => { it("does not delegate filtering to cmdk while palette mode groups swap", () => { render(); diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index 0e64f6e1c5..d2ea2325c2 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, type Dispatch, type SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; import { formatDistanceToNow } from "date-fns"; import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react"; import { @@ -80,8 +81,23 @@ function CommandItemRow({ keywords={getCommandSearchTerms(cmd)} onSelect={() => onSelect(cmd)} > - {cmd.icon && {cmd.icon}} - {cmd.label} + {cmd.icon && ( + + {cmd.icon} + + )} + + {cmd.label} + {cmd.context && ( + + {cmd.context} + + )} + {cmd.shortcut && {formatShortcut(cmd.shortcut)}} {cmd.enterMode && ( @@ -159,6 +175,45 @@ type CommandsListContentProps = { onTaskSelect: (task: Task) => void; }; +function SearchCommandGroups({ + commands, + search, + onSelect, +}: Pick) { + const { t } = useTranslation(); + const matches = sortCommandsForSearch(commands, search).filter( + (cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0, + ); + const regularMatches = matches.filter((cmd) => !cmd.searchOnly); + const searchOnlyGroups = new Map(); + + for (const cmd of matches) { + if (!cmd.searchOnly) continue; + const groupItems = searchOnlyGroups.get(cmd.group) ?? []; + groupItems.push(cmd); + searchOnlyGroups.set(cmd.group, groupItems); + } + + return ( + <> + {regularMatches.length > 0 && ( + + {regularMatches.map((cmd) => ( + + ))} + + )} + {[...searchOnlyGroups].map(([group, items]) => ( + + {items.map((cmd) => ( + + ))} + + ))} + + ); +} + function CommandsListContent({ commands, grouped, @@ -195,21 +250,19 @@ function CommandsListContent({ )} {search.trim() ? ( - - {sortCommandsForSearch(commands, search) - .filter((cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0) - .map((cmd) => ( - - ))} - + ) : ( - grouped.map(([group, items]) => ( - - {items.map((cmd) => ( - - ))} - - )) + grouped.map(([group, items]) => { + const visibleItems = items.filter((cmd) => !cmd.searchOnly); + if (visibleItems.length === 0) return null; + return ( + + {visibleItems.map((cmd) => ( + + ))} + + ); + }) )} ); diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 2a1bb9c1c7..08fef56d94 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -26,6 +26,7 @@ import { ResetWatchDialog, useWatchResetController } from "@/components/watches/ import { cleanupMergedReviewTasks, cleanupClosedIssueTasks } from "@/lib/api/domains/github-api"; import type { ReviewWatch, IssueWatch } from "@/lib/types/github"; import { useTranslation } from "react-i18next"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; // CleanupNowButton runs a manual global sweep over the dedup tables. Useful // for users who upgraded with a pile of legacy merged-PR / closed-issue @@ -260,6 +261,7 @@ export function GitHubConnectionSection({ workspaceId }: { workspaceId: string } diff --git a/apps/web/components/gitlab/gitlab-settings.tsx b/apps/web/components/gitlab/gitlab-settings.tsx index e08f53ee7f..1831172a57 100644 --- a/apps/web/components/gitlab/gitlab-settings.tsx +++ b/apps/web/components/gitlab/gitlab-settings.tsx @@ -32,6 +32,7 @@ import { GitLabWatchSettings } from "./watch-settings"; import { GitLabActionPresetsSection } from "./action-presets-section"; import { Trans, useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; const DEFAULT_HOST = "https://gitlab.com"; // The bare hostname as it reads mid-sentence. Interpolated rather than written @@ -429,6 +430,7 @@ function GitLabConnectionCard(props: ConnectionCardProps) { } = props; return ( } diff --git a/apps/web/components/global-commands.test.tsx b/apps/web/components/global-commands.test.tsx index ac20d62cac..f6ff8ff302 100644 --- a/apps/web/components/global-commands.test.tsx +++ b/apps/web/components/global-commands.test.tsx @@ -60,6 +60,9 @@ vi.mock("@/lib/keyboard/shortcut-overrides", () => ({ getShortcut: () => undefin vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mocks.push }), })); +vi.mock("@/components/settings-discovery-commands", () => ({ + SettingsDiscoveryCommands: () => null, +})); function navigationCommand(): CommandItem { const command = mocks.commands.find((item) => item.id === "nav-stats"); diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx index 5597267e48..a30500ea63 100644 --- a/apps/web/components/global-commands.tsx +++ b/apps/web/components/global-commands.tsx @@ -5,15 +5,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useRouter } from "@/lib/routing/client-router"; import { useTheme } from "@/components/theme/app-theme"; -import { - IconSun, - IconMoon, - IconRobot, - IconCpu, - IconFolder, - IconMessageCircle, - IconSparkles, -} from "@tabler/icons-react"; +import { IconSun, IconMoon, IconMessageCircle, IconSparkles } from "@tabler/icons-react"; import { useStaticDestinations } from "@/hooks/use-app-destinations"; import { PALETTE_NAVIGATION_GROUP_KEY } from "@/lib/navigation/surface-policy"; import { useRegisterCommands } from "@/hooks/use-register-commands"; @@ -24,13 +16,13 @@ import { useAppStore } from "@/components/state-provider"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import type { CommandItem } from "@/lib/commands/types"; +import { SettingsDiscoveryCommands } from "@/components/settings-discovery-commands"; type PushFn = ReturnType["push"]; // Catalog keys, not copy — safe at module scope (no `t()` call here). The // palette groups by this resolved value, so every producer must use these. const GROUP_NAVIGATION = PALETTE_NAVIGATION_GROUP_KEY; -const GROUP_SETTINGS = "common:commandGroupSettings"; const GROUP_ACTIONS = "common:commandGroupActions"; /** @@ -112,43 +104,6 @@ function useNavigationCommands(push: PushFn, t: TFunction): CommandItem[] { return cacheRef.current.commands; } -function buildSettingsCommands(push: PushFn, t: TFunction): CommandItem[] { - return [ - { - id: "settings-agents", - label: t("common:commandAgentsSettings"), - group: t(GROUP_SETTINGS), - icon: , - keywords: searchKeywords(t, "common:commandAgentsSettingsKeywords"), - action: () => push("/settings/agents"), - }, - { - id: "settings-executors", - label: t("common:commandExecutorsSettings"), - group: t(GROUP_SETTINGS), - icon: , - keywords: searchKeywords(t, "common:commandExecutorsSettingsKeywords"), - action: () => push("/settings/executors"), - }, - { - id: "settings-workspace", - label: t("common:commandWorkspaceSettings"), - group: t(GROUP_SETTINGS), - icon: , - keywords: searchKeywords(t, "common:commandWorkspaceSettingsKeywords"), - action: () => push("/settings/workspace"), - }, - { - id: "settings-prompts", - label: t("common:commandPromptsSettings"), - group: t(GROUP_SETTINGS), - icon: , - keywords: searchKeywords(t, "common:commandPromptsSettingsKeywords"), - action: () => push("/settings/prompts"), - }, - ]; -} - function buildThemeCommand( resolvedTheme: string | undefined, setTheme: (theme: string) => void, @@ -207,7 +162,6 @@ export function GlobalCommands() { const commands = useMemo( () => [ ...navigationCommands, - ...buildSettingsCommands(router.push, t), buildThemeCommand(resolvedTheme, setTheme, t), quickChatCommand, configChatCommand, @@ -231,5 +185,5 @@ export function GlobalCommands() { useAppShortcuts(); usePluginShortcuts(); - return null; + return ; } diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx index d7b1b4e2c0..a9d9189141 100644 --- a/apps/web/components/jira/jira-settings.tsx +++ b/apps/web/components/jira/jira-settings.tsx @@ -41,6 +41,7 @@ import { secretCopy, secretPlaceholder, } from "@/components/jira/jira-secret-help"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; // Cloud sites live under this domain; shown as an example, not as prose, so it // is interpolated rather than written into the catalog. @@ -568,6 +569,7 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string }) return ( } title={t("jira:jiraIntegration")} description={t("jira:connectThisWorkspaceToAtlassianCloud")} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index b9d5318bc3..daed66a966 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -32,6 +32,7 @@ import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; type FormState = { defaultTeamKey: string; @@ -439,6 +440,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string } return ( } title={t("linear:linearIntegration")} description={t("linear:linearIntegrationDescription")} diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx index e90ad0eb73..d03c54e609 100644 --- a/apps/web/components/sentry/sentry-settings.tsx +++ b/apps/web/components/sentry/sentry-settings.tsx @@ -21,6 +21,7 @@ import { SentryInstanceCard } from "./sentry-instance-card"; import { SentryInstanceForm } from "./sentry-instance-form"; import { SentryIssueWatchersSection } from "./sentry-issue-watchers-section"; import { useTranslation } from "react-i18next"; +import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; // EditMode is the mutually-exclusive form state: at most one add-or-edit form // is open at a time. @@ -189,6 +190,7 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string } return ( } title={t("sentry:sentryIntegration")} description={t("sentry:sentryIntegrationDescription")} diff --git a/apps/web/components/settings-discovery-commands.test.ts b/apps/web/components/settings-discovery-commands.test.ts new file mode 100644 index 0000000000..3947329ae7 --- /dev/null +++ b/apps/web/components/settings-discovery-commands.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedSettingsDiscoveryItem } from "@/lib/settings-discovery/types"; +import { buildSettingsDiscoveryCommands } from "./settings-discovery-commands"; + +function item(overrides: Partial = {}) { + return { + id: "terminal-font-size", + kind: "control" as const, + label: "Terminal Font Size", + aliases: ["text size"], + breadcrumb: ["General", "Terminal"], + groupId: "general", + groupLabel: "General", + href: "/settings/general/terminal#setting-terminal-font-size", + targetId: "setting-terminal-font-size", + order: 31, + ...overrides, + }; +} + +describe("buildSettingsDiscoveryCommands", () => { + it("creates search-only commands with stable ids, aliases, and display-only context", () => { + const [command] = buildSettingsDiscoveryCommands([item()], vi.fn(), "Settings"); + + expect(command).toMatchObject({ + id: "setting:terminal-font-size", + label: "Terminal Font Size", + group: "Settings", + context: "Settings › General › Terminal", + searchOnly: true, + keywords: ["text size", "General", "Terminal"], + }); + }); + + it("retains legacy command ids and aliases for existing settings pages", () => { + const [command] = buildSettingsDiscoveryCommands( + [item({ id: "agents", kind: "page", label: "Agents", aliases: [], breadcrumb: [] })], + vi.fn(), + "Settings", + { agents: ["agent settings", "agent profiles"] }, + ); + + expect(command.id).toBe("settings-agents"); + expect(command.keywords).toEqual(["agent settings", "agent profiles"]); + }); + + it("navigates to the catalog target", () => { + window.history.replaceState({}, "", "/settings/general/appearance"); + const push = vi.fn(); + const [command] = buildSettingsDiscoveryCommands([item()], push, "Settings"); + + command.action?.(); + + expect(push).toHaveBeenCalledWith("/settings/general/terminal#setting-terminal-font-size"); + }); +}); diff --git a/apps/web/components/settings-discovery-commands.tsx b/apps/web/components/settings-discovery-commands.tsx new file mode 100644 index 0000000000..cf9d5e0c56 --- /dev/null +++ b/apps/web/components/settings-discovery-commands.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettingsDiscovery } from "@/hooks/domains/settings/use-settings-discovery"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useRegisterCommands } from "@/hooks/use-register-commands"; +import { useCommandPanelOpen } from "@/lib/commands/command-registry"; +import type { CommandItem } from "@/lib/commands/types"; +import { useRouter } from "@/lib/routing/client-router"; +import { navigateToSettingsDiscovery } from "@/lib/settings-discovery/navigation"; +import type { ResolvedSettingsDiscoveryItem } from "@/lib/settings-discovery/types"; + +type Push = ReturnType["push"]; +type LegacyKeywords = Partial>; + +const LEGACY_COMMAND_IDS: Record = { + agents: "settings-agents", + executors: "settings-executors", + workspaces: "settings-workspace", + prompts: "settings-prompts", +}; + +export function buildSettingsDiscoveryCommands( + items: ResolvedSettingsDiscoveryItem[], + push: Push, + settingsGroupLabel: string, + legacyKeywords: LegacyKeywords = {}, +): CommandItem[] { + return items.map((item) => ({ + id: LEGACY_COMMAND_IDS[item.id] ?? `setting:${item.id}`, + label: item.label, + group: settingsGroupLabel, + context: [settingsGroupLabel, ...item.breadcrumb].join(" › "), + keywords: unique([...item.aliases, ...item.breadcrumb, ...(legacyKeywords[item.id] ?? [])]), + searchOnly: true, + priority: 80, + action: () => navigateToSettingsDiscovery(item.href, push), + })); +} + +export function SettingsDiscoveryCommands() { + const { t } = useTranslation(); + const router = useRouter(); + const { open, mode } = useCommandPanelOpen(); + useSettingsData(open && mode === "commands"); + const items = useSettingsDiscovery(); + const settingsGroupLabel = t("common:commandGroupSettings"); + const commands = useMemo( + () => buildSettingsDiscoveryCommands(items, router.push, settingsGroupLabel), + [items, router.push, settingsGroupLabel], + ); + + useRegisterCommands(commands); + return null; +} + +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/apps/web/components/settings/account/security-settings.tsx b/apps/web/components/settings/account/security-settings.tsx index d01ec3f9f8..6f6b30a785 100644 --- a/apps/web/components/settings/account/security-settings.tsx +++ b/apps/web/components/settings/account/security-settings.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Spinner } from "@kandev/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; @@ -16,6 +16,8 @@ import { revokeSession, type AuthSession, } from "@/lib/api/domains/auth-api"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { ACCOUNT_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/account"; import { formatDateTime } from "@/lib/i18n/formats"; function ChangePasswordCard() { @@ -44,7 +46,10 @@ function ChangePasswordCard() { }; return ( - + {t("account:password")} @@ -97,7 +102,7 @@ function ChangePasswordCard() { - + ); } @@ -135,7 +140,10 @@ function SessionsCard() { }; return ( - + {t("account:activeSessions")} @@ -194,7 +202,7 @@ function SessionsCard() { )} - + ); } diff --git a/apps/web/components/settings/agent-generated-task-title-settings.tsx b/apps/web/components/settings/agent-generated-task-title-settings.tsx index 62824b9586..cafbd2f15b 100644 --- a/apps/web/components/settings/agent-generated-task-title-settings.tsx +++ b/apps/web/components/settings/agent-generated-task-title-settings.tsx @@ -8,6 +8,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"; export function AgentGeneratedTaskTitleSettings() { @@ -43,7 +44,11 @@ export function AgentGeneratedTaskTitleSettings() { }); return ( - + {t("settings:agentGeneratedTaskTitles")} {t("settings:agentGeneratedTaskTitlesDescription")} diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index abd3a0e2fe..4fd32b89ed 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -52,6 +52,7 @@ import { ProfileMcpConfigCard } from "@/app/settings/agents/[agentId]/profile-mc import { CommandPreviewCard } from "@/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card"; import type { AgentProfileMcpConfig } from "@/lib/types/http"; import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings"; +import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; type ProfileEditorProps = { agent: Agent; @@ -143,7 +144,10 @@ function ProfileSettingsCard({ const savedPermissionValues = profilePermissionValues(savedProfile, permissionSettings); return ( - + {t("agents:profileSettings")} @@ -270,12 +274,14 @@ function ProfileEditorBody({ baselineFlags={savedProfile.cliFlags ?? []} onChange={(next) => updateDraft({ cliFlags: next })} permissionSettings={permissionSettings} + discoveryTargetId={agentProfileDiscoveryTarget(draft.id, "cli-flags")} /> + {t("settings:transcriptNavigation")} diff --git a/apps/web/components/settings/archive-confirmation-settings.tsx b/apps/web/components/settings/archive-confirmation-settings.tsx index ea8b4210f4..e355bcfdff 100644 --- a/apps/web/components/settings/archive-confirmation-settings.tsx +++ b/apps/web/components/settings/archive-confirmation-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"; @@ -42,7 +43,11 @@ export function ArchiveConfirmationSettings() { }); return ( - + {t("settings:archiveConfirmation")} diff --git a/apps/web/components/settings/cli-flags-field.tsx b/apps/web/components/settings/cli-flags-field.tsx index 311e62d469..c31ad390dc 100644 --- a/apps/web/components/settings/cli-flags-field.tsx +++ b/apps/web/components/settings/cli-flags-field.tsx @@ -413,6 +413,7 @@ type CustomCLIFlagsCardProps = { baselineFlags?: CLIFlag[]; onChange: (next: CLIFlag[]) => void; permissionSettings?: Record; + discoveryTargetId?: string; }; /** @@ -426,6 +427,7 @@ export function CustomCLIFlagsCard({ baselineFlags, onChange, permissionSettings, + discoveryTargetId, }: CustomCLIFlagsCardProps) { const { t } = useTranslation(); const curatedFlagTexts = useMemo( @@ -450,7 +452,11 @@ export function CustomCLIFlagsCard({ const onAdd = (next: CLIFlag) => onChange([...flags, next]); return ( - +
diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index e8eeffc6e2..244c73b183 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -7,6 +7,8 @@ import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { Textarea } from "@kandev/ui/textarea"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; +import { SettingsTarget } from "@/components/settings/settings-target"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; import { Combobox, type ComboboxOption } from "@/components/combobox"; import { EditableCard } from "@/components/settings/editable-card"; import { LspStatusLocationSetting } from "@/components/settings/lsp-status-location-setting"; @@ -447,7 +449,7 @@ export function EditorsSettings() { onSave={() => saveDefaultRequest.run()} >
-
+
{t("settings:fileEditor")}
@@ -472,7 +474,7 @@ export function EditorsSettings() { setExpandedConfigLang={state.setExpandedConfigLang} updateLspConfigString={updateLspConfigString} /> -
+ } title={t("settings:externalMcpEndpoints")} description={t("settings:externalMcpEndpointsDescription")} @@ -232,6 +234,7 @@ function SnippetsSection({ return ( } title={t("settings:externalMcpSnippets")} description={t("settings:externalMcpSnippetsDescription")} diff --git a/apps/web/components/settings/general-nav.ts b/apps/web/components/settings/general-nav.ts index 8957100145..df918f54e0 100644 --- a/apps/web/components/settings/general-nav.ts +++ b/apps/web/components/settings/general-nav.ts @@ -10,6 +10,7 @@ import { IconTerminal2, } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { GENERAL_DISCOVERY_DEFINITIONS } from "@/lib/settings-discovery/catalog/general"; export type GeneralNavItem = { href: string; @@ -18,57 +19,64 @@ export type GeneralNavItem = { icon: TablerIcon; }; -export const GENERAL_NAV_ITEMS: GeneralNavItem[] = [ +const GENERAL_NAV_PRESENTATION: Array< + Pick & { id: string } +> = [ { - href: "/settings/general/appearance", - labelKey: "settings:appearance", + id: "general-appearance", descriptionKey: "settings:themeMetricsAndChangesPanelPreferences", icon: IconPalette, }, { - href: "/settings/general/layouts", - labelKey: "settings:layouts", + id: "general-layouts", descriptionKey: "settings:taskWorkbenchLayoutProfilesAndDefaults", icon: IconLayoutDashboard, }, { - href: "/settings/general/terminal", - labelKey: "settings:terminal", + id: "general-terminal", descriptionKey: "settings:shellTerminalFontsAndLinkBehavior", icon: IconTerminal2, }, { - href: "/settings/general/notifications", - labelKey: "settings:notifications", + id: "general-notifications", descriptionKey: "settings:providersAndNotificationEvents", icon: IconBell, }, { - href: "/settings/general/editors", - labelKey: "settings:editors", + id: "general-editors", descriptionKey: "settings:editorIntegrationsAndDefaults", icon: IconCode, }, { - href: "/settings/general/keyboard-shortcuts", - labelKey: "settings:keyboardShortcuts", + id: "general-keyboard-shortcuts", descriptionKey: "settings:chatInputAndCommandShortcuts", icon: IconCommand, }, { - href: "/settings/general/task-actions", - labelKey: "settings:taskActions", + id: "general-task-actions", descriptionKey: "settings:mcpTaskDefaultsAndArchiveSafeguards", icon: IconArchive, }, { - href: "/settings/general/message-queue", - labelKey: "system:messageQueueTitle", + id: "general-message-queue", descriptionKey: "system:messageQueueDescription", icon: IconMessage, }, ]; +const generalPages = new Map(GENERAL_DISCOVERY_DEFINITIONS.map((item) => [item.id, item])); + +export const GENERAL_NAV_ITEMS: GeneralNavItem[] = GENERAL_NAV_PRESENTATION.map((item) => { + const page = generalPages.get(item.id); + if (!page?.labelKey) throw new Error(`Missing General discovery page: ${item.id}`); + return { + href: page.href, + labelKey: page.labelKey, + descriptionKey: item.descriptionKey, + icon: item.icon, + }; +}); + /** A nav item with its copy already translated, ready to render. */ export type ResolvedGeneralNavItem = { href: string; diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 5d8d58d20f..614ebeff49 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -37,6 +37,7 @@ import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides" import { buildPluginShortcutEntries } from "@/lib/keyboard/plugin-shortcuts"; import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; function ThemeSettingsCard({ theme, @@ -49,7 +50,11 @@ function ThemeSettingsCard({ }) { const { t } = useTranslation(); return ( - + {t("settings:colorTheme")} @@ -82,7 +87,11 @@ function ChatSubmitKeyCard({ }) { const { t } = useTranslation(); return ( - + {t("settings:submitShortcut")} @@ -120,7 +129,11 @@ function ChangesPanelLayoutCard({ }) { const { t } = useTranslation(); return ( - + {t("settings:changesPanelLayout")} diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx index 9904ba4103..b3b13a5f23 100644 --- a/apps/web/components/settings/keyboard-shortcuts-card.tsx +++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx @@ -25,6 +25,7 @@ import { type ShortcutConflictGroup, } from "@/lib/keyboard/shortcut-conflicts"; import { SettingsCard } from "./settings-card"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; import { useTranslation } from "react-i18next"; type ShortcutRecorderProps = { @@ -228,6 +229,8 @@ function buildConflictLabels(groups: ShortcutConflictGroup[]): Map + {t("settings:keyboardShortcuts")}
- {ids.map((id) => ( + {CONFIGURABLE_SHORTCUT_IDS.map((id) => ( - + {/* Card only supplies vertical padding; the horizontal px-4 comes from CardContent, which every other settings card here goes through. */} diff --git a/apps/web/components/settings/layouts/layout-settings.tsx b/apps/web/components/settings/layouts/layout-settings.tsx index d40ea17c65..ac957e3c34 100644 --- a/apps/web/components/settings/layouts/layout-settings.tsx +++ b/apps/web/components/settings/layouts/layout-settings.tsx @@ -28,6 +28,8 @@ import { LayoutEditor } from "./layout-editor"; import { LayoutProfileList } from "./layout-profile-list"; import { useLayoutSettings } from "./use-layout-settings"; import { useTranslation } from "react-i18next"; +import { SettingsTarget } from "@/components/settings/settings-target"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; type Controller = ReturnType; @@ -235,7 +237,11 @@ export function LayoutSettings() { discard: controller.cancel, }); return ( -
+ {controller.error && ( @@ -261,6 +267,6 @@ export function LayoutSettings() {
- + ); } diff --git a/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx b/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx index 0964f4ea7a..803f09e4f0 100644 --- a/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx +++ b/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx @@ -10,6 +10,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; import type { MCPTaskAgentProfileDefault } from "@/lib/types/http"; import { SettingsCard } from "./settings-card"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; import { useSettingsSaveContributor } from "./settings-save-provider"; import { Trans, useTranslation } from "react-i18next"; @@ -124,7 +125,11 @@ export function MCPTaskAgentProfileDefaultSettings() { }); return ( - +

{t("settings:profileForTasksCreatedByAgents")}

diff --git a/apps/web/components/settings/notification-permission-section.tsx b/apps/web/components/settings/notification-permission-section.tsx index 2392c1deb2..b59a28697b 100644 --- a/apps/web/components/settings/notification-permission-section.tsx +++ b/apps/web/components/settings/notification-permission-section.tsx @@ -9,6 +9,8 @@ import { type NativeNotificationPermission, } from "@/lib/desktop/native-notification-client"; import type { PermissionRefresh } from "./notifications-settings-actions"; +import { SettingsTarget } from "./settings-target"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; type NotificationPermissionState = | NativeNotificationPermission @@ -42,7 +44,7 @@ export function DesktopNotificationsSection({ }: DesktopNotificationsSectionProps) { const { t } = useTranslation(); return ( -
+
{t("settings:desktopNotifications")}
@@ -117,7 +119,7 @@ export function DesktopNotificationsSection({ {notificationPermission === "error" && (

{t("settings:notificationPermissionCheckFailed")}

)} -
+ ); } diff --git a/apps/web/components/settings/notification-sound-section.tsx b/apps/web/components/settings/notification-sound-section.tsx index 631ca37bdb..f67c011ca8 100644 --- a/apps/web/components/settings/notification-sound-section.tsx +++ b/apps/web/components/settings/notification-sound-section.tsx @@ -17,6 +17,8 @@ import { type SoundPresetId, } from "@/lib/notifications/sound"; import { useSettingsSaveContributor } from "./settings-save-provider"; +import { SettingsTarget } from "./settings-target"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; /** * Catalog keys for the preset names, keyed by the preset id — the id is the @@ -56,7 +58,8 @@ export function NotificationSoundSection({ }); return ( -
)} -
+
); } diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index 52b2da9913..dffaf4dca0 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -25,6 +25,8 @@ import { type NotificationsState, type AppriseFormMode, } from "@/components/settings/notifications-settings-actions"; +import { SettingsTarget } from "@/components/settings/settings-target"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; function AppriseProviderCardActions({ provider, @@ -211,7 +213,7 @@ function ExternalProvidersSection({ }: ExternalProvidersSectionProps) { const { t } = useTranslation(); return ( -
+
{t("settings:externalProviders")}

@@ -285,7 +287,7 @@ function ExternalProvidersSection({ )}

)} -
+ ); } @@ -410,7 +412,7 @@ export function NotificationsSettings() { onTextareaInput={handleTextareaInput} /> -
+
{t("settings:notificationEvents")}

@@ -426,7 +428,7 @@ export function NotificationsSettings() { onTestProvider={actions.handleTestProvider} /> )} -

+
); } diff --git a/apps/web/components/settings/profile-edit/env-vars-card.tsx b/apps/web/components/settings/profile-edit/env-vars-card.tsx index 343c530b8f..617a0e1156 100644 --- a/apps/web/components/settings/profile-edit/env-vars-card.tsx +++ b/apps/web/components/settings/profile-edit/env-vars-card.tsx @@ -297,6 +297,7 @@ type EnvVarsFieldProps = { onAdd: (row: EnvVarRow) => void; onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void; onRemove: (index: number) => void; + discoveryTargetId?: string; }; function EnvVarsFieldBody({ @@ -358,7 +359,11 @@ export function EnvVarsCard(props: EnvVarsFieldProps) { props.baselineRows !== undefined && JSON.stringify(rowsToEnvVars(props.rows)) !== JSON.stringify(rowsToEnvVars(props.baselineRows)); return ( - +
diff --git a/apps/web/components/settings/profile-edit/mcp-policy-card.tsx b/apps/web/components/settings/profile-edit/mcp-policy-card.tsx index 630a484f21..58da8abecd 100644 --- a/apps/web/components/settings/profile-edit/mcp-policy-card.tsx +++ b/apps/web/components/settings/profile-edit/mcp-policy-card.tsx @@ -50,6 +50,7 @@ type McpPolicyCardProps = { baselinePolicy?: string; mcpPolicyErrorKey: string | null; onPolicyChange: (value: string) => void; + discoveryTargetId?: string; }; export function McpPolicyCard({ @@ -57,6 +58,7 @@ export function McpPolicyCard({ baselinePolicy, mcpPolicyErrorKey, onPolicyChange, + discoveryTargetId, }: McpPolicyCardProps) { const { t } = useTranslation(); const isDirty = baselinePolicy !== undefined && mcpPolicy !== baselinePolicy; @@ -67,7 +69,7 @@ export function McpPolicyCard({ }; return ( - + {t("executors:mcpPolicy")} diff --git a/apps/web/components/settings/profile-edit/profile-details-card.tsx b/apps/web/components/settings/profile-edit/profile-details-card.tsx index 4ed0fae03f..d48028975b 100644 --- a/apps/web/components/settings/profile-edit/profile-details-card.tsx +++ b/apps/web/components/settings/profile-edit/profile-details-card.tsx @@ -10,13 +10,19 @@ type ProfileDetailsCardProps = { name: string; baselineName?: string; onNameChange: (v: string) => void; + discoveryTargetId?: string; }; -export function ProfileDetailsCard({ name, baselineName, onNameChange }: ProfileDetailsCardProps) { +export function ProfileDetailsCard({ + name, + baselineName, + onNameChange, + discoveryTargetId, +}: ProfileDetailsCardProps) { const { t } = useTranslation(); const isDirty = baselineName !== undefined && name.trim() !== baselineName.trim(); return ( - + {t("executors:profileDetails")} diff --git a/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx b/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx index 6c11acd433..669e8df8ed 100644 --- a/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx +++ b/apps/web/components/settings/profile-edit/profile-env-vars-section.tsx @@ -27,6 +27,7 @@ type ProfileEnvVarsEditorProps = { baselineEnvVars?: ProfileEnvVar[]; secrets: { id: string; name: string }[]; onChange: (envVars: ProfileEnvVar[]) => void; + discoveryTargetId?: string; }; export function ProfileEnvVarsEditor({ @@ -34,6 +35,7 @@ export function ProfileEnvVarsEditor({ baselineEnvVars, secrets, onChange, + discoveryTargetId, }: ProfileEnvVarsEditorProps) { // `synced` is what we've acknowledged from the parent (either via the prop // or our own last emission). When the prop diverges from it we re-seed @@ -88,6 +90,7 @@ export function ProfileEnvVarsEditor({ onAdd={handleAdd} onUpdate={handleUpdate} onRemove={handleRemove} + discoveryTargetId={discoveryTargetId} /> ); } @@ -96,12 +99,14 @@ type ProfileEnvVarsSectionProps = { envVars?: ProfileEnvVar[]; baselineEnvVars?: ProfileEnvVar[]; onChange: (patch: Partial) => void; + discoveryTargetId?: string; }; export function ProfileEnvVarsSection({ envVars, baselineEnvVars, onChange, + discoveryTargetId, }: ProfileEnvVarsSectionProps) { const { items: secrets } = useSecrets(); const handleChange = useCallback( @@ -115,6 +120,7 @@ export function ProfileEnvVarsSection({ baselineEnvVars={baselineEnvVars} secrets={secrets} onChange={handleChange} + discoveryTargetId={discoveryTargetId} /> ); } diff --git a/apps/web/components/settings/profile-edit/profile-script-cards.tsx b/apps/web/components/settings/profile-edit/profile-script-cards.tsx new file mode 100644 index 0000000000..00db40769c --- /dev/null +++ b/apps/web/components/settings/profile-edit/profile-script-cards.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { ScriptCard } from "@/components/settings/profile-edit/script-card"; +import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api"; +import { executorProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; +import type { ExecutorType } from "@/lib/types/http"; + +type ProfileScripts = { + isRemote: boolean; + prepareDescription: string; + prepareValue: string; + prepareBaseline: string; + onPrepareChange: (value: string) => void; + cleanupValue: string; + cleanupBaseline: string; + onCleanupChange: (value: string) => void; + placeholders: ScriptPlaceholder[]; +}; + +export function ProfileScriptCards({ + executorType, + profileId, + scripts, +}: { + executorType: ExecutorType; + profileId: string; + scripts: ProfileScripts; +}) { + const { t } = useTranslation(); + return ( + <> + + {scripts.isRemote && ( + + )} + + ); +} diff --git a/apps/web/components/settings/profile-edit/script-card.tsx b/apps/web/components/settings/profile-edit/script-card.tsx index d738385d54..14dee08133 100644 --- a/apps/web/components/settings/profile-edit/script-card.tsx +++ b/apps/web/components/settings/profile-edit/script-card.tsx @@ -15,6 +15,7 @@ type ScriptCardProps = { height?: string; placeholders: ScriptPlaceholder[]; executorType: string; + discoveryTargetId?: string; }; export function ScriptCard({ @@ -26,6 +27,7 @@ export function ScriptCard({ height = "300px", placeholders, executorType, + discoveryTargetId, }: ScriptCardProps) { const containerRef = useRef(null); const [editorHeight, setEditorHeight] = useState(height); @@ -46,7 +48,7 @@ export function ScriptCard({ const isDirty = baselineValue !== undefined && value !== baselineValue; return ( - + {title} {description} diff --git a/apps/web/components/settings/settings-card.tsx b/apps/web/components/settings/settings-card.tsx index 609bde7105..c1d230f94f 100644 --- a/apps/web/components/settings/settings-card.tsx +++ b/apps/web/components/settings/settings-card.tsx @@ -1,10 +1,39 @@ -import type { ComponentProps } from "react"; +"use client"; + +import { useCallback, type ComponentProps, type Ref } from "react"; import { Card } from "@kandev/ui/card"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; type SettingsCardProps = ComponentProps & { isDirty?: boolean; + discoveryTargetId?: string; }; -export function SettingsCard({ isDirty = false, ...props }: SettingsCardProps) { - return ; +export function SettingsCard({ + isDirty = false, + discoveryTargetId, + ref, + ...props +}: SettingsCardProps) { + const registerTarget = useSettingsTargetRegistration(discoveryTargetId); + const mergedRef = useCallback( + (element: HTMLDivElement | null) => { + registerTarget(element); + assignRef(ref, element); + }, + [ref, registerTarget], + ); + return ( + + ); +} + +function assignRef(ref: Ref | undefined, element: HTMLDivElement | null): void { + if (typeof ref === "function") ref(element); + else if (ref) ref.current = element; } diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index f977ba64f5..7196e87377 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -15,6 +15,7 @@ import { IntegrationCopyConfigMenu } from "@/components/integrations/integration import { integrationFromPathname } from "@/components/integrations/integration-copy-config"; import { safeDecodePathSegment } from "@/lib/routing/path"; import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; +import { SettingsTargetProvider } from "@/components/settings/settings-target-provider"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item"; @@ -320,14 +321,14 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) { )} setOpen(false)} > {t("settings:home")}
@@ -363,26 +364,28 @@ function SettingsShell({ return ( -
- } - showStatusTrigger={false} - className="h-10" - actions={showIntegrationCopyAction ? : undefined} - /> - {/* Scroll the content, not the topbar: min-h-0 lets this flex child + +
+ } + showStatusTrigger={false} + className="h-10" + actions={showIntegrationCopyAction ? : undefined} + /> + {/* Scroll the content, not the topbar: min-h-0 lets this flex child shrink below its content height so overflow-y-auto can take effect. */} -
- {children} -
-
+
+ {children} +
+
+
); diff --git a/apps/web/components/settings/settings-section.tsx b/apps/web/components/settings/settings-section.tsx index 04e0946e11..f796bf2505 100644 --- a/apps/web/components/settings/settings-section.tsx +++ b/apps/web/components/settings/settings-section.tsx @@ -1,4 +1,7 @@ +"use client"; + import type { ReactNode } from "react"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; type SettingsSectionProps = { icon?: ReactNode; @@ -7,6 +10,7 @@ type SettingsSectionProps = { description?: string; action?: ReactNode; children: ReactNode; + discoveryTargetId?: string; }; export function SettingsSection({ @@ -16,9 +20,11 @@ export function SettingsSection({ description, action, children, + discoveryTargetId, }: SettingsSectionProps) { + const registerTarget = useSettingsTargetRegistration(discoveryTargetId); return ( -
+
{/* Stack the action row under the title on narrow screens instead of squeezing both onto one line; shrink-0 keeps the actions from being compressed by a long description when side by side. */} diff --git a/apps/web/components/settings/settings-target-provider.test.tsx b/apps/web/components/settings/settings-target-provider.test.tsx new file mode 100644 index 0000000000..5e3ea458e1 --- /dev/null +++ b/apps/web/components/settings/settings-target-provider.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { createRef } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event"; +import { emitSettingsTargetRequest } from "@/lib/settings-discovery/target"; +import { SettingsTargetProvider } from "./settings-target-provider"; +import { SettingsTarget } from "./settings-target"; + +afterEach(() => { + cleanup(); + window.history.replaceState({}, "", "/"); + vi.restoreAllMocks(); +}); + +describe("SettingsTargetProvider", () => { + it("preserves a caller-supplied ref while registering the discovery target", () => { + const callerRef = createRef(); + + const view = render( + + + Control + + , + ); + + expect(callerRef.current).toBe(view.getByText("Control")); + }); + + it("reveals an initial target after descendants register", async () => { + window.history.replaceState({}, "", "/settings/general/terminal#font-size"); + const reveal = vi.fn(); + + render( + + Control + , + ); + + await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1)); + expect((reveal.mock.calls[0][0] as HTMLElement).getAttribute("data-settings-target")).toBe( + "font-size", + ); + }); + + it("retains a route request until asynchronous content registers", async () => { + const reveal = vi.fn(); + const view = render( + + Loading + , + ); + window.history.replaceState({}, "", "/settings/general/terminal#late-control"); + window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); + + view.rerender( + + Loaded + , + ); + + await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1)); + }); + + it("handles repeated explicit requests and browser history", async () => { + const reveal = vi.fn(); + render( + + Control + , + ); + + emitSettingsTargetRequest("font-size"); + emitSettingsTargetRequest("font-size"); + window.history.replaceState({}, "", "/settings/general/terminal#font-size"); + window.dispatchEvent(new PopStateEvent("popstate")); + + await waitFor(() => expect(reveal).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/apps/web/components/settings/settings-target-provider.tsx b/apps/web/components/settings/settings-target-provider.tsx new file mode 100644 index 0000000000..5fbf3f91a6 --- /dev/null +++ b/apps/web/components/settings/settings-target-provider.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from "react"; +import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event"; +import { + SETTINGS_TARGET_REQUEST_EVENT, + createSettingsTargetRegistry, + revealSettingsTarget, + settingsTargetFromHash, + type SettingsTargetRegistry, + type SettingsTargetRequestDetail, +} from "@/lib/settings-discovery/target"; + +const SettingsTargetContext = createContext(null); + +export function SettingsTargetProvider({ + children, + revealTarget = revealSettingsTarget, +}: { + children: ReactNode; + revealTarget?: (element: HTMLElement) => void; +}) { + const registryRef = useRef(null); + if (!registryRef.current) registryRef.current = createSettingsTargetRegistry(revealTarget); + const registry = registryRef.current; + + useEffect(() => { + const requestHashTarget = () => { + const targetId = settingsTargetFromHash(window.location.hash); + if (targetId) registry.request(targetId); + }; + const requestExplicitTarget = (event: Event) => { + const targetId = (event as CustomEvent).detail?.targetId; + if (targetId) registry.request(targetId); + }; + + window.addEventListener(LOCATION_CHANGE_EVENT, requestHashTarget); + window.addEventListener("hashchange", requestHashTarget); + window.addEventListener("popstate", requestHashTarget); + window.addEventListener(SETTINGS_TARGET_REQUEST_EVENT, requestExplicitTarget); + requestHashTarget(); + + return () => { + window.removeEventListener(LOCATION_CHANGE_EVENT, requestHashTarget); + window.removeEventListener("hashchange", requestHashTarget); + window.removeEventListener("popstate", requestHashTarget); + window.removeEventListener(SETTINGS_TARGET_REQUEST_EVENT, requestExplicitTarget); + }; + }, [registry]); + + return ( + {children} + ); +} + +export function useSettingsTargetRegistration(targetId?: string) { + const registry = useContext(SettingsTargetContext); + const unregisterRef = useRef<(() => void) | null>(null); + + return useCallback( + (element: HTMLElement | null) => { + unregisterRef.current?.(); + unregisterRef.current = + element && targetId && registry ? registry.register(targetId, element) : null; + }, + [registry, targetId], + ); +} diff --git a/apps/web/components/settings/settings-target-surfaces.test.tsx b/apps/web/components/settings/settings-target-surfaces.test.tsx new file mode 100644 index 0000000000..6cfb99347e --- /dev/null +++ b/apps/web/components/settings/settings-target-surfaces.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { emitSettingsTargetRequest } from "@/lib/settings-discovery/target"; +import { SettingsCard } from "./settings-card"; +import { SettingsSection } from "./settings-section"; +import { SettingsTargetProvider } from "./settings-target-provider"; + +afterEach(cleanup); + +describe("settings target surfaces", () => { + it("registers a card without adding a layout wrapper", async () => { + const reveal = vi.fn(); + const { container } = render( + + Card + , + ); + + emitSettingsTargetRequest("card-control"); + + await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1)); + expect((reveal.mock.calls[0][0] as HTMLElement).getAttribute("data-slot")).toBe("card"); + expect(container.children).toHaveLength(1); + expect(container.firstElementChild?.getAttribute("data-slot")).toBe("card"); + }); + + it("registers a section as a stable target", async () => { + const reveal = vi.fn(); + render( + + + Content + + , + ); + + emitSettingsTargetRequest("section-control"); + + await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1)); + expect((reveal.mock.calls[0][0] as HTMLElement).tagName).toBe("SECTION"); + }); +}); diff --git a/apps/web/components/settings/settings-target.tsx b/apps/web/components/settings/settings-target.tsx new file mode 100644 index 0000000000..92f47f0552 --- /dev/null +++ b/apps/web/components/settings/settings-target.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useCallback, type ComponentProps, type Ref } from "react"; +import { useSettingsTargetRegistration } from "./settings-target-provider"; + +type SettingsTargetProps = ComponentProps<"div"> & { + targetId: string; +}; + +export function SettingsTarget({ targetId, ref, ...props }: SettingsTargetProps) { + const registerTarget = useSettingsTargetRegistration(targetId); + const mergedRef = useCallback( + (element: HTMLDivElement | null) => { + registerTarget(element); + assignRef(ref, element); + }, + [ref, registerTarget], + ); + return
; +} + +function assignRef(ref: Ref | undefined, element: HTMLDivElement | null): void { + if (typeof ref === "function") ref(element); + else if (ref) ref.current = element; +} diff --git a/apps/web/components/settings/shell-settings-card.tsx b/apps/web/components/settings/shell-settings-card.tsx index bfcb29460c..cdb262af7e 100644 --- a/apps/web/components/settings/shell-settings-card.tsx +++ b/apps/web/components/settings/shell-settings-card.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsCard } from "@/components/settings/settings-card"; import { useTranslation } from "react-i18next"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; const AUTO_SHELL = "auto"; const CUSTOM_SHELL = "custom"; @@ -124,7 +125,7 @@ export function ShellSettingsCard({ title={t("common:shell")} description={t("settings:pickTheDefaultShellForTask")} > - + {t("settings:preferredShell")} diff --git a/apps/web/components/settings/sprites-settings.tsx b/apps/web/components/settings/sprites-settings.tsx index 87c2b46e28..c30cacc2d9 100644 --- a/apps/web/components/settings/sprites-settings.tsx +++ b/apps/web/components/settings/sprites-settings.tsx @@ -4,7 +4,7 @@ import { useState, useCallback } from "react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card"; +import { CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { IconTrash, @@ -23,6 +23,8 @@ import { } from "@/lib/api/domains/sprites-api"; import type { SpritesInstance, SpritesTestResult, SpritesTestStep } from "@/lib/types/http-sprites"; import { Trans, useTranslation } from "react-i18next"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; +import { SettingsCard } from "./settings-card"; const SPRITES_TOKEN_ENV_VAR = "SPRITES_API_TOKEN"; @@ -52,7 +54,7 @@ export function SpritesConnectionCard({ secretId }: { secretId?: string }) { }, [secretId, t]); return ( - +
@@ -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).