feat: add settings discovery (#2281)

This commit is contained in:
José Almeida
2026-08-05 14:07:52 +01:00
committed by GitHub
parent 9b3aa282a9
commit 56350ffc2b
117 changed files with 4840 additions and 385 deletions
+24
View File
@@ -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
@@ -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 (
<Card>
<SettingsCard discoveryTargetId={discoveryTargetId}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconTerminal2 className="h-5 w-5" />
@@ -210,6 +213,6 @@ export function CommandPreviewCard({
)}
{!loading && !error && !preview && <CommandPreviewEmpty />}
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -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<typeof useSecrets>["items"];
};
function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) {
function ExecutorSpecificSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) {
const gitIdentityBaseline = getGitIdentityBaseline(profile, form.localGitIdentity);
return (
<>
<ProfileDetailsCard
name={form.name}
baselineName={profile.name}
onNameChange={form.setName}
/>
{executor.type === "ssh" && (
<SSHAgentReadinessCard
executorId={executor.id}
@@ -478,6 +474,25 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
secrets={secrets}
/>
)}
</>
);
}
function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSectionsProps) {
return (
<>
<ProfileDetailsCard
name={form.name}
baselineName={profile.name}
onNameChange={form.setName}
discoveryTargetId={executorProfileDiscoveryTarget(profile.id, "profile-details")}
/>
<ExecutorSpecificSections
executor={executor}
profile={profile}
form={form}
secrets={secrets}
/>
<EnvVarsCard
rows={form.envVarRows}
baselineRows={envVarsToRows(profile.env_vars)}
@@ -485,54 +500,34 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
onAdd={form.addEnvVar}
onUpdate={form.updateEnvVar}
onRemove={form.removeEnvVar}
discoveryTargetId={executorProfileDiscoveryTarget(profile.id, "environment-variables")}
/>
<ProfileScriptCards
executorType={executor.type}
profileId={profile.id}
scripts={{
isRemote: form.isRemote,
prepareDescription: form.prepareDesc,
prepareValue: form.prepareScript,
prepareBaseline: profile.prepare_script ?? "",
onPrepareChange: form.setPrepareScript,
cleanupValue: form.cleanupScript,
cleanupBaseline: profile.cleanup_script ?? "",
onCleanupChange: form.setCleanupScript,
placeholders: form.placeholders,
}}
/>
<ProfileScriptCards executor={executor} profile={profile} form={form} />
<McpPolicyCard
mcpPolicy={form.mcpPolicy}
baselinePolicy={profile.mcp_policy ?? ""}
mcpPolicyErrorKey={form.mcpPolicyErrorKey}
onPolicyChange={form.setMcpPolicy}
discoveryTargetId={executorProfileDiscoveryTarget(profile.id, "mcp-policy")}
/>
</>
);
}
/** The prepare/cleanup script pair, split out to keep ProfileEditSections
* under the max-lines-per-function budget. */
function ProfileScriptCards({
executor,
profile,
form,
}: Omit<ProfileEditSectionsProps, "secrets">) {
const { t } = useTranslation();
return (
<>
<ScriptCard
title={t("executors:prepareScript")}
description={form.prepareDesc}
value={form.prepareScript}
baselineValue={profile.prepare_script ?? ""}
onChange={form.setPrepareScript}
height="300px"
placeholders={form.placeholders}
executorType={executor.type}
/>
{form.isRemote && (
<ScriptCard
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={form.cleanupScript}
baselineValue={profile.cleanup_script ?? ""}
onChange={form.setCleanupScript}
height="200px"
placeholders={form.placeholders}
executorType={executor.type}
/>
)}
</>
);
}
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
const { t } = useTranslation();
const router = useRouter();
@@ -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 (
<div className="space-y-2">
<SettingsTarget targetId={discoveryTargetId} className="space-y-2">
<Label>{label}</Label>
<Select value={value || "none"} onValueChange={(v) => onChange(v === "none" ? "" : v)}>
<SelectTrigger className="w-full" data-settings-dirty={isDirty}>
@@ -111,11 +115,12 @@ function SelectField({
)}
</SelectContent>
</Select>
</div>
</SettingsTarget>
);
}
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({
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<SettingsTarget
targetId={workspaceDiscoveryTarget(workspaceId, "name")}
className="space-y-2"
>
<Label htmlFor="workspace-name">{t("workspaces:name")}</Label>
<Input
id="workspace-name"
@@ -165,7 +174,7 @@ function WorkspaceSettingsCard({
data-settings-dirty={nameIsDirty}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
</SettingsTarget>
<SelectField
label={t("workspaces:defaultExecutor")}
placeholder={t("workspaces:selectDefaultExecutor")}
@@ -175,6 +184,7 @@ function WorkspaceSettingsCard({
options={executorsEmpty ? [] : executorOptions}
emptyLabel={t("workspaces:noExecutorsAvailable")}
emptyValue=""
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-executor")}
/>
<SelectField
label={t("workspaces:defaultAgentProfile")}
@@ -185,6 +195,7 @@ function WorkspaceSettingsCard({
options={profileOptions}
emptyLabel={t("workspaces:noAgentProfilesAvailable")}
emptyValue="empty-agent-profiles"
discoveryTargetId={workspaceDiscoveryTarget(workspaceId, "default-agent-profile")}
/>
</div>
</CardContent>
@@ -560,6 +571,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {
</div>
<Separator />
<WorkspaceSettingsCard
workspaceId={currentWorkspace.id}
workspaceNameDraft={workspaceNameDraft}
nameIsDirty={workspaceNameDraft.trim() !== savedState.name}
onNameChange={setWorkspaceNameDraft}
@@ -1,18 +1,27 @@
"use client";
import { IconKey, IconShieldLock, IconUserCircle } from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
ACCOUNT_DISCOVERY_DEFINITIONS,
ACCOUNT_SECURITY_SETTINGS_HREF,
ACCOUNT_SETTINGS_HREF,
} from "@/lib/settings-discovery/catalog/account";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/account";
const DEFAULT_HREF = `${ROOT_HREF}/security`;
const ACCOUNT_ITEM_ICONS: Record<string, TablerIcon> = {
"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
<SettingsGroup
label={t("sidebar:account")}
icon={IconUserCircle}
href={DEFAULT_HREF}
isActive={pathname.startsWith(ROOT_HREF)}
href={ACCOUNT_SECURITY_SETTINGS_HREF}
isActive={pathname.startsWith(ACCOUNT_SETTINGS_HREF)}
expanded={expanded}
onToggle={onToggle}
>
@@ -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)
<SettingsGroup
label={t("common:agents")}
icon={IconRobot}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
href={AGENTS_SETTINGS_HREF}
isActive={pathname === AGENTS_SETTINGS_HREF}
expanded={expanded}
onToggle={onToggle}
>
{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 (
<SettingsLeaf
@@ -4,10 +4,9 @@ import { IconCpu } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { getExecutorIcon } from "@/lib/executor-icons";
import { EXECUTORS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/executors";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/executors";
type ExecutorsGroupProps = {
pathname: string;
expanded?: boolean;
@@ -25,14 +24,14 @@ export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupP
<SettingsGroup
label={t("common:executors")}
icon={IconCpu}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
href={EXECUTORS_SETTINGS_HREF}
isActive={pathname === EXECUTORS_SETTINGS_HREF}
expanded={expanded}
onToggle={onToggle}
>
{allProfiles.map((profile) => {
const Icon = getExecutorIcon(profile.executorType);
const profilePath = `${ROOT_HREF}/${profile.id}`;
const profilePath = `${EXECUTORS_SETTINGS_HREF}/${profile.id}`;
return (
<SettingsLeaf
key={profile.id}
@@ -2,10 +2,9 @@
import { IconSettings } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGeneralNavItems } from "@/components/settings/general-nav";
import { GENERAL_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/general";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const GENERAL_HREF = "/settings/general";
type GeneralGroupProps = {
pathname: string;
expanded?: boolean;
@@ -19,8 +18,8 @@ export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps
<SettingsGroup
label={t("settings:general")}
icon={IconSettings}
href={GENERAL_HREF}
isActive={pathname === GENERAL_HREF}
href={GENERAL_SETTINGS_HREF}
isActive={pathname === GENERAL_SETTINGS_HREF}
expanded={expanded}
onToggle={onToggle}
>
@@ -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<HTMLElement>("[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(<SettingsSearch {...props} query="layout" />);
expect(mocks.animate).toHaveBeenCalledWith(
[
{ opacity: 0, transform: "translateY(2px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 140, easing: EASE_OUT },
);
mocks.animate.mockClear();
rerender(<SettingsSearch {...props} query="beta layout" />);
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(<SettingsSearch {...props} query="layout" />);
rerender(<SettingsSearch {...props} query="beta layout" />);
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();
});
});
@@ -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<HTMLInputElement>) => {
if (event.key !== "Escape" || query.length === 0) return;
event.preventDefault();
event.stopPropagation();
onQueryChange("");
};
return (
<>
<div
className="sticky top-0 z-10 bg-background pb-2 md:bg-sidebar md:pb-1.5"
data-testid="settings-search"
>
<div className="relative">
<IconSearch
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground md:left-2.5 md:size-3.5"
/>
<Input
type="search"
value={query}
aria-label={t("settings:searchSettings")}
placeholder={t("settings:searchSettings")}
autoComplete="off"
spellCheck={false}
onChange={(event: ChangeEvent<HTMLInputElement>) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
className="h-11 appearance-none bg-background pl-9 pr-11 text-base md:h-8 md:pl-8 md:pr-8 md:text-xs [&::-webkit-search-cancel-button]:hidden"
/>
{query && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("settings:clearSettingsSearch")}
onClick={() => onQueryChange("")}
className="absolute right-0 top-0 size-11 text-muted-foreground hover:text-foreground md:size-8"
>
<IconX className="size-4 md:size-3.5" />
</Button>
)}
</div>
</div>
{trimmedQuery && <SettingsSearchResults results={results} onSelect={onSelect} />}
</>
);
}
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 (
<div ref={motionRef}>
<p
className="flex min-h-11 items-center px-2.5 text-xs text-muted-foreground"
role="status"
>
{t("settings:noMatchingSettings")}
</p>
</div>
);
}
return (
<div ref={motionRef} data-testid="settings-search-results">
<p className="sr-only" role="status">
{t("settings:settingsSearchResults", { count: results.length })}
</p>
{groups.map((group) => (
<section key={group.id} aria-labelledby={`settings-search-group-${group.id}`}>
<h3
id={`settings-search-group-${group.id}`}
data-settings-search-motion-key={`group:${group.id}`}
className="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground"
>
{group.label}
</h3>
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<SettingsSearchResult key={item.id} item={item} onSelect={onSelect} />
))}
</div>
</section>
))}
</div>
);
}
function SettingsSearchResult({
item,
onSelect,
}: {
item: ResolvedSettingsDiscoveryItem;
onSelect: () => void;
}) {
const router = useRouter();
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (!isPlainPrimaryClick(event)) return;
event.preventDefault();
navigateToSettingsDiscovery(item.href, router.push);
onSelect();
};
return (
<Link
href={item.href}
onClick={handleClick}
data-settings-search-motion-key={`item:${item.id}`}
className="flex min-h-11 min-w-0 cursor-pointer flex-col justify-center rounded-md px-2.5 py-1.5 outline-none transition-colors hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-primary/40 motion-reduce:transition-none"
>
<span className="truncate text-[13px] font-medium text-foreground">{item.label}</span>
{item.breadcrumb.length > 0 && (
<span className="truncate text-[11px] leading-4 text-muted-foreground">
{item.breadcrumb.join(" › ")}
</span>
)}
</Link>
);
}
function groupResults(results: ResolvedSettingsDiscoveryItem[]): ResultGroup[] {
const groups = new Map<string, ResultGroup>();
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<HTMLAnchorElement>): boolean {
return (
event.button === 0 &&
!event.metaKey &&
!event.altKey &&
!event.ctrlKey &&
!event.shiftKey &&
(!event.currentTarget.target || event.currentTarget.target === "_self")
);
}
@@ -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(<SettingsTree pathname="/settings" />);
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(<SettingsTree pathname="/settings/voice-mode" />);
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(<SettingsTree pathname="/settings" />);
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(<SettingsTree pathname="/settings" />);
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(<SettingsTree pathname="/settings" />);
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);
@@ -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<string | null>(() =>
settingsOpenGroupIdForPath(pathname),
);
@@ -80,50 +86,60 @@ export function SettingsTree({ pathname }: { pathname: string }) {
return (
<>
<GeneralGroup pathname={pathname} {...groupProps("general")} />
<WorkspacesGroup pathname={pathname} {...groupProps("workspaces")} />
<AgentsGroup pathname={pathname} {...groupProps("agents")} />
<SettingsLeaf
href={PROMPTS_HREF}
label={t("common:prompts")}
icon={IconMessageCircle}
isActive={pathname === PROMPTS_HREF}
<SettingsSearch
items={discoveryItems}
query={query}
onQueryChange={setQuery}
onSelect={() => setQuery("")}
/>
<SettingsLeaf
href={VOICE_MODE_HREF}
label={t("settings:voiceMode")}
icon={IconMicrophone}
isActive={pathname === VOICE_MODE_HREF}
/>
<SettingsLeaf
href={UTILITY_HREF}
label={t("settings:utilityAgents")}
icon={IconWand}
isActive={pathname === UTILITY_HREF}
/>
<ExecutorsGroup pathname={pathname} {...groupProps("executors")} />
{/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */}
<SettingsLeaf
href={SECRETS_HREF}
label={t("settings:secrets")}
icon={IconKey}
isActive={pathname === SECRETS_HREF}
/>
<SettingsLeaf
href={EXT_MCP_HREF}
label={t("common:externalMcp")}
icon={IconPlugConnected}
isActive={pathname === EXT_MCP_HREF}
/>
<PluginSlot name="settings-nav" />
<SettingsLeaf
href={PLUGINS_HREF}
label={t("common:plugins")}
icon={IconPuzzle}
isActive={pathname === PLUGINS_HREF}
/>
<SystemGroup pathname={pathname} {...groupProps("system")} />
{showAccountGroup && <AccountGroup pathname={pathname} {...groupProps("account")} />}
{query.trim() ? null : (
<>
<GeneralGroup pathname={pathname} {...groupProps("general")} />
<WorkspacesGroup pathname={pathname} {...groupProps("workspaces")} />
<AgentsGroup pathname={pathname} {...groupProps("agents")} />
<SettingsLeaf
href={PROMPTS_SETTINGS_HREF}
label={t("common:prompts")}
icon={IconMessageCircle}
isActive={pathname === PROMPTS_SETTINGS_HREF}
/>
<SettingsLeaf
href={VOICE_MODE_SETTINGS_HREF}
label={t("settings:voiceMode")}
icon={IconMicrophone}
isActive={pathname === VOICE_MODE_SETTINGS_HREF}
/>
<SettingsLeaf
href={UTILITY_AGENTS_SETTINGS_HREF}
label={t("settings:utilityAgents")}
icon={IconWand}
isActive={pathname === UTILITY_AGENTS_SETTINGS_HREF}
/>
<ExecutorsGroup pathname={pathname} {...groupProps("executors")} />
{/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */}
<SettingsLeaf
href={SECRETS_HREF}
label={t("settings:secrets")}
icon={IconKey}
isActive={pathname === SECRETS_HREF}
/>
<SettingsLeaf
href={EXTERNAL_MCP_SETTINGS_HREF}
label={t("common:externalMcp")}
icon={IconPlugConnected}
isActive={pathname === EXTERNAL_MCP_SETTINGS_HREF}
/>
<PluginSlot name="settings-nav" />
<SettingsLeaf
href={PLUGINS_SETTINGS_HREF}
label={t("common:plugins")}
icon={IconPuzzle}
isActive={pathname === PLUGINS_SETTINGS_HREF}
/>
<SystemGroup pathname={pathname} {...groupProps("system")} />
{showAccountGroup && <AccountGroup pathname={pathname} {...groupProps("account")} />}
</>
)}
</>
);
}
@@ -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<string, TablerIcon> = {
"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)
<SettingsGroup
label={t("common:system")}
icon={IconServerCog}
href={DEFAULT_HREF}
isActive={pathname.startsWith(ROOT_HREF)}
href={SYSTEM_STATUS_SETTINGS_HREF}
isActive={pathname.startsWith(SYSTEM_SETTINGS_HREF)}
expanded={expanded}
onToggle={onToggle}
>
@@ -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<HTMLDivElement>(null);
const previousPositionsRef = useRef(new Map<string, number>());
const animationsRef = useRef(new Map<string, Animation>());
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<HTMLElement>(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<string, Animation>, activeKeys: Set<string>) {
for (const [key, animation] of animations) {
if (activeKeys.has(key)) continue;
animation.cancel();
animations.delete(key);
}
}
function cancelAnimations(animations: Map<string, Animation>) {
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
);
}
@@ -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 (
<SettingsLeaf
@@ -94,7 +94,7 @@ function WorkspaceIntegrationItems({
href={href}
label={label}
labelSuffix={enabled.has(slug) ? <EnabledBadge /> : 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
<SettingsGroup
label={t("common:workspaces")}
icon={IconFolder}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
href={WORKSPACES_SETTINGS_HREF}
isActive={pathname === WORKSPACES_SETTINGS_HREF}
expanded={expanded}
onToggle={onToggle}
>
{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`;
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS["azure-devops"]}
icon={<IconBrandAzure className="h-5 w-5" />}
title={t("azuredevops:integrationTitle")}
description={t("azuredevops:integrationDescription")}
@@ -23,8 +23,12 @@ vi.mock("@kandev/ui/command", () => ({
"data-testid"?: string;
"data-repository"?: string;
}) => (
<section data-testid={testId} data-repository={repository}>
{heading && <h2>{heading}</h2>}
<section {...{ "cmdk-group": "" }} data-testid={testId} data-repository={repository}>
{heading && (
<div {...{ "cmdk-group-heading": "" }} aria-hidden>
{heading}
</div>
)}
{children}
</section>
),
@@ -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> = {}): 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(
<CommandPanelView
{...viewProps({
mode: MODE_COMMANDS,
search: "",
commands: [goToSettings, fontSize],
grouped: [
["Navigation", [goToSettings]],
["Settings", [fontSize]],
],
})}
/>,
);
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(
<CommandPanelView
{...viewProps({
mode: MODE_COMMANDS,
search: "font size",
commands: [goToSettings, fontSize],
grouped: [],
})}
/>,
);
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(
<CommandPanelView
{...viewProps({
mode: MODE_COMMANDS,
search: "terminal font size",
commands: [fontSizeGuide, fontSize],
grouped: [],
})}
/>,
);
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(<CommandPanelView {...viewProps({ mode: "commands" })} />);
+69 -16
View File
@@ -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 && <span className="text-muted-foreground">{cmd.icon}</span>}
<span>{cmd.label}</span>
{cmd.icon && (
<span
className={
cmd.context ? "self-start pt-0.5 text-muted-foreground" : "text-muted-foreground"
}
>
{cmd.icon}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{cmd.label}</span>
{cmd.context && (
<span className="block truncate text-[0.625rem] leading-4 text-muted-foreground">
{cmd.context}
</span>
)}
</span>
{cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>}
{cmd.enterMode && (
<span className="ml-auto text-muted-foreground">
@@ -159,6 +175,45 @@ type CommandsListContentProps = {
onTaskSelect: (task: Task) => void;
};
function SearchCommandGroups({
commands,
search,
onSelect,
}: Pick<CommandsListContentProps, "commands" | "search" | "onSelect">) {
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<string, CommandItemType[]>();
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 && (
<CommandGroup heading={t("common:commandGroupCommands")}>
{regularMatches.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
)}
{[...searchOnlyGroups].map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
))}
</>
);
}
function CommandsListContent({
commands,
grouped,
@@ -195,21 +250,19 @@ function CommandsListContent({
</CommandGroup>
)}
{search.trim() ? (
<CommandGroup heading="Commands">
{sortCommandsForSearch(commands, search)
.filter((cmd) => scoreCommandSearch(cmd.id, search, getCommandSearchTerms(cmd)) > 0)
.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
<SearchCommandGroups commands={commands} search={search} onSelect={onSelect} />
) : (
grouped.map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
))
grouped.map(([group, items]) => {
const visibleItems = items.filter((cmd) => !cmd.searchOnly);
if (visibleItems.length === 0) return null;
return (
<CommandGroup key={group} heading={group}>
{visibleItems.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
</CommandGroup>
);
})
)}
</>
);
@@ -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 }
<Separator />
<GitHubCallbackNotice workspaceId={workspaceId} />
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.github}
title={t("github:workspaceGithubAccess")}
description={t("github:credentialUsedForRepositorySyncWatches")}
>
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.gitlab}
title="GitLab"
description={t("gitlab:connectAGitlabAccountSoKandev")}
icon={<IconBrandGitlab className="h-4 w-4" />}
@@ -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");
+3 -49
View File
@@ -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<typeof useRouter>["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: <IconRobot className="size-3.5" />,
keywords: searchKeywords(t, "common:commandAgentsSettingsKeywords"),
action: () => push("/settings/agents"),
},
{
id: "settings-executors",
label: t("common:commandExecutorsSettings"),
group: t(GROUP_SETTINGS),
icon: <IconCpu className="size-3.5" />,
keywords: searchKeywords(t, "common:commandExecutorsSettingsKeywords"),
action: () => push("/settings/executors"),
},
{
id: "settings-workspace",
label: t("common:commandWorkspaceSettings"),
group: t(GROUP_SETTINGS),
icon: <IconFolder className="size-3.5" />,
keywords: searchKeywords(t, "common:commandWorkspaceSettingsKeywords"),
action: () => push("/settings/workspace"),
},
{
id: "settings-prompts",
label: t("common:commandPromptsSettings"),
group: t(GROUP_SETTINGS),
icon: <IconMessageCircle className="size-3.5" />,
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<CommandItem[]>(
() => [
...navigationCommands,
...buildSettingsCommands(router.push, t),
buildThemeCommand(resolvedTheme, setTheme, t),
quickChatCommand,
configChatCommand,
@@ -231,5 +185,5 @@ export function GlobalCommands() {
useAppShortcuts();
usePluginShortcuts();
return null;
return <SettingsDiscoveryCommands />;
}
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.jira}
icon={<IconTicket className="h-5 w-5" />}
title={t("jira:jiraIntegration")}
description={t("jira:connectThisWorkspaceToAtlassianCloud")}
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.linear}
icon={<IconHexagon className="h-5 w-5" />}
title={t("linear:linearIntegration")}
description={t("linear:linearIntegrationDescription")}
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.sentry}
icon={<IconBrandSentry className="h-5 w-5" />}
title={t("sentry:sentryIntegration")}
description={t("sentry:sentryIntegrationDescription")}
@@ -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<ResolvedSettingsDiscoveryItem> = {}) {
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");
});
});
@@ -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<typeof useRouter>["push"];
type LegacyKeywords = Partial<Record<string, string[]>>;
const LEGACY_COMMAND_IDS: Record<string, string> = {
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))];
}
@@ -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 (
<Card data-testid="account-security-password-card">
<SettingsCard
discoveryTargetId={ACCOUNT_SETTINGS_TARGETS.password}
data-testid="account-security-password-card"
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconKey className="h-4 w-4" /> {t("account:password")}
@@ -97,7 +102,7 @@ function ChangePasswordCard() {
</Button>
</form>
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -135,7 +140,10 @@ function SessionsCard() {
};
return (
<Card data-testid="account-sessions-card">
<SettingsCard
discoveryTargetId={ACCOUNT_SETTINGS_TARGETS.sessions}
data-testid="account-sessions-card"
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconDevices className="h-4 w-4" /> {t("account:activeSessions")}
@@ -194,7 +202,7 @@ function SessionsCard() {
</Table>
)}
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="agent-generated-task-title-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentGeneratedTitles}
data-testid="agent-generated-task-title-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:agentGeneratedTaskTitles")}</CardTitle>
<CardDescription>{t("settings:agentGeneratedTaskTitlesDescription")}</CardDescription>
@@ -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 (
<SettingsCard isDirty={isDirty}>
<SettingsCard
isDirty={isDirty}
discoveryTargetId={agentProfileDiscoveryTarget(draft.id, "profile-settings")}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{t("agents:profileSettings")}</span>
@@ -270,12 +274,14 @@ function ProfileEditorBody({
baselineFlags={savedProfile.cliFlags ?? []}
onChange={(next) => updateDraft({ cliFlags: next })}
permissionSettings={permissionSettings}
discoveryTargetId={agentProfileDiscoveryTarget(draft.id, "cli-flags")}
/>
<ProfileEnvVarsSection
envVars={draft.envVars}
baselineEnvVars={savedProfile.envVars}
onChange={updateDraft}
discoveryTargetId={agentProfileDiscoveryTarget(draft.id, "environment-variables")}
/>
<CommandPreviewCard
@@ -287,6 +293,7 @@ function ProfileEditorBody({
commandPrefix={draft.commandPrefix}
envVars={draft.envVars}
secrets={secrets}
discoveryTargetId={agentProfileDiscoveryTarget(draft.id, "command-preview")}
/>
<ProfileMcpConfigCard
@@ -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";
@@ -127,7 +128,11 @@ export function AnchoredPromptBarSettings() {
});
return (
<SettingsCard isDirty={isDirty} data-testid="anchored-prompt-bar-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.transcriptNavigation}
data-testid="anchored-prompt-bar-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:transcriptNavigation")}</CardTitle>
</CardHeader>
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="archive-confirmation-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.archiveConfirmation}
data-testid="archive-confirmation-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:archiveConfirmation")}</CardTitle>
</CardHeader>
@@ -413,6 +413,7 @@ type CustomCLIFlagsCardProps = {
baselineFlags?: CLIFlag[];
onChange: (next: CLIFlag[]) => void;
permissionSettings?: Record<string, PermissionSetting>;
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 (
<SettingsCard isDirty={isDirty} data-testid="custom-cli-flags-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={discoveryTargetId}
data-testid="custom-cli-flags-card"
>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
@@ -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()}
>
<div className="space-y-6">
<div className="space-y-4">
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.fileEditor} className="space-y-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("settings:fileEditor")}
</div>
@@ -472,7 +474,7 @@ export function EditorsSettings() {
setExpandedConfigLang={state.setExpandedConfigLang}
updateLspConfigString={updateLspConfigString}
/>
</div>
</SettingsTarget>
<Separator />
<EditorsSection
defaultOptions={defaultOptions}
@@ -15,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { getBackendConfig } from "@/lib/config";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import {
@@ -66,6 +67,7 @@ export function ExternalMcpSettings() {
<Separator />
<SettingsSection
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.externalMcpEndpoints}
icon={<IconPlugConnected className="h-5 w-5" />}
title={t("settings:externalMcpEndpoints")}
description={t("settings:externalMcpEndpointsDescription")}
@@ -232,6 +234,7 @@ function SnippetsSection({
return (
<SettingsSection
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.externalMcpSnippets}
icon={<IconCode className="h-5 w-5" />}
title={t("settings:externalMcpSnippets")}
description={t("settings:externalMcpSnippetsDescription")}
+25 -17
View File
@@ -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<GeneralNavItem, "descriptionKey" | "icon"> & { 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;
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="theme-settings-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.colorTheme}
data-testid="theme-settings-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:colorTheme")}</CardTitle>
</CardHeader>
@@ -82,7 +87,11 @@ function ChatSubmitKeyCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isDirty} data-testid="chat-submit-key-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.submitShortcut}
data-testid="chat-submit-key-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:submitShortcut")}</CardTitle>
</CardHeader>
@@ -120,7 +129,11 @@ function ChangesPanelLayoutCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isDirty} data-testid="changes-panel-layout-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.changesPanelLayout}
data-testid="changes-panel-layout-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:changesPanelLayout")}</CardTitle>
</CardHeader>
@@ -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<string, strin
return labels;
}
const CONFIGURABLE_SHORTCUT_IDS = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[];
export function KeyboardShortcutsCard({
overrides,
baselineOverrides = {},
@@ -277,16 +280,17 @@ export function KeyboardShortcutsCard({
[onChange, overrides],
);
const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[];
return (
<SettingsCard isDirty={JSON.stringify(overrides) !== JSON.stringify(baselineOverrides)}>
<SettingsCard
isDirty={JSON.stringify(overrides) !== JSON.stringify(baselineOverrides)}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.keyboardShortcuts}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:keyboardShortcuts")}</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border">
{ids.map((id) => (
{CONFIGURABLE_SHORTCUT_IDS.map((id) => (
<ShortcutRecorder
key={id}
shortcutId={id}
@@ -17,6 +17,7 @@ import {
selectableLocales,
type SupportedLocale,
} from "@/lib/i18n";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
/**
* Language switcher. Applies immediately (no deferred save) — `activateLocale`
@@ -43,7 +44,7 @@ export function LanguageSettings() {
title={t("settings:language")}
description={t("settings:chooseTheLanguageUsedAcrossThe")}
>
<SettingsCard>
<SettingsCard discoveryTargetId={GENERAL_SETTINGS_TARGETS.displayLanguage}>
{/* Card only supplies vertical padding; the horizontal px-4 comes from
CardContent, which every other settings card here goes through. */}
<CardContent>
@@ -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<typeof useLayoutSettings>;
@@ -235,7 +237,11 @@ export function LayoutSettings() {
discard: controller.cancel,
});
return (
<div className="min-w-0 space-y-6" data-testid="layout-settings">
<SettingsTarget
targetId={GENERAL_SETTINGS_TARGETS.layoutProfiles}
className="min-w-0 space-y-6"
data-testid="layout-settings"
>
<LayoutSettingsHeader />
{controller.error && (
<Alert variant="destructive">
@@ -261,6 +267,6 @@ export function LayoutSettings() {
</section>
</div>
<DeleteProfileDialog controller={controller} open={deleteOpen} onOpenChange={setDeleteOpen} />
</div>
</SettingsTarget>
);
}
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="mcp-task-profile-default-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.agentTaskProfile}
data-testid="mcp-task-profile-default-card"
>
<CardHeader>
<CardTitle className="text-base">
<h3>{t("settings:profileForTasksCreatedByAgents")}</h3>
@@ -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 (
<div className="space-y-4">
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.desktopNotifications} className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">{t("settings:desktopNotifications")}</div>
@@ -117,7 +119,7 @@ export function DesktopNotificationsSection({
{notificationPermission === "error" && (
<p className="text-sm text-amber-600">{t("settings:notificationPermissionCheckFailed")}</p>
)}
</div>
</SettingsTarget>
);
}
@@ -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 (
<div
<SettingsTarget
targetId={GENERAL_SETTINGS_TARGETS.notificationSound}
className="space-y-4 rounded-md border p-4"
data-settings-dirty={isDirty}
data-testid="notification-sound-group"
@@ -119,6 +122,6 @@ export function NotificationSoundSection({
</TooltipProvider>
</div>
)}
</div>
</SettingsTarget>
);
}
@@ -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 (
<div className="space-y-4">
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
<div>
<div className="text-base font-medium">{t("settings:externalProviders")}</div>
<p className="text-sm text-muted-foreground">
@@ -285,7 +287,7 @@ function ExternalProvidersSection({
)}
</div>
)}
</div>
</SettingsTarget>
);
}
@@ -410,7 +412,7 @@ export function NotificationsSettings() {
onTextareaInput={handleTextareaInput}
/>
<Separator className="my-4" />
<div className="space-y-4">
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationEvents} className="space-y-4">
<div>
<div className="text-base font-medium">{t("settings:notificationEvents")}</div>
<p className="text-sm text-muted-foreground">
@@ -426,7 +428,7 @@ export function NotificationsSettings() {
onTestProvider={actions.handleTestProvider}
/>
)}
</div>
</SettingsTarget>
</SettingsPageTemplate>
);
}
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="env-vars-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={props.discoveryTargetId}
data-testid="env-vars-card"
>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
@@ -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 (
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={discoveryTargetId}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("executors:mcpPolicy")}
@@ -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 (
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={discoveryTargetId}>
<CardHeader>
<CardTitle>{t("executors:profileDetails")}</CardTitle>
</CardHeader>
@@ -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<AgentProfile>) => 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}
/>
);
}
@@ -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 (
<>
<ScriptCard
title={t("executors:prepareScript")}
description={scripts.prepareDescription}
value={scripts.prepareValue}
baselineValue={scripts.prepareBaseline}
onChange={scripts.onPrepareChange}
height="300px"
placeholders={scripts.placeholders}
executorType={executorType}
discoveryTargetId={executorProfileDiscoveryTarget(profileId, "prepare-script")}
/>
{scripts.isRemote && (
<ScriptCard
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={scripts.cleanupValue}
baselineValue={scripts.cleanupBaseline}
onChange={scripts.onCleanupChange}
height="200px"
placeholders={scripts.placeholders}
executorType={executorType}
/>
)}
</>
);
}
@@ -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<HTMLDivElement>(null);
const [editorHeight, setEditorHeight] = useState(height);
@@ -46,7 +48,7 @@ export function ScriptCard({
const isDirty = baselineValue !== undefined && value !== baselineValue;
return (
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={discoveryTargetId}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
+32 -3
View File
@@ -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<typeof Card> & {
isDirty?: boolean;
discoveryTargetId?: string;
};
export function SettingsCard({ isDirty = false, ...props }: SettingsCardProps) {
return <Card {...props} data-settings-dirty={isDirty} data-settings-dirty-level="card" />;
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 (
<Card
{...props}
ref={mergedRef}
data-settings-dirty={isDirty}
data-settings-dirty-level="card"
/>
);
}
function assignRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement | null): void {
if (typeof ref === "function") ref(element);
else if (ref) ref.current = element;
}
@@ -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 }) {
)}
<Link
href={linkToTaskOverview()}
className="flex h-10 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm font-medium text-foreground/80 transition-colors hover:bg-muted hover:text-foreground"
className="flex h-11 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm font-medium text-foreground/80 transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setOpen(false)}
>
<IconHome className="h-4 w-4 shrink-0" />
<span className="truncate">{t("settings:home")}</span>
</Link>
<div
className="flex flex-col gap-0.5 [&_a]:min-h-10 [&_a]:text-sm [&_button]:min-h-10 [&_button]:text-sm [&_svg]:h-4 [&_svg]:w-4"
className="flex flex-col gap-0.5 [&_a]:min-h-11 [&_a]:text-sm [&_button]:min-h-11 [&_button]:min-w-11 [&_button]:text-sm [&_svg]:h-4 [&_svg]:w-4"
onClick={closeOnLinkClick}
>
<SettingsTree pathname={pathname} />
@@ -363,26 +364,28 @@ function SettingsShell({
return (
<TooltipProvider>
<SettingsSaveProvider key={pathname}>
<main className="flex min-h-0 flex-1 flex-col">
<PageTopbar
title={title}
backHref={backHref}
backLabel={backLabel}
parents={parents}
leading={<SettingsMobileMenu pathname={pathname} />}
showStatusTrigger={false}
className="h-10"
actions={showIntegrationCopyAction ? <IntegrationCopyConfigAction /> : undefined}
/>
{/* Scroll the content, not the topbar: min-h-0 lets this flex child
<SettingsTargetProvider>
<main className="flex min-h-0 flex-1 flex-col">
<PageTopbar
title={title}
backHref={backHref}
backLabel={backLabel}
parents={parents}
leading={<SettingsMobileMenu pathname={pathname} />}
showStatusTrigger={false}
className="h-10"
actions={showIntegrationCopyAction ? <IntegrationCopyConfigAction /> : 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. */}
<div
data-testid="settings-scroll-container"
className="flex min-w-0 min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4 pb-[calc(11rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))]"
>
{children}
</div>
</main>
<div
data-testid="settings-scroll-container"
className="flex min-w-0 min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4 pb-[calc(11rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))]"
>
{children}
</div>
</main>
</SettingsTargetProvider>
</SettingsSaveProvider>
</TooltipProvider>
);
@@ -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 (
<section className="space-y-4">
<section ref={registerTarget} className="space-y-4">
{/* 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. */}
@@ -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<HTMLDivElement>();
const view = render(
<SettingsTargetProvider>
<SettingsTarget ref={callerRef} targetId="font-size">
Control
</SettingsTarget>
</SettingsTargetProvider>,
);
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(
<SettingsTargetProvider revealTarget={reveal}>
<SettingsTarget targetId="font-size">Control</SettingsTarget>
</SettingsTargetProvider>,
);
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(
<SettingsTargetProvider revealTarget={reveal}>
<span>Loading</span>
</SettingsTargetProvider>,
);
window.history.replaceState({}, "", "/settings/general/terminal#late-control");
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT));
view.rerender(
<SettingsTargetProvider revealTarget={reveal}>
<SettingsTarget targetId="late-control">Loaded</SettingsTarget>
</SettingsTargetProvider>,
);
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1));
});
it("handles repeated explicit requests and browser history", async () => {
const reveal = vi.fn();
render(
<SettingsTargetProvider revealTarget={reveal}>
<SettingsTarget targetId="font-size">Control</SettingsTarget>
</SettingsTargetProvider>,
);
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));
});
});
@@ -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<SettingsTargetRegistry | null>(null);
export function SettingsTargetProvider({
children,
revealTarget = revealSettingsTarget,
}: {
children: ReactNode;
revealTarget?: (element: HTMLElement) => void;
}) {
const registryRef = useRef<SettingsTargetRegistry | null>(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<SettingsTargetRequestDetail>).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 (
<SettingsTargetContext.Provider value={registry}>{children}</SettingsTargetContext.Provider>
);
}
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],
);
}
@@ -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(
<SettingsTargetProvider revealTarget={reveal}>
<SettingsCard discoveryTargetId="card-control">Card</SettingsCard>
</SettingsTargetProvider>,
);
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(
<SettingsTargetProvider revealTarget={reveal}>
<SettingsSection discoveryTargetId="section-control" title="Section">
Content
</SettingsSection>
</SettingsTargetProvider>,
);
emitSettingsTargetRequest("section-control");
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1));
expect((reveal.mock.calls[0][0] as HTMLElement).tagName).toBe("SECTION");
});
});
@@ -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 <div {...props} ref={mergedRef} />;
}
function assignRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement | null): void {
if (typeof ref === "function") ref(element);
else if (ref) ref.current = element;
}
@@ -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")}
>
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={GENERAL_SETTINGS_TARGETS.preferredShell}>
<CardHeader>
<CardTitle className="text-base">{t("settings:preferredShell")}</CardTitle>
</CardHeader>
@@ -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 (
<Card>
<SettingsCard discoveryTargetId={GENERAL_SETTINGS_TARGETS.spritesConnection}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -110,7 +112,7 @@ export function SpritesConnectionCard({ secretId }: { secretId?: string }) {
</div>
{testResult && <TestResultDisplay result={testResult} />}
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -200,7 +202,7 @@ export function SpritesInstancesCard({ secretId }: { secretId?: string }) {
}, [secretId, instances, removeSpritesInstance]);
return (
<Card>
<SettingsCard discoveryTargetId={GENERAL_SETTINGS_TARGETS.spritesInstances}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -235,7 +237,7 @@ export function SpritesInstancesCard({ secretId }: { secretId?: string }) {
onDestroy={handleDestroy}
/>
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="startup-page-settings-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.startupPage}
data-testid="startup-page-settings-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:openKandevTo")}</CardTitle>
<CardDescription>{t("settings:thisAppliesWhenKandevStarts")}</CardDescription>
@@ -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 (
<SettingsCard isDirty={isDirty || Boolean(isShowInTopbarDirty) || Boolean(isSimplifiedDirty)}>
<SettingsCard
isDirty={isDirty || Boolean(isShowInTopbarDirty) || Boolean(isSimplifiedDirty)}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.resourceMetrics}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:resourceMetrics")}</CardTitle>
</CardHeader>
@@ -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 (
<div className="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<SettingsTarget
targetId={SYSTEM_SETTINGS_TARGETS.storageActions}
className="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 sm:max-w-xl">
<p className="text-sm font-medium">{t("system:storageActionsTitle")}</p>
<p className="text-xs text-muted-foreground">{t("system:storageActionsDescription")}</p>
@@ -122,7 +127,7 @@ function StorageActions({
</StorageActionButton>
</div>
</div>
</div>
</SettingsTarget>
);
}
@@ -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({
<SettingsCard
className="min-w-0"
isDirty={isDirty}
discoveryTargetId={storageSettingsTarget(sectionId)}
data-testid={`storage-policy-section-${sectionId}`}
>
<CardHeader>
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="terminal-font-size-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={TERMINAL_FONT_SIZE_TARGET}
data-testid="terminal-font-size-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:terminalFontSize")}</CardTitle>
</CardHeader>
@@ -172,7 +181,11 @@ function TerminalFontCard({
const selectValue = isCustom ? CUSTOM_VALUE : fontFamily || "default";
return (
<SettingsCard isDirty={isDirty} data-testid="terminal-font-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={TERMINAL_FONT_FAMILY_TARGET}
data-testid="terminal-font-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:terminalFont")}</CardTitle>
</CardHeader>
@@ -227,7 +240,11 @@ function TerminalLinksCard({
}) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isDirty} data-testid="terminal-links-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={TERMINAL_LINKS_TARGET}
data-testid="terminal-links-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:terminalLinks")}</CardTitle>
</CardHeader>
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="unread-divider-settings-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.unreadMessages}
data-testid="unread-divider-settings-card"
>
<CardHeader>
<CardTitle className="text-base">{t("settings:unreadMessages")}</CardTitle>
</CardHeader>
@@ -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 (
<SettingsCard isDirty={isDirty} data-testid="utility-default-model-card">
<SettingsCard
isDirty={isDirty}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityDefaultModel}
data-testid="utility-default-model-card"
>
<CardHeader>
<CardTitle className="text-base">
<h3>{t("settings:utilityDefaultModelTitle")}</h3>
@@ -299,6 +304,7 @@ export function PerActionOverridesSection({
savedBuiltins.find((saved) => saved.id === agent.id),
),
)}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityActions}
data-testid="utility-actions-card"
>
<CardHeader>
@@ -337,7 +343,10 @@ type CustomAgentsSectionProps = {
export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) {
const { t } = useTranslation();
return (
<Card data-testid="utility-custom-agents-card">
<SettingsCard
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityCustomAgents}
data-testid="utility-custom-agents-card"
>
<CardHeader>
<CardTitle className="text-base">
<h3>{t("settings:utilityCustomAgentsTitle")}</h3>
@@ -366,7 +375,7 @@ export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomA
</div>
)}
</CardContent>
</Card>
</SettingsCard>
);
}
@@ -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 (
<SettingsCard isDirty={voiceMode.engine !== savedVoiceMode.engine}>
<SettingsCard
isDirty={voiceMode.engine !== savedVoiceMode.engine}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceEngine}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceEngineTitle")}</CardTitle>
</CardHeader>
@@ -401,7 +405,7 @@ function BehaviorCard() {
voiceMode.mode !== savedVoiceMode.mode ||
voiceMode.autoSend !== savedVoiceMode.autoSend;
return (
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceBehavior}>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceBehavior")}</CardTitle>
</CardHeader>
@@ -422,7 +426,10 @@ function WhisperModelCard() {
const { save, saving } = useVoiceModeSaver();
return (
<SettingsCard isDirty={voiceMode.whisperWebModel !== savedVoiceMode.whisperWebModel}>
<SettingsCard
isDirty={voiceMode.whisperWebModel !== savedVoiceMode.whisperWebModel}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceModel}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceWhisperModelTitle")}</CardTitle>
</CardHeader>
@@ -469,6 +476,7 @@ function EnableCard() {
return (
<SettingsCard
isDirty={voiceMode.enabled !== savedVoiceMode.enabled}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceEnable}
data-testid="voice-enable-card"
>
<CardHeader>
@@ -550,7 +558,7 @@ function VoiceShortcutCard() {
}, [overrides, persist]);
return (
<SettingsCard isDirty={isDirty}>
<SettingsCard isDirty={isDirty} discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceShortcut}>
<CardHeader>
<CardTitle className="text-base">
{/* The shortcut's own label comes from the shared keyboard registry,
@@ -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 (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.slack}
icon={<IconBrandSlack className="h-5 w-5" />}
title={t("slack:integrationTitle")}
description={t("slack:integrationDescription", { example: DEFAULT_PREFIX_EXAMPLE })}
+9
View File
@@ -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),
+32 -5
View File
@@ -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,
+29 -38
View File
@@ -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<SeedResult> {
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<SessionPage> {
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");
@@ -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");
});
});
@@ -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<void>((resolve) => requestAnimationFrame(() => resolve()));
return [...document.querySelectorAll<HTMLElement>('[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<number | CSSNumericValue> } =>
Boolean(row.key),
);
}, query);
}
@@ -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
@@ -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",
);
});
});
@@ -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],
);
}
+4
View File
@@ -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 */
@@ -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<string, string>
)[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<string>();
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);
});
});
@@ -0,0 +1,4 @@
export {
SETTINGS_DISCOVERY_DEFINITIONS,
SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS,
} from "./catalog/index";
@@ -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",
},
];
@@ -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,
},
];
@@ -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,
},
];
@@ -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,
},
];
@@ -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 },
];
@@ -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<string, string> = {
"/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";
@@ -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;
@@ -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,
},
];
@@ -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<string, string> = {
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",
},
];
@@ -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,
},
];
@@ -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}`;
}
@@ -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<SettingsTargetRequestDetail>).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<SettingsTargetRequestDetail>).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);
});
});
@@ -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);
}
+219
View File
@@ -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,
};
});
}
@@ -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> = {},
): 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",
]);
});
});
+58
View File
@@ -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);
}
@@ -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);
});
});
+118
View File
@@ -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<HTMLElement, number>();
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<SettingsTargetRequestDetail>(SETTINGS_TARGET_REQUEST_EVENT, {
detail: { targetId },
}),
);
}
export function createSettingsTargetRegistry(
reveal: (element: HTMLElement) => void = revealSettingsTarget,
): SettingsTargetRegistry {
const targets = new Map<string, HTMLElement>();
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<HTMLElement>(`[${SETTINGS_TARGET_FOCUS_ATTRIBUTE}]`) ??
element.querySelector<HTMLElement>(
`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
);
}
+49
View File
@@ -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 }>;
}>;
};
+1 -4
View File
@@ -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.",
+32
View File
@@ -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",

Some files were not shown because too many files have changed in this diff Show More