feat: add settings discovery (#2281)
This commit is contained in:
@@ -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
|
||||
|
||||
+6
-3
@@ -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" })} />);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 }>;
|
||||
}>;
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user