-
{displayName}
+
{displayName}
{agent.supports_mcp &&
MCP }
{configured &&
{t("agents:configured")} }
+ {/* Its profiles are still listed and editable below, so the card has to
+ say why none of them can run. */}
+ {!agent.available &&
}
{probing && (
@@ -97,7 +104,7 @@ function InstalledAgentIdentity({
{agent.matched_path ? t("agents:detectedAt", { path: agent.matched_path }) : ""}
@@ -107,8 +114,9 @@ function InstalledAgentIdentity({
}
/**
- * Card rendered under "Installed Agents" - links to the agent profile
- * editor and surfaces a yellow lock icon when the capability probe reports
+ * Card rendered under "Installed Agents" - links to the agent's page (its
+ * profile list) once configured, or straight into profile creation otherwise.
+ * Surfaces a yellow lock icon when the capability probe reports
* `auth_required`. Clicking the lock opens a PTY login dialog if the agent
* type has a registered LoginCommand.
*/
@@ -123,6 +131,7 @@ export function InstalledAgentCard({
onPreview,
onUpdate,
onAuthComplete,
+ children,
}: Props) {
const { t } = useTranslation();
const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
@@ -142,34 +151,21 @@ export function InstalledAgentCard({
};
return (
-
-
-
-
-
-
-
- {hasAgentRecord ? t("agents:createNewProfile") : t("agents:setupProfile")}
-
-
+
+ {/* Header section: identity + agent-level actions. */}
+
+
+
+
+
{runtimeUpdate?.supported && onPreview && onUpdate && (
)}
+ {!hasAgentRecord && (
+ // Sized to match the runtime-update trigger it sits beside — a
+ // coarse-pointer target on a phone, 28px from `sm` up. The default
+ // `sm` size (24px) both mismatched that neighbour on desktop and
+ // left a sub-target-size tap area on touch.
+
+
+
+ {t("agents:setupProfile")}
+
+
+ )}
-
-
+
+ {/* Profiles area: full-bleed below the header, split off by a 1px border. */}
+ {children}
+
);
}
diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx
index b3b13a5f23..5e15a4ae74 100644
--- a/apps/web/components/settings/keyboard-shortcuts-card.tsx
+++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx
@@ -25,7 +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 { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { useTranslation } from "react-i18next";
type ShortcutRecorderProps = {
diff --git a/apps/web/components/settings/language-settings.tsx b/apps/web/components/settings/language-settings.tsx
index f3da49dfc7..67a4fc4284 100644
--- a/apps/web/components/settings/language-settings.tsx
+++ b/apps/web/components/settings/language-settings.tsx
@@ -17,7 +17,7 @@ import {
selectableLocales,
type SupportedLocale,
} from "@/lib/i18n";
-import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
/**
* Language switcher. Applies immediately (no deferred save) — `activateLocale`
diff --git a/apps/web/components/settings/layouts/layout-settings.tsx b/apps/web/components/settings/layouts/layout-settings.tsx
index ac957e3c34..5e8974394d 100644
--- a/apps/web/components/settings/layouts/layout-settings.tsx
+++ b/apps/web/components/settings/layouts/layout-settings.tsx
@@ -29,7 +29,7 @@ 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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
type Controller = ReturnType
;
diff --git a/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx b/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx
index 803f09e4f0..3dfe2b64a1 100644
--- a/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx
+++ b/apps/web/components/settings/mcp-task-agent-profile-default-settings.tsx
@@ -10,7 +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 { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { Trans, useTranslation } from "react-i18next";
diff --git a/apps/web/components/settings/notification-permission-section.tsx b/apps/web/components/settings/notification-permission-section.tsx
index cb215ce05e..2c76204004 100644
--- a/apps/web/components/settings/notification-permission-section.tsx
+++ b/apps/web/components/settings/notification-permission-section.tsx
@@ -10,7 +10,7 @@ import {
} 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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
type NotificationPermissionState =
| NativeNotificationPermission
diff --git a/apps/web/components/settings/notification-sound-section.tsx b/apps/web/components/settings/notification-sound-section.tsx
index cbcb716151..eacf279dbe 100644
--- a/apps/web/components/settings/notification-sound-section.tsx
+++ b/apps/web/components/settings/notification-sound-section.tsx
@@ -18,7 +18,7 @@ import {
} 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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
/**
* Catalog keys for the preset names, keyed by the preset id — the id is the
diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx
index 8f4474c7e9..bf50d10753 100644
--- a/apps/web/components/settings/notifications-settings.tsx
+++ b/apps/web/components/settings/notifications-settings.tsx
@@ -26,7 +26,7 @@ import {
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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
function AppriseProviderCardActions({
provider,
diff --git a/apps/web/components/settings/record-badges.tsx b/apps/web/components/settings/record-badges.tsx
new file mode 100644
index 0000000000..10facaa203
--- /dev/null
+++ b/apps/web/components/settings/record-badges.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+
+import { cn } from "@/lib/utils";
+
+// Shared so the three cannot drift apart in size or shape; only the colour
+// differs between them.
+const BADGE_BASE = "shrink-0 border px-1 py-0.5 text-[9px] font-medium leading-none";
+const BADGE_INFO = "rounded border-primary/35 bg-primary/10 text-primary";
+const BADGE_WARN = "rounded border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400";
+/**
+ * Exported because the integrations index sizes its own copy for a card while
+ * the menu sizes one for a row. Only the colour is shared — that is the part
+ * that must not drift, since the two badges claim the same thing.
+ */
+export const BADGE_OK_COLORS =
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
+
+/**
+ * The badges that qualify a record wherever it is listed.
+ *
+ * Each of these says something the row itself cannot: which workspace commands
+ * ended up acting on, and which profiles a picker will refuse to offer. Both
+ * facts matter in the settings menu and on the record's own page, so both live
+ * here rather than being redrawn per surface — a badge that reads differently
+ * in the menu than on the page it opens is worse than no badge.
+ */
+
+/** The workspace new tasks and commands act on. */
+export function ActiveWorkspaceBadge() {
+ const { t } = useTranslation();
+ // Keeps its pill shape and slightly larger type: it predates the others and
+ // the workspace list and switcher already draw it this way.
+ return (
+
+ {t("sidebar:activeWorkspaceBadge")}
+
+ );
+}
+
+/**
+ * An agent whose CLI is not on this machine. Its profiles stay listed and
+ * editable — you may be about to install it — but nothing can run against it.
+ *
+ * Amber, like the disabled badge and like the panel the profile page shows for
+ * this same fact: both mark a record you cannot currently run, whatever the
+ * reason. Blue is left to the badge that simply says which record is current.
+ */
+export function NotInstalledBadge() {
+ const { t } = useTranslation();
+ return {t("agents:notInstalled")} ;
+}
+
+/**
+ * An integration that is connected — credentials stored and the last health
+ * check passed. The settings tree showed this before it became a menu; this is
+ * the same pill at menu scale.
+ */
+export function IntegrationEnabledBadge() {
+ const { t } = useTranslation();
+ return (
+ {t("sidebar:enabledBadge")}
+ );
+}
+
+/**
+ * A profile switched off. It is hidden from every task and session picker but
+ * stays listed so it can be edited and switched back on — which is exactly why
+ * it has to be marked, or it reads as an ordinary profile that pickers have
+ * inexplicably lost.
+ */
+export function DisabledBadge() {
+ const { t } = useTranslation();
+ return {t("sidebar:disabledBadge")} ;
+}
diff --git a/apps/web/components/settings/record-dot.tsx b/apps/web/components/settings/record-dot.tsx
new file mode 100644
index 0000000000..443babc195
--- /dev/null
+++ b/apps/web/components/settings/record-dot.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * The mark in front of a record — an agent profile on the Agents page, and the
+ * same profile, plus workspaces and executor profiles, in the settings menu.
+ *
+ * One static appearance for every record. It says "this one is yours", nothing
+ * more: a mark that also carried state would mean two different things
+ * depending on which kind of record it sat in front of.
+ */
+export function RecordDot({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/components/settings/secrets-settings.tsx b/apps/web/components/settings/secrets-settings.tsx
index a235047e4b..658d5a2873 100644
--- a/apps/web/components/settings/secrets-settings.tsx
+++ b/apps/web/components/settings/secrets-settings.tsx
@@ -26,6 +26,7 @@ import {
} from "@/lib/api/domains/secrets-api";
import { useRequest } from "@/lib/http/use-request";
import type { SecretListItem, SecretScope, UpdateSecretRequest } from "@/lib/types/http-secrets";
+import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header";
export type SecretFormState = {
name: string;
@@ -455,8 +456,8 @@ type SecretsSettingsProps = {
type SecretsSettingsState = ReturnType;
type SecretsSettingsActions = ReturnType;
-function secretScopeTitle(t: TFunction) {
- return t("settings:secrets");
+function secretScopeTitle(t: TFunction, scope: SecretScope) {
+ return scope === "workspace" ? t("settings:secrets") : t("settings:globalSecrets");
}
function secretScopeDescription(t: TFunction, scope: SecretScope) {
@@ -478,6 +479,7 @@ function secretDraftInvalidReason(
}
type SecretsSettingsBodyProps = {
+ scope: SecretScope;
workspaceId?: string;
state: SecretsSettingsState;
actions: SecretsSettingsActions;
@@ -486,6 +488,7 @@ type SecretsSettingsBodyProps = {
};
function SecretsSettingsBody({
+ scope,
workspaceId,
state,
actions,
@@ -502,7 +505,7 @@ function SecretsSettingsBody({
that spec time out rather than silently scan an unrendered route. */}
-
{secretScopeTitle(t)}
+
{secretScopeTitle(t, scope)}
+ ) : undefined
+ }
isDirty={isDirty}
saveStatus="idle"
saveId={`secrets-${scope}-item-draft`}
@@ -612,6 +622,7 @@ function SecretsSettingsContent({
onDiscard={actions.resetForm}
>
string;
+
+/** A resolved label plus where it came from. */
+export type SegmentTitle = {
+ label: string;
+ /**
+ * True when the label is a title-cased URL segment rather than catalog copy
+ * or a brand name — i.e. it will read as English in every locale.
+ */
+ fromUrl: boolean;
+};
+
+/**
+ * Brand/initialism overrides so the derived label matches how the rest of the
+ * app spells these. Locale-invariant by nature, so they are not catalog keys.
+ * Integration slugs are NOT listed: those come from `WORKSPACE_INTEGRATIONS`
+ * below, the same table the command palette and the route list read.
+ */
+const SEGMENT_LABEL_OVERRIDES: Record = {
+ mcp: "MCP",
+ ui: "UI",
+ vscode: "VS Code",
+};
+
+/** Integration slug → brand label, from the catalog that owns that mapping. */
+const INTEGRATION_LABELS: Record = Object.fromEntries(WORKSPACE_INTEGRATIONS);
+
+/**
+ * Catalog key per settings path segment. The breadcrumb's page title used to be
+ * title-cased straight off the URL, which no lint rule can catch (there is no
+ * literal to flag) and no locale can translate — the pseudo-locale QA pass is
+ * what surfaced it.
+ */
+const SEGMENT_LABEL_KEYS: Record = {
+ about: "system:navAbout",
+ agent: "settings:agent",
+ agents: "common:agents",
+ appearance: "settings:appearance",
+ automations: "common:automations",
+ browse: "agents:browseAvailableAgents",
+ changelog: "common:changelog",
+ "data-storage": "system:navDataStorage",
+ executor: "common:executor",
+ executors: "common:executors",
+ "external-mcp": "common:externalMcp",
+ "feature-toggles": "system:navFeatureToggles",
+ integrations: "common:integrations",
+ "keyboard-shortcuts": "settings:keyboardShortcuts",
+ layouts: "settings:layouts",
+ new: "settings:new",
+ notifications: "settings:notifications",
+ plugins: "common:plugins",
+ preferences: "settings:preferences",
+ profiles: "executors:profiles",
+ prompts: "common:prompts",
+ repositories: "sidebar:repositories",
+ secrets: "settings:secrets",
+ security: "settings:security",
+ status: "common:status",
+ system: "common:system",
+ "task-behavior": "settings:taskBehavior",
+ "terminal-editors": "settings:terminalAndEditors",
+ tokens: "settings:tokens",
+ updates: "system:navUpdates",
+ users: "system:navUsers",
+ "utility-agents": "settings:utilityAgents",
+ "voice-mode": "settings:voiceMode",
+ workflows: "workflows:workflows",
+ workspace: "common:workspace",
+ workspaces: "common:workspaces",
+};
+
+/**
+ * Full-path overrides for pages whose segment word is scope-ambiguous: the
+ * install-wide secrets page is "Global Secrets", while a workspace's secrets
+ * tab keeps the plain segment label.
+ */
+const FULL_PATH_LABEL_KEYS: Record = {
+ "/settings/secrets": "settings:globalSecrets",
+};
+
+/** Segments that identify a record rather than name a page. */
+const ID_SEGMENT = /^[0-9a-f-]{8,}$/i;
+
+/**
+ * Display name for a path segment: a translated page name, a brand name, or —
+ * for an unmapped segment — dash-aware title casing, which stays English.
+ */
+export function segmentTitle(segment: string, t: Translate): SegmentTitle {
+ const key = SEGMENT_LABEL_KEYS[segment];
+ if (key) return { label: t(key), fromUrl: false };
+ const brand = INTEGRATION_LABELS[segment] ?? SEGMENT_LABEL_OVERRIDES[segment];
+ if (brand) return { label: brand, fromUrl: false };
+ return { label: titleCaseSegment(segment), fromUrl: true };
+}
+
+/** Brand label for an integration slug, or null when the slug is not one. */
+export function integrationTitle(slug: string | null): string | null {
+ return (slug && INTEGRATION_LABELS[slug]) || null;
+}
+
+/**
+ * Title for a settings page derived from its deepest non-id path segment.
+ * `/settings` → null (the topbar names it "Settings" as the page itself).
+ * Id-looking segments are skipped, so `/settings/workspace/` resolves to
+ * "Workspace" rather than the raw id.
+ */
+export function deriveSegmentTitle(pathname: string, t: Translate): SegmentTitle | null {
+ const fullPathKey = FULL_PATH_LABEL_KEYS[pathname];
+ if (fullPathKey) return { label: t(fullPathKey), fromUrl: false };
+
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments.length <= 1) return null; // just /settings
+ for (let index = segments.length - 1; index >= 1; index--) {
+ const segment = segments[index];
+ if (ID_SEGMENT.test(segment)) continue;
+ return segmentTitle(segment, t);
+ }
+ return null;
+}
+
+function titleCaseSegment(segment: string): string {
+ return segment
+ .split("-")
+ .map((part) => (part.length === 0 ? part : part[0].toUpperCase() + part.slice(1)))
+ .join(" ");
+}
diff --git a/apps/web/components/settings/settings-breadcrumbs.test.ts b/apps/web/components/settings/settings-breadcrumbs.test.ts
new file mode 100644
index 0000000000..6872341665
--- /dev/null
+++ b/apps/web/components/settings/settings-breadcrumbs.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ matchSettingsCrumbRoute,
+ resolveSettingsBreadcrumbs,
+ type CrumbValues,
+} from "./settings-breadcrumbs";
+
+// `t` echoes the key so an assertion names the catalog entry the crumb resolved
+// to. A real label would prove the copy, not the wiring.
+const t = (key: string) => key;
+
+const SETTINGS = "common:settings";
+const WORKSPACES = "common:workspaces";
+const WORKSPACE = "common:workspace";
+const EXECUTORS = "common:executors";
+const AGENTS = "common:agents";
+const PLUGINS = "common:plugins";
+const INTEGRATIONS = "common:integrations";
+const AUTOMATIONS = "common:automations";
+
+const SETTINGS_HREF = "/settings";
+const WORKSPACES_HREF = "/settings/workspaces";
+const EXECUTORS_HREF = "/settings/executors";
+const WS_HREF = `${WORKSPACES_HREF}/ws-1`;
+const EXECUTOR_HREF = "/settings/executor/exec-1";
+
+const WS_NAME = "Kanban1";
+const EXECUTOR_NAME = "local-docker";
+const PROFILE_NAME = "Alpha";
+const PLUGIN_NAME = "Time Tracker";
+
+const NAMES: Record = {
+ "ws-1": WS_NAME,
+ "auto-1": "Nightly sync",
+ "exec-1": EXECUTOR_NAME,
+ "exec-profile-1": PROFILE_NAME,
+ "agent-profile-1": "My Profile101",
+ claude: "Claude Code",
+ "plugin-1": PLUGIN_NAME,
+};
+
+function values(overrides: Partial = {}): CrumbValues {
+ return {
+ workspaceName: ({ workspaceId }) => (workspaceId && NAMES[workspaceId]) || null,
+ agentDisplayName: ({ agentName }) => (agentName && NAMES[agentName]) || null,
+ agentProfileName: ({ agentProfileId }) => (agentProfileId && NAMES[agentProfileId]) || null,
+ automationName: ({ automationId }) => (automationId && NAMES[automationId]) || null,
+ executorName: ({ executorId }) => (executorId && NAMES[executorId]) || null,
+ executorProfileName: ({ executorProfileId }) =>
+ (executorProfileId && NAMES[executorProfileId]) || null,
+ executorTypeTitle: ({ executorType }) => (executorType ? `New ${executorType} Profile` : null),
+ integrationTitle: ({ integrationSlug }) => (integrationSlug === "github" ? "GitHub" : null),
+ pluginName: ({ pluginId }) => (pluginId && NAMES[pluginId]) || null,
+ ...overrides,
+ };
+}
+
+/** The crumb chain as `label` strings, so a test reads like the topbar. */
+function chain(pathname: string, overrides?: Partial): string[] {
+ const crumbs = resolveSettingsBreadcrumbs(pathname, t, values(overrides));
+ return [...crumbs.parents.map((parent) => parent.label), crumbs.title];
+}
+
+function hrefs(pathname: string): Array {
+ return resolveSettingsBreadcrumbs(pathname, t, values()).parents.map((parent) => parent.href);
+}
+
+describe("settings breadcrumb chain", () => {
+ it("gives the settings index no parents", () => {
+ const crumbs = resolveSettingsBreadcrumbs(SETTINGS_HREF, t, values());
+ expect(crumbs.parents).toEqual([]);
+ expect(crumbs.title).toBe("settings:settings");
+ });
+
+ it("keeps the Settings crumb a phone-only link", () => {
+ const [settings] = resolveSettingsBreadcrumbs(
+ "/settings/preferences/appearance",
+ t,
+ values(),
+ ).parents;
+ expect(settings).toEqual({ label: SETTINGS, href: SETTINGS_HREF, phoneOnlyLink: true });
+ });
+
+ it("names the owning menu page but never a flat page's own row", () => {
+ // Appearance IS the menu row, so it is not its own parent.
+ expect(chain("/settings/preferences/appearance")).toEqual([SETTINGS, "settings:appearance"]);
+ // Browse is a sub-page of the Agents row, so the row becomes a crumb.
+ expect(chain("/settings/agents/browse")).toEqual([
+ SETTINGS,
+ AGENTS,
+ "agents:browseAvailableAgents",
+ ]);
+ });
+});
+
+describe("settings breadcrumb: workspaces", () => {
+ it("titles the workspace page with its name and links the list", () => {
+ expect(chain(WS_HREF)).toEqual([SETTINGS, WORKSPACES, WS_NAME]);
+ expect(hrefs(WS_HREF)).toEqual([SETTINGS_HREF, WORKSPACES_HREF]);
+ });
+
+ it("adds the workspace name crumb on its tabs", () => {
+ expect(chain(`${WS_HREF}/repositories`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WS_NAME,
+ "sidebar:repositories",
+ ]);
+ expect(hrefs(`${WS_HREF}/repositories`)).toEqual([SETTINGS_HREF, WORKSPACES_HREF, WS_HREF]);
+ });
+
+ it("falls back to the Workspace label until the name loads", () => {
+ expect(chain(`${WORKSPACES_HREF}/ws-unknown/repositories`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WORKSPACE,
+ "sidebar:repositories",
+ ]);
+ });
+});
+
+describe("settings breadcrumb: integrations", () => {
+ // Integrations is a real page, so an integration detail hangs off it rather
+ // than jumping straight from the workspace to "GitHub".
+ it("keeps the Integrations crumb on a service page", () => {
+ expect(chain(`${WS_HREF}/integrations/github`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WS_NAME,
+ INTEGRATIONS,
+ "GitHub",
+ ]);
+ expect(hrefs(`${WS_HREF}/integrations/github`)).toEqual([
+ SETTINGS_HREF,
+ WORKSPACES_HREF,
+ WS_HREF,
+ `${WS_HREF}/integrations`,
+ ]);
+ });
+
+ it("titles the Integrations tab itself without repeating the crumb", () => {
+ expect(chain(`${WS_HREF}/integrations`)).toEqual([SETTINGS, WORKSPACES, WS_NAME, INTEGRATIONS]);
+ });
+});
+
+describe("settings breadcrumb: automations", () => {
+ // The id was skipped as unreadable and the title landed back on the section
+ // segment, so an automation used to read "Automations › Automations".
+ it("titles an automation with its name", () => {
+ expect(chain(`${WS_HREF}/automations/auto-1`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WS_NAME,
+ AUTOMATIONS,
+ "Nightly sync",
+ ]);
+ });
+
+ it("names the create page rather than the section", () => {
+ expect(chain(`${WS_HREF}/automations/new`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WS_NAME,
+ AUTOMATIONS,
+ "automations:newAutomation",
+ ]);
+ });
+
+ it("falls back to the Automation label, never to the section label", () => {
+ expect(chain(`${WS_HREF}/automations/auto-gone`)).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ WS_NAME,
+ AUTOMATIONS,
+ "common:automation",
+ ]);
+ });
+});
+
+describe("settings breadcrumb: executors", () => {
+ // Both spellings are live: `/executors` is the page listing every profile,
+ // `/executor/` an executor's own page. Neither had any crumb chain.
+ it("puts every executor route under the Executors page", () => {
+ expect(chain(`${EXECUTORS_HREF}/exec-profile-1`)).toEqual([SETTINGS, EXECUTORS, PROFILE_NAME]);
+ expect(chain(`${EXECUTORS_HREF}/ssh/exec-1`)).toEqual([SETTINGS, EXECUTORS, EXECUTOR_NAME]);
+ expect(chain(`${EXECUTORS_HREF}/new/local_docker`)).toEqual([
+ SETTINGS,
+ EXECUTORS,
+ "New local_docker Profile",
+ ]);
+ expect(chain(EXECUTOR_HREF)).toEqual([SETTINGS, EXECUTORS, EXECUTOR_NAME]);
+ expect(chain("/settings/executor/new")).toEqual([SETTINGS, EXECUTORS, "settings:new"]);
+ });
+
+ it("names the executor a profile belongs to", () => {
+ expect(chain(`${EXECUTOR_HREF}/profile/exec-profile-1`)).toEqual([
+ SETTINGS,
+ EXECUTORS,
+ EXECUTOR_NAME,
+ PROFILE_NAME,
+ ]);
+ expect(hrefs(`${EXECUTOR_HREF}/profile/exec-profile-1`)).toEqual([
+ SETTINGS_HREF,
+ EXECUTORS_HREF,
+ EXECUTOR_HREF,
+ ]);
+ });
+
+ it("does not title an unknown executor type off the URL", () => {
+ // "local_docker" title-cased is "Local_docker" — the old behaviour.
+ expect(
+ resolveSettingsBreadcrumbs(
+ `${EXECUTORS_HREF}/new/local_docker`,
+ t,
+ values({ executorTypeTitle: () => null }),
+ ),
+ ).toMatchObject({ title: "executors:newProfile", titleFromUrlSegment: false });
+ });
+});
+
+describe("settings breadcrumb: agents", () => {
+ it("titles an agent with its display name, directly under Agents", () => {
+ expect(chain("/settings/agents/claude")).toEqual([SETTINGS, AGENTS, "Claude Code"]);
+ });
+
+ it("titles an agent profile with the profile name", () => {
+ expect(chain("/settings/agents/claude/profiles/agent-profile-1")).toEqual([
+ SETTINGS,
+ AGENTS,
+ "My Profile101",
+ ]);
+ });
+});
+
+describe("settings breadcrumb: plugins", () => {
+ it("titles a plugin page with its display name", () => {
+ expect(chain("/settings/plugins/plugin-1")).toEqual([SETTINGS, PLUGINS, PLUGIN_NAME]);
+ });
+
+ it("names the plugin as the parent of its own sub-pages", () => {
+ expect(chain("/settings/plugins/plugin-1/reports")).toEqual([
+ SETTINGS,
+ PLUGINS,
+ PLUGIN_NAME,
+ "Reports",
+ ]);
+ });
+
+ it("falls back to the plugin id, which no catalog can name", () => {
+ const crumbs = resolveSettingsBreadcrumbs("/settings/plugins/time-tracker", t, values());
+ expect(crumbs.title).toBe(PLUGIN_NAME);
+ expect(crumbs.titleFromUrlSegment).toBe(true);
+ });
+
+ it("falls back to the plugin id, not the sub-page it is the parent of", () => {
+ // A crumb that falls back to the *page's* deepest segment would label the
+ // plugin "Reports" — its own child's name.
+ expect(chain("/settings/plugins/time-tracker/reports")).toEqual([
+ SETTINGS,
+ PLUGINS,
+ PLUGIN_NAME,
+ "Reports",
+ ]);
+ });
+});
+
+describe("settings breadcrumb: routes with no crumb row", () => {
+ it("still names the owning page for a legacy workspace path", () => {
+ // Redirect frames get no crumb row of their own, but the menu knows the
+ // legacy prefix, so the chain is still oriented.
+ expect(chain("/settings/workspace/ws-1/integrations/github")).toEqual([
+ SETTINGS,
+ WORKSPACES,
+ "GitHub",
+ ]);
+ });
+
+ it("reports a title it had to take off the URL", () => {
+ expect(resolveSettingsBreadcrumbs("/settings/not-a-real-page", t, values())).toMatchObject({
+ title: "Not A Real Page",
+ titleFromUrlSegment: true,
+ });
+ });
+});
+
+describe("settings breadcrumb: malformed ids", () => {
+ const BAD = `${WORKSPACES_HREF}/%E0%A4%A/repositories`;
+
+ it("labels the crumb from the catalog instead of the broken segment", () => {
+ expect(chain(BAD)).toEqual([SETTINGS, WORKSPACES, WORKSPACE, "sidebar:repositories"]);
+ });
+
+ it("renders the crumb as static text rather than linking somewhere broken", () => {
+ expect(hrefs(BAD)).toEqual([SETTINGS_HREF, WORKSPACES_HREF, undefined]);
+ });
+});
+
+describe("matchSettingsCrumbRoute", () => {
+ it("decodes captured ids so lookups and labels see the real value", () => {
+ const matched = matchSettingsCrumbRoute(`${WORKSPACES_HREF}/ws%20one/automations/a%2Fb`);
+ expect(matched?.params).toEqual({ workspaceId: "ws one", automationId: "a/b" });
+ });
+
+ it("re-encodes ids when filling a crumb href", () => {
+ expect(hrefs(`${WORKSPACES_HREF}/ws%20one/repositories`)).toEqual([
+ SETTINGS_HREF,
+ WORKSPACES_HREF,
+ `${WORKSPACES_HREF}/ws%20one`,
+ ]);
+ });
+
+ it("prefers the reserved route over the record route it would shadow", () => {
+ expect(matchSettingsCrumbRoute("/settings/executor/new")?.params).toEqual({});
+ expect(matchSettingsCrumbRoute("/settings/agents/browse")?.params).toEqual({});
+ expect(matchSettingsCrumbRoute(`${WS_HREF}/automations/new`)?.params).toEqual({
+ workspaceId: "ws-1",
+ });
+ });
+});
diff --git a/apps/web/components/settings/settings-breadcrumbs.ts b/apps/web/components/settings/settings-breadcrumbs.ts
new file mode 100644
index 0000000000..2f7e82df9b
--- /dev/null
+++ b/apps/web/components/settings/settings-breadcrumbs.ts
@@ -0,0 +1,383 @@
+import { settingsMenuOwnerOf } from "@/components/app-sidebar/sections/settings/settings-menu-sections";
+import type { ParentCrumb } from "@/components/page-topbar";
+import { safeDecodePathSegment } from "@/lib/routing/path";
+import { PLUGINS_SETTINGS_HREF, WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog";
+import { deriveSegmentTitle, segmentTitle } from "./settings-breadcrumb-labels";
+
+/**
+ * The settings breadcrumb, declared per route.
+ *
+ * The chain is assembled from three sources, each owning exactly one question:
+ *
+ * 1. "Settings" — always first; a link only on a phone, where the
+ * sidebar that owns the settings menu is hidden.
+ * 2. The owning page — `settingsMenuOwnerOf`, i.e. the same sidebar row that
+ * highlights for this route. Reading the menu rather
+ * than re-deriving it is what keeps the crumb and the
+ * highlighted row from disagreeing, and it covers routes
+ * with no row of their own (plugin sub-pages, the legacy
+ * `/settings/workspace/` spellings).
+ * 3. `SETTINGS_CRUMB_ROUTES` below — the depth *inside* that page, plus where
+ * the page title comes from.
+ *
+ * Anything with no row in (3) falls back to titling itself off its deepest URL
+ * segment (`settings-breadcrumb-labels.ts`). That fallback is right for the
+ * flat pages, which are their menu row, and wrong for a record page — so
+ * `titleFromUrlSegment` reports when it fired and a test gates on it.
+ *
+ * This replaced a hand-maintained `if` chain with a branch per record type. The
+ * chain is why `…/automations/` read "Automations › Automations" (the id was
+ * skipped and the title landed back on the section) and why every executor page
+ * sat directly under "Settings" (no branch existed).
+ */
+
+type Translate = (key: string) => string;
+
+/**
+ * Path params captured from a crumb route pattern, percent-decoded. A segment
+ * that fails to decode is `null`, never a raw undecodable value: a crumb whose
+ * href needs it renders as static text instead of linking somewhere broken.
+ */
+export type CrumbParams = Readonly>;
+
+/**
+ * Runtime label lookups the settings layout supplies — record names from the
+ * store, mostly. Each reads whatever it needs out of the matched params and
+ * returns null when it cannot resolve (not loaded yet, deleted, or belonging to
+ * a different parent than the URL names).
+ */
+export type CrumbValues = {
+ workspaceName: (params: CrumbParams) => string | null;
+ agentDisplayName: (params: CrumbParams) => string | null;
+ agentProfileName: (params: CrumbParams) => string | null;
+ automationName: (params: CrumbParams) => string | null;
+ executorName: (params: CrumbParams) => string | null;
+ executorProfileName: (params: CrumbParams) => string | null;
+ /** "New Profile" for the create-profile route. */
+ executorTypeTitle: (params: CrumbParams) => string | null;
+ integrationTitle: (params: CrumbParams) => string | null;
+ pluginName: (params: CrumbParams) => string | null;
+};
+
+export type CrumbValueName = keyof CrumbValues;
+
+/**
+ * What to show while a runtime value has not resolved, or never will. `key` is
+ * catalog copy ("Workspace"); `param` title-cases that captured path segment,
+ * for values that ARE identifiers — a plugin id, which no catalog can name.
+ *
+ * A crumb must never fall back to `{ segment: true }`: that reads the *page's*
+ * deepest segment, so a parent crumb would end up labelled with its child's
+ * name.
+ */
+export type CrumbFallback = { key: string } | { param: string };
+
+export type CrumbLabel =
+ /** A catalog key, translated at render. */
+ | { key: string }
+ /** A runtime value, with what to show until it resolves. */
+ | { value: CrumbValueName; fallback: CrumbFallback }
+ /** The deepest non-id URL segment, via the segment label catalog. */
+ | { segment: true };
+
+export type CrumbSpec = {
+ label: CrumbLabel;
+ /**
+ * Path template whose `:param` placeholders are filled from the matched
+ * params. Omitted — or unresolvable — renders the crumb as static text.
+ */
+ href?: string;
+ phoneOnlyLink?: boolean;
+};
+
+export type SettingsCrumbRoute = {
+ pattern: RegExp;
+ /** Param name per capture group, in order. */
+ params: readonly string[];
+ /** Crumbs between the owning page and the page title. */
+ crumbs: readonly CrumbSpec[];
+ title: CrumbLabel;
+};
+
+export type SettingsBreadcrumbs = {
+ parents: ParentCrumb[];
+ title: string;
+ /**
+ * True when the title is a title-cased URL segment rather than catalog copy,
+ * a brand name or a record name — i.e. it stays English in every locale.
+ * Read by the coverage gate in `src/settings-routes.test.ts`; the layout
+ * itself ignores it.
+ */
+ titleFromUrlSegment: boolean;
+};
+
+const SETTINGS_HREF = "/settings";
+const SETTINGS_KEY = "common:settings";
+const WORKSPACE_KEY = "common:workspace";
+const EXECUTOR_KEY = "common:executor";
+const PROFILE_KEY = "common:profile";
+const INTEGRATIONS_KEY = "common:integrations";
+const AUTOMATIONS_KEY = "common:automations";
+
+const WORKSPACE_HREF = `${WORKSPACES_SETTINGS_HREF}/:workspaceId`;
+const WORKSPACE_INTEGRATIONS_HREF = `${WORKSPACE_HREF}/integrations`;
+const WORKSPACE_AUTOMATIONS_HREF = `${WORKSPACE_HREF}/automations`;
+// Singular `/settings/executor/` is an executor; plural `/settings/executors`
+// is the page that lists every executor's profiles. Both are live routes.
+const EXECUTOR_HREF = "/settings/executor/:executorId";
+const PLUGIN_HREF = `${PLUGINS_SETTINGS_HREF}/:pluginId`;
+
+const SEGMENT_TITLE: CrumbLabel = { segment: true };
+
+const WORKSPACE_CRUMB: CrumbSpec = {
+ label: { value: "workspaceName", fallback: { key: WORKSPACE_KEY } },
+ href: WORKSPACE_HREF,
+};
+const INTEGRATIONS_CRUMB: CrumbSpec = {
+ label: { key: INTEGRATIONS_KEY },
+ href: WORKSPACE_INTEGRATIONS_HREF,
+};
+const AUTOMATIONS_CRUMB: CrumbSpec = {
+ label: { key: AUTOMATIONS_KEY },
+ href: WORKSPACE_AUTOMATIONS_HREF,
+};
+const EXECUTOR_CRUMB: CrumbSpec = {
+ label: { value: "executorName", fallback: { key: EXECUTOR_KEY } },
+ href: EXECUTOR_HREF,
+};
+const PLUGIN_LABEL: CrumbLabel = { value: "pluginName", fallback: { param: "pluginId" } };
+const PLUGIN_CRUMB: CrumbSpec = { label: PLUGIN_LABEL, href: PLUGIN_HREF };
+
+/**
+ * Matched in order, first match wins, so a longer route comes before the
+ * shorter one it would otherwise be swallowed by (`/executor/new` before
+ * `/executor/`, `/agents/browse` before `/agents/`).
+ *
+ * A crumb only links to a page that exists. The group index paths
+ * (`/settings/preferences`, `/settings/system`) redirect to a sibling, so they
+ * get no crumb — a crumb that bounces you somewhere else is worse than none,
+ * and on desktop the sidebar already shows the group.
+ */
+export const SETTINGS_CRUMB_ROUTES: readonly SettingsCrumbRoute[] = [
+ // ── Workspaces ────────────────────────────────────────────────────────────
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)$/,
+ params: ["workspaceId"],
+ crumbs: [],
+ title: { value: "workspaceName", fallback: { key: WORKSPACE_KEY } },
+ },
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)\/integrations$/,
+ params: ["workspaceId"],
+ crumbs: [WORKSPACE_CRUMB],
+ title: { key: INTEGRATIONS_KEY },
+ },
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)\/integrations\/([^/]+)$/,
+ params: ["workspaceId", "integrationSlug"],
+ crumbs: [WORKSPACE_CRUMB, INTEGRATIONS_CRUMB],
+ title: { value: "integrationTitle", fallback: { key: INTEGRATIONS_KEY } },
+ },
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)\/automations$/,
+ params: ["workspaceId"],
+ crumbs: [WORKSPACE_CRUMB],
+ title: { key: AUTOMATIONS_KEY },
+ },
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)\/automations\/new$/,
+ params: ["workspaceId"],
+ crumbs: [WORKSPACE_CRUMB, AUTOMATIONS_CRUMB],
+ title: { key: "automations:newAutomation" },
+ },
+ {
+ pattern: /^\/settings\/workspaces\/([^/]+)\/automations\/([^/]+)$/,
+ params: ["workspaceId", "automationId"],
+ crumbs: [WORKSPACE_CRUMB, AUTOMATIONS_CRUMB],
+ title: { value: "automationName", fallback: { key: "common:automation" } },
+ },
+ {
+ // Tabs of the workspace page. Their own segment names them; they need the
+ // row only so the workspace-name crumb appears.
+ pattern: /^\/settings\/workspaces\/([^/]+)\/(?:repositories|workflows|secrets)$/,
+ params: ["workspaceId"],
+ crumbs: [WORKSPACE_CRUMB],
+ title: SEGMENT_TITLE,
+ },
+ // ── Agents ────────────────────────────────────────────────────────────────
+ {
+ // The install catalogue, not an agent — so it must precede the agent route.
+ pattern: /^\/settings\/agents\/browse$/,
+ params: [],
+ crumbs: [],
+ title: SEGMENT_TITLE,
+ },
+ {
+ // No agent-name crumb: a saved agent has no page of its own (the route
+ // redirects to the index), so its profiles sit directly under Agents.
+ pattern: /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/,
+ params: ["agentName", "agentProfileId"],
+ crumbs: [],
+ title: { value: "agentProfileName", fallback: { key: PROFILE_KEY } },
+ },
+ {
+ pattern: /^\/settings\/agents\/([^/]+)$/,
+ params: ["agentName"],
+ crumbs: [],
+ title: { value: "agentDisplayName", fallback: { key: "settings:agent" } },
+ },
+ // ── Executors ─────────────────────────────────────────────────────────────
+ {
+ pattern: /^\/settings\/executors\/new\/([^/]+)$/,
+ params: ["executorType"],
+ crumbs: [],
+ title: { value: "executorTypeTitle", fallback: { key: "executors:newProfile" } },
+ },
+ {
+ pattern: /^\/settings\/executors\/ssh\/([^/]+)$/,
+ params: ["executorId"],
+ crumbs: [],
+ title: { value: "executorName", fallback: { key: EXECUTOR_KEY } },
+ },
+ {
+ pattern: /^\/settings\/executors\/([^/]+)$/,
+ params: ["executorProfileId"],
+ crumbs: [],
+ title: { value: "executorProfileName", fallback: { key: PROFILE_KEY } },
+ },
+ {
+ pattern: /^\/settings\/executor\/new$/,
+ params: [],
+ crumbs: [],
+ title: SEGMENT_TITLE,
+ },
+ {
+ pattern: /^\/settings\/executor\/([^/]+)\/profile\/([^/]+)$/,
+ params: ["executorId", "executorProfileId"],
+ crumbs: [EXECUTOR_CRUMB],
+ title: { value: "executorProfileName", fallback: { key: PROFILE_KEY } },
+ },
+ {
+ pattern: /^\/settings\/executor\/([^/]+)$/,
+ params: ["executorId"],
+ crumbs: [],
+ title: { value: "executorName", fallback: { key: EXECUTOR_KEY } },
+ },
+ // ── Plugins ───────────────────────────────────────────────────────────────
+ {
+ pattern: /^\/settings\/plugins\/([^/]+)$/,
+ params: ["pluginId"],
+ crumbs: [],
+ title: PLUGIN_LABEL,
+ },
+ {
+ // Sub-pages a plugin registered itself. Their depth is the plugin's own, so
+ // only the plugin crumb is known here.
+ pattern: /^\/settings\/plugins\/([^/]+)\/.+$/,
+ params: ["pluginId"],
+ crumbs: [PLUGIN_CRUMB],
+ title: SEGMENT_TITLE,
+ },
+];
+
+export type MatchedCrumbRoute = { route: SettingsCrumbRoute; params: CrumbParams };
+
+export function matchSettingsCrumbRoute(pathname: string): MatchedCrumbRoute | null {
+ for (const route of SETTINGS_CRUMB_ROUTES) {
+ const match = pathname.match(route.pattern);
+ if (!match) continue;
+ const params: Record = {};
+ route.params.forEach((name, index) => {
+ params[name] = safeDecodePathSegment(match[index + 1]);
+ });
+ return { route, params };
+ }
+ return null;
+}
+
+type ResolveContext = {
+ pathname: string;
+ params: CrumbParams;
+ t: Translate;
+ values: CrumbValues;
+};
+
+const NO_PARAMS: CrumbParams = {};
+
+/**
+ * Fill a crumb href template. Returns null when a placeholder has no usable
+ * value, so the caller renders static text rather than a link to nowhere. Ids
+ * are re-encoded: params arrive decoded, for lookups and display.
+ */
+function fillCrumbHref(template: string, params: CrumbParams): string | null {
+ let href = template;
+ for (const [name, value] of Object.entries(params)) {
+ const token = `:${name}`;
+ if (!href.includes(token)) continue;
+ if (value === null) return null;
+ href = href.replace(token, encodeURIComponent(value));
+ }
+ return href.includes(":") ? null : href;
+}
+
+type ResolvedLabel = { label: string; fromUrl: boolean };
+
+function resolveSegment(context: ResolveContext): ResolvedLabel {
+ return (
+ deriveSegmentTitle(context.pathname, context.t) ?? {
+ label: context.t(SETTINGS_KEY),
+ fromUrl: false,
+ }
+ );
+}
+
+function resolveFallback(fallback: CrumbFallback, context: ResolveContext): ResolvedLabel {
+ if ("key" in fallback) return { label: context.t(fallback.key), fromUrl: false };
+ const param = context.params[fallback.param];
+ if (param) return segmentTitle(param, context.t);
+ return resolveSegment(context);
+}
+
+function resolveCrumbLabel(label: CrumbLabel, context: ResolveContext): ResolvedLabel {
+ if ("key" in label) return { label: context.t(label.key), fromUrl: false };
+ if ("segment" in label) return resolveSegment(context);
+ const resolved = context.values[label.value](context.params);
+ if (resolved) return { label: resolved, fromUrl: false };
+ return resolveFallback(label.fallback, context);
+}
+
+/**
+ * Build the breadcrumb for a `/settings/*` pathname: the parent crumbs
+ * `PageTopbar` renders plus the current page title.
+ */
+export function resolveSettingsBreadcrumbs(
+ pathname: string,
+ t: Translate,
+ values: CrumbValues,
+): SettingsBreadcrumbs {
+ if (pathname.split("/").filter(Boolean).length <= 1) {
+ // `/settings` itself: the page IS Settings, so it has no parents.
+ return { parents: [], title: t("settings:settings"), titleFromUrlSegment: false };
+ }
+
+ const matched = matchSettingsCrumbRoute(pathname);
+ const context: ResolveContext = { pathname, params: matched?.params ?? NO_PARAMS, t, values };
+ const parents: ParentCrumb[] = [
+ { label: t(SETTINGS_KEY), href: SETTINGS_HREF, phoneOnlyLink: true },
+ ];
+
+ const owner = settingsMenuOwnerOf(pathname);
+ if (owner) parents.push({ label: t(owner.labelKey), href: owner.href });
+
+ for (const crumb of matched?.route.crumbs ?? []) {
+ const href = crumb.href ? fillCrumbHref(crumb.href, context.params) : null;
+ parents.push({
+ label: resolveCrumbLabel(crumb.label, context).label,
+ ...(href ? { href } : {}),
+ ...(crumb.phoneOnlyLink ? { phoneOnlyLink: true } : {}),
+ });
+ }
+
+ const title = resolveCrumbLabel(matched?.route.title ?? SEGMENT_TITLE, context);
+ return { parents, title: title.label, titleFromUrlSegment: title.fromUrl };
+}
diff --git a/apps/web/components/settings/settings-index.test.tsx b/apps/web/components/settings/settings-index.test.tsx
new file mode 100644
index 0000000000..0fee00712c
--- /dev/null
+++ b/apps/web/components/settings/settings-index.test.tsx
@@ -0,0 +1,93 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const breakpoint = { isMobile: false };
+const router = { replace: vi.fn(), push: vi.fn() };
+
+vi.mock("@/lib/routing/client-router", () => ({
+ useRouter: () => router,
+}));
+
+vi.mock("@/hooks/use-responsive-breakpoint", () => ({
+ useResponsiveBreakpoint: () => breakpoint,
+}));
+
+// The tree is store- and plugin-heavy; this test is about the routing decision.
+vi.mock("@/components/settings/settings-page-nav", () => ({
+ SettingsPageNav: ({ pathname }: { pathname: string }) => (
+
+ ),
+}));
+
+import { SettingsIndex } from "./settings-index";
+
+const RESTORE_TO = "/settings/preferences/terminal-editors";
+
+describe("SettingsIndex", () => {
+ beforeEach(() => {
+ router.replace.mockClear();
+ router.push.mockClear();
+ breakpoint.isMobile = false;
+ window.history.replaceState({}, "", "/settings");
+ });
+ afterEach(cleanup);
+
+ it("is the settings index on a phone", () => {
+ breakpoint.isMobile = true;
+
+ render( );
+
+ expect(screen.getByTestId("settings-index")).not.toBeNull();
+ const nav = screen.getByTestId("mock-page-nav");
+ expect(nav.getAttribute("data-pathname")).toBe("/settings");
+ expect(router.replace).not.toHaveBeenCalled();
+ });
+
+ it("hands off on desktop, where the sidebar shows the tree", () => {
+ render( );
+
+ expect(router.replace).toHaveBeenCalledWith(RESTORE_TO);
+ expect(screen.queryByTestId("settings-index")).toBeNull();
+ });
+
+ it("replaces rather than pushes, so Back does not land here and redirect again", () => {
+ render( );
+
+ expect(router.push).not.toHaveBeenCalled();
+ expect(router.replace).toHaveBeenCalledTimes(1);
+ });
+
+ it("hands off when the viewport grows past the sidebar boundary", () => {
+ breakpoint.isMobile = true;
+ const { rerender } = render( );
+ expect(router.replace).not.toHaveBeenCalled();
+
+ // From md up the sidebar renders this same tree, so staying would show two
+ // identical menus side by side.
+ breakpoint.isMobile = false;
+ rerender( );
+
+ expect(router.replace).toHaveBeenCalledWith(RESTORE_TO);
+ expect(screen.queryByTestId("settings-index")).toBeNull();
+ });
+
+ it("does not hand off once the user has already navigated away", () => {
+ // The sidebar is clickable while this route's chunk loads, so the effect can
+ // run after a tap has moved on. Dragging them back would be worse than not
+ // restoring at all.
+ window.history.replaceState({}, "", "/?home=overview");
+
+ render( );
+
+ expect(router.replace).not.toHaveBeenCalled();
+ });
+
+ it("keeps the target it mounted with", () => {
+ const { rerender } = render( );
+
+ rerender( );
+
+ expect(router.replace).toHaveBeenCalledTimes(1);
+ expect(router.replace).toHaveBeenCalledWith(RESTORE_TO);
+ });
+});
diff --git a/apps/web/components/settings/settings-index.tsx b/apps/web/components/settings/settings-index.tsx
new file mode 100644
index 0000000000..ca21e3f2b2
--- /dev/null
+++ b/apps/web/components/settings/settings-index.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { SettingsPageNav } from "@/components/settings/settings-page-nav";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { useRouter } from "@/lib/routing/client-router";
+
+const SETTINGS_INDEX_PATH = "/settings";
+
+/**
+ * Bare `/settings`.
+ *
+ * Below `md` it *is* the settings index: the same tree the nav sheet shows,
+ * rendered as a real route. There is no sidebar there, so the list is the
+ * content — and the breadcrumb's "Settings" crumb (which every settings leaf
+ * emits) finally points at a page instead of a duplicate.
+ *
+ * From `md` up the sidebar already shows that tree, so a page repeating it would
+ * be the duplication this route used to be: it hands off to `restoreTo`, the
+ * settings page this device was last on.
+ *
+ * `restoreTo` is resolved by the route table, which owns the list of static
+ * settings paths that a stored value has to still be a member of — see
+ * `readLastSettingsPath`.
+ *
+ * The handoff follows the live breakpoint rather than the one at mount: growing
+ * a window past `md` reveals the sidebar's copy of this tree, and two identical
+ * menus side by side is the duplication this route exists to remove.
+ * `useResponsiveBreakpoint` reads the real viewport on the first client render,
+ * so there is no desktop-first flash to guard against either way.
+ */
+export function SettingsIndex({ restoreTo }: { restoreTo: string }) {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { isMobile } = useResponsiveBreakpoint();
+ // Captured so a re-render cannot retarget an in-flight handoff. Nothing
+ // records `/settings`, so the stored value cannot change while we sit here.
+ const restoreTarget = useRef(restoreTo);
+
+ useEffect(() => {
+ if (isMobile) return;
+ // The sidebar is interactive while this route's chunk is still loading, so a
+ // tap can navigate away before this effect flushes. Read the live location
+ // rather than trusting mount order: handing off after the user has left
+ // would drag them back into Settings.
+ if (window.location.pathname !== SETTINGS_INDEX_PATH) return;
+ // `replace`, not `push`: with a history entry for `/settings` still in
+ // place, Back would land here and immediately redirect again.
+ router.replace(restoreTarget.current);
+ }, [router, isMobile]);
+
+ if (!isMobile) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/settings-layout-client.test.tsx b/apps/web/components/settings/settings-layout-client.test.tsx
index f76f6cfad9..cafb3409d6 100644
--- a/apps/web/components/settings/settings-layout-client.test.tsx
+++ b/apps/web/components/settings/settings-layout-client.test.tsx
@@ -5,6 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let pathname = "/settings/integrations/github";
const COPY_CONFIG_TEST_ID = "mock-copy-config";
+// The rendered current-page crumb; PageTopbar marks it, not the parents.
+const CURRENT_PAGE = '[aria-current="page"]';
+const WORKSPACES_HREF = "/settings/workspaces";
+const WS_HREF = `${WORKSPACES_HREF}/ws-1`;
+
const state = {
configChat: { isOpen: false },
workspaces: {
@@ -14,6 +19,20 @@ const state = {
{ id: "ws-2", name: "Archive" },
],
},
+ availableAgents: { items: [{ name: "claude", display_name: "Claude Code" }] },
+ settingsAgents: {
+ items: [{ name: "claude", profiles: [{ id: "prof-1", name: "My Profile101" }] }],
+ },
+ executors: {
+ items: [
+ {
+ id: "exec-1",
+ name: "local-docker",
+ profiles: [{ id: "exec-prof-1", name: "Alpha" }],
+ },
+ ],
+ },
+ automations: { items: [{ id: "auto-1", workspace_id: "ws-1", name: "Nightly sync" }] },
setActiveWorkspace: vi.fn(),
};
@@ -29,28 +48,19 @@ vi.mock("@/components/state-provider", () => ({
selector(state) ?? fallback,
}));
-vi.mock("@/components/page-topbar", () => ({
- PageTopbar: ({
- actions,
- parents,
- title,
- }: {
- actions?: ReactNode;
- parents?: Array<{ label: string; href: string }>;
- title: string;
- }) => (
-
- ),
+// Keep the real PageShell/PageTopbar so the scroll-container and breadcrumb
+// assertions test real markup; stub only the nav sheet (store/plugin-heavy)
+// and the affordance hook (reads sidebar state this test doesn't model).
+vi.mock("@/components/navigation/app-nav-sheet", () => ({
+ AppNavSheet: () => null,
+}));
+
+vi.mock("@/hooks/use-home-affordance", () => ({
+ useHomeAffordance: () => ({ mode: "phone", href: "/?home=overview" }),
+}));
+
+vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({
+ AppStatusDrawerTrigger: () => null,
}));
vi.mock("@kandev/ui/tooltip", () => ({
@@ -78,7 +88,7 @@ function DirtySettings() {
return Dirty settings
;
}
-describe("SettingsLayoutClient", () => {
+describe("SettingsLayoutClient integrations actions", () => {
beforeEach(() => {
pathname = "/settings/integrations/github";
state.workspaces.activeId = "ws-1";
@@ -99,7 +109,7 @@ describe("SettingsLayoutClient", () => {
});
it("shows copy config on workspace-scoped integration pages", () => {
- pathname = "/settings/workspace/ws-1/integrations/github";
+ pathname = `${WS_HREF}/integrations/github`;
render(
@@ -110,8 +120,15 @@ describe("SettingsLayoutClient", () => {
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
});
- it("uses the workspace from scoped integration routes before store hydration catches up", () => {
- pathname = "/settings/workspace/ws-2/integrations/github";
+ // The route the app actually renders. `integrationFromPathname` accepts both
+ // spellings, so the source workspace has to be read off both too — parsing
+ // only the legacy singular path left this action copying the *active*
+ // workspace's credentials while the breadcrumb named the routed one.
+ it.each([
+ ["canonical", "/settings/workspaces/ws-2/integrations/github"],
+ ["legacy", "/settings/workspace/ws-2/integrations/github"],
+ ])("copies from the routed workspace, not the active one (%s path)", (_label, path) => {
+ pathname = path;
state.workspaces.activeId = "ws-1";
render(
@@ -123,8 +140,14 @@ describe("SettingsLayoutClient", () => {
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-2");
});
- it("falls back to the active workspace when a scoped route has invalid encoding", () => {
- pathname = "/settings/workspace/%E0%A4%A/integrations/github";
+ // A URL that names a workspace and cannot resolve it has no safe source: the
+ // active workspace is not what the route referred to, and copying its
+ // credentials into the chosen target would be silent and wrong.
+ it.each([
+ ["deleted since the URL was saved", "/settings/workspaces/ws-deleted/integrations/github"],
+ ["a malformed segment", "/settings/workspaces/%E0%A4%A/integrations/github"],
+ ])("offers no copy action when the routed workspace is %s", (_label, path) => {
+ pathname = path;
state.workspaces.activeId = "ws-1";
render(
@@ -133,7 +156,7 @@ describe("SettingsLayoutClient", () => {
,
);
- expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
+ expect(screen.queryByTestId(COPY_CONFIG_TEST_ID)).toBeNull();
});
it("hosts the route save action and reserves safe-area scroll space", async () => {
@@ -155,8 +178,8 @@ describe("SettingsLayoutClient", () => {
);
});
- it("translates the Message Queue breadcrumb and keeps the shared scroll owner", async () => {
- pathname = "/settings/general/message-queue";
+ it("translates the Task behavior breadcrumb and keeps the shared scroll owner", async () => {
+ pathname = "/settings/preferences/task-behavior";
await i18n.changeLanguage("pseudo");
try {
render(
@@ -165,7 +188,7 @@ describe("SettingsLayoutClient", () => {
,
);
- expect(screen.getByTestId("page-topbar-title").textContent).toBe("Ḿēśśàĝē Qũēũē");
+ expect(screen.getByText("Ţàśķ Ɓēĥàvĩōŕ")).toBeTruthy();
expect(screen.getByTestId("settings-scroll-container").className).toContain(
"overflow-y-auto",
);
@@ -175,42 +198,170 @@ describe("SettingsLayoutClient", () => {
});
});
-describe("SettingsLayoutClient workspace breadcrumbs", () => {
- beforeEach(() => {
- pathname = "/settings/workspace/ws-2/secrets";
- state.workspaces.activeId = "ws-1";
- state.setActiveWorkspace.mockClear();
- });
-
+describe("SettingsLayoutClient breadcrumbs", () => {
afterEach(() => cleanup());
- it("includes the workspace name in workspace-scoped breadcrumbs", () => {
+ it("renders the Settings crumb as a phone-only link with static desktop text", () => {
+ pathname = "/settings/preferences/appearance";
+
render(
- Settings page
+ Appearance settings
,
);
- expect(screen.getByTestId("page-topbar-breadcrumbs").textContent).toBe(
- "SettingsArchiveSecrets",
+ const link = screen.getByRole("link", { name: "Settings" });
+ expect(link.getAttribute("href")).toBe("/settings");
+ expect(link.className).toContain("md:hidden");
+ const desktopText = screen
+ .getAllByText("Settings")
+ .find((el) => el.tagName === "SPAN" && el.className.includes("md:inline"));
+ expect(desktopText).toBeTruthy();
+ });
+
+ it("links Workspaces and the workspace name on workspace sub-pages", () => {
+ pathname = `${WS_HREF}/repositories`;
+
+ render(
+
+ Repositories
+ ,
);
- expect(screen.getByRole("link", { name: "Archive" }).getAttribute("href")).toBe(
- "/settings/workspace/ws-2",
+
+ expect(screen.getByRole("link", { name: "Workspaces" }).getAttribute("href")).toBe(
+ WORKSPACES_HREF,
+ );
+ expect(screen.getByRole("link", { name: "Default" }).getAttribute("href")).toBe(
+ "/settings/workspaces/ws-1",
);
});
- it("keeps the automations parent after the workspace breadcrumb", () => {
- pathname = "/settings/workspace/ws-2/automations/new";
+ it("titles the workspace overview with the workspace name", () => {
+ pathname = WS_HREF;
- render(
+ const { container } = render(
- Settings page
+ Overview
,
);
- const breadcrumbs = screen.getByTestId("page-topbar-breadcrumbs");
- expect(
- Array.from(breadcrumbs.querySelectorAll("a, span")).map((element) => element.textContent),
- ).toEqual(["Settings", "Archive", "Automations", "New"]);
+ expect(screen.getByRole("link", { name: "Workspaces" }).getAttribute("href")).toBe(
+ WORKSPACES_HREF,
+ );
+ // The workspace name is the current page crumb (no anchor of its own).
+ expect(container.querySelector(`a[href="${WS_HREF}"]`)).toBeNull();
+ expect(screen.getByText("Default").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ it("links Agents and titles the create page with the display name", () => {
+ pathname = "/settings/agents/claude";
+
+ const { container } = render(
+
+ Agent setup
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: "Agents" }).getAttribute("href")).toBe(
+ "/settings/agents",
+ );
+ expect(container.querySelector('a[href="/settings/agents/claude"]')).toBeNull();
+ expect(screen.getByText("Claude Code").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ it("titles profile pages with the profile name, directly under Agents", () => {
+ pathname = "/settings/agents/claude/profiles/prof-1";
+
+ const { container } = render(
+
+ Profile editor
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: "Agents" }).getAttribute("href")).toBe(
+ "/settings/agents",
+ );
+ // The saved agent has no page of its own, so no agent-name crumb.
+ expect(container.querySelector('a[href="/settings/agents/claude"]')).toBeNull();
+ expect(screen.getByText("My Profile101").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ // Integrations is a page of its own, so an integration service hangs off it
+ // rather than jumping from the workspace straight to "GitHub".
+});
+
+// The chains the URL-segment heuristic could not express: a section that is
+// itself a page, and record pages whose parent is another record.
+describe("SettingsLayoutClient record breadcrumbs", () => {
+ afterEach(() => cleanup());
+
+ it("links Integrations from an integration service page", () => {
+ pathname = `${WS_HREF}/integrations/github`;
+
+ render(
+
+ GitHub settings
+ ,
+ );
+
+ expect(crumbHrefs()).toEqual([
+ "/settings",
+ WORKSPACES_HREF,
+ "/settings/workspaces/ws-1",
+ "/settings/workspaces/ws-1/integrations",
+ ]);
+ expect(screen.getByText("GitHub").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ // The automation id was skipped as unreadable and the title fell back to the
+ // section segment, so this page read "… › Automations › Automations".
+ it("titles an automation with its name under a linked Automations crumb", () => {
+ pathname = `${WS_HREF}/automations/auto-1`;
+
+ render(
+
+ Automation editor
+ ,
+ );
+
+ expect(screen.getByRole("link", { name: "Automations" }).getAttribute("href")).toBe(
+ "/settings/workspaces/ws-1/automations",
+ );
+ expect(screen.getByText("Nightly sync").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ it("puts an executor profile under its executor, under Executors", () => {
+ pathname = "/settings/executor/exec-1/profile/exec-prof-1";
+
+ render(
+
+ Profile editor
+ ,
+ );
+
+ expect(crumbHrefs()).toEqual(["/settings", "/settings/executors", "/settings/executor/exec-1"]);
+ expect(screen.getByRole("link", { name: "local-docker" })).toBeTruthy();
+ expect(screen.getByText("Alpha").closest(CURRENT_PAGE)).toBeTruthy();
+ });
+
+ it("names the Executors page for a bare executor route", () => {
+ pathname = "/settings/executors/exec-prof-1";
+
+ render(
+
+ Profile editor
+ ,
+ );
+
+ expect(crumbHrefs()).toEqual(["/settings", "/settings/executors"]);
+ expect(screen.getByRole("link", { name: "Executors" })).toBeTruthy();
});
});
+
+/** Parent crumb hrefs in order, read off the rendered breadcrumb nav. */
+function crumbHrefs(): string[] {
+ const nav = screen.getByRole("navigation", { name: "breadcrumb" });
+ return [...nav.querySelectorAll("a[href]")]
+ .map((link) => link.getAttribute("href") ?? "")
+ .filter((href) => href.startsWith("/settings"));
+}
diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx
index cf9cbe5b84..af4be84087 100644
--- a/apps/web/components/settings/settings-layout-client.tsx
+++ b/apps/web/components/settings/settings-layout-client.tsx
@@ -1,176 +1,20 @@
"use client";
-import { useState, type MouseEvent } from "react";
import { usePathname } from "@/lib/routing/client-router";
-import { Button } from "@kandev/ui/button";
-import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet";
import { TooltipProvider } from "@kandev/ui/tooltip";
-import { IconActivity, IconHome, IconMenu2 } from "@tabler/icons-react";
-import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
-import { PageTopbar } from "@/components/page-topbar";
-import Link from "@/components/routing/app-link";
-import { SettingsTree } from "@/components/app-sidebar/sections/settings/settings-tree";
+import { PageShell } from "@/components/page-shell";
+import type { ParentCrumb } from "@/components/page-topbar";
import { useAppStore } from "@/components/state-provider";
import { IntegrationCopyConfigMenu } from "@/components/integrations/integration-copy-config-menu";
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 { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item";
-import { linkToTaskOverview } from "@/lib/links";
-import { cn } from "@/lib/utils";
-
-// Brand/initialism overrides so the derived label matches how the rest of the
-// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
-// listed here falls back to dash-aware title-casing of the path segment.
-const SEGMENT_LABEL_OVERRIDES: Record = {
- "azure-devops": "Azure DevOps",
- github: "GitHub",
- jira: "Jira",
- linear: "Linear",
- mcp: "MCP",
- ui: "UI",
- vscode: "VS Code",
-};
-
-/**
- * Catalog key per settings path segment. The breadcrumb's page title used to be
- * title-cased straight off the URL, which no lint rule can catch (there is no
- * literal to flag) and no locale can translate — the pseudo-locale QA pass is
- * what surfaced it. `SEGMENT_LABEL_OVERRIDES` above stays for brand names, which
- * are the same in every language.
- */
-const SEGMENT_LABEL_KEYS: Record = {
- agent: "settings:agent",
- agents: "common:agents",
- appearance: "settings:appearance",
- automations: "common:automations",
- changelog: "common:changelog",
- "changes-panel": "settings:changesPanel",
- "chat-input": "settings:chatInput",
- editors: "settings:editors",
- executor: "settings:executor",
- executors: "common:executors",
- "external-mcp": "common:externalMcp",
- general: "settings:general",
- integrations: "common:integrations",
- "keyboard-shortcuts": "settings:keyboardShortcuts",
- layouts: "settings:layouts",
- "message-queue": "system:messageQueueTitle",
- new: "settings:new",
- notifications: "settings:notifications",
- plugins: "common:plugins",
- prompts: "common:prompts",
- "resource-metrics": "settings:resourceMetrics",
- secrets: "settings:secrets",
- security: "settings:security",
- shell: "common:shell",
- sprites: "settings:sprites",
- system: "common:system",
- "task-actions": "settings:taskActions",
- terminal: "settings:terminal",
- tokens: "settings:tokens",
- "utility-agents": "settings:utilityAgents",
- "voice-mode": "settings:voiceMode",
- workspace: "common:workspace",
- workspaces: "common:workspaces",
-};
-
-/**
- * Display name for a path segment: a translated page name, a brand override, or
- * (for an unmapped route) dash-aware title casing, which stays English.
- */
-function segmentLabel(segment: string, t: (key: string) => string): string {
- if (SEGMENT_LABEL_KEYS[segment]) return t(SEGMENT_LABEL_KEYS[segment]);
- if (SEGMENT_LABEL_OVERRIDES[segment]) return SEGMENT_LABEL_OVERRIDES[segment];
- return segment
- .split("-")
- .map((p) => (p.length === 0 ? p : p[0].toUpperCase() + p.slice(1)))
- .join(" ");
-}
-
-// Derive the human-readable label for the current /settings sub-page from the
-// deepest non-id path segment. /settings → null (the topbar still shows
-// "Settings" as the page itself). UUID-looking segments are skipped so e.g.
-// /settings/workspace/ resolves to "Workspace" not the raw id.
-function deriveCurrentPageLabel(pathname: string, t: (key: string) => string): string | null {
- const segments = pathname.split("/").filter(Boolean);
- if (segments.length <= 1) return null; // just /settings
- for (let i = segments.length - 1; i >= 1; i--) {
- const seg = segments[i];
- if (/^[0-9a-f-]{8,}$/i.test(seg)) continue; // skip ids
- return segmentLabel(seg, t);
- }
- return null;
-}
-
-// Build the intermediate breadcrumb crumbs between the back link and the
-// current page title. Workspace-scoped pages include the routed workspace so
-// the breadcrumb identifies which workspace owns the settings being edited.
-function deriveParents(
- pathname: string,
- workspaceName: string | null,
- t: (key: string) => string,
-): Array<{ label: string; href: string }> {
- const segments = pathname.split("/").filter(Boolean);
- if (segments.length <= 1) return [];
-
- const parents: Array<{ label: string; href: string }> = [
- { label: t("common:settings"), href: "/settings" },
- ];
-
- const workspaceId = workspaceIdFromPathname(pathname);
- if (workspaceId) {
- parents.push({
- label: workspaceName ?? t("common:workspace"),
- href: `/settings/workspace/${encodeURIComponent(workspaceId)}`,
- });
- }
-
- const automationsMatch = pathname.match(
- /^\/settings\/workspace\/([^/]+)\/automations(?:\/(.+))?/,
- );
- if (automationsMatch && automationsMatch[2]) {
- // Only inject the Automations crumb when we're on a sub-page (new or
- // edit), not on the listing page itself — the listing page title is
- // already "Automations".
- parents.push({
- label: t("common:automations"),
- href: `/settings/workspace/${automationsMatch[1]}/automations`,
- });
- }
-
- return parents;
-}
+import { useSettingsBreadcrumbs } from "@/components/settings/use-settings-breadcrumbs";
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
- const { t } = useTranslation();
const pathname = usePathname();
- const workspaces = useAppStore((s) => s.workspaces.items);
- const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents";
- const showIntegrationCopyAction = integrationFromPathname(pathname) !== null;
-
- if (isAgentDetail) {
- return (
-
- {children}
-
- );
- }
-
- const pageLabel = deriveCurrentPageLabel(pathname, t);
- const title = pageLabel ?? t("settings:settings");
- const routeWorkspaceId = workspaceIdFromPathname(pathname);
- const workspaceName =
- workspaces.find((workspace) => workspace.id === routeWorkspaceId)?.name ?? null;
- const parents = deriveParents(pathname, workspaceName, t);
+ const { parents, title } = useSettingsBreadcrumbs(pathname);
return (
{children}
@@ -189,11 +33,7 @@ function IntegrationCopyConfigAction() {
const pathname = usePathname();
const workspaces = useAppStore((s) => s.workspaces.items);
const activeId = useAppStore((s) => s.workspaces.activeId);
- const routeWorkspaceId = workspaceIdFromPathname(pathname);
- const selected =
- routeWorkspaceId && workspaces.some((workspace) => workspace.id === routeWorkspaceId)
- ? routeWorkspaceId
- : (activeId ?? workspaces[0]?.id ?? null);
+ const selected = copySourceWorkspaceId(pathname, workspaces, activeId);
const integration = integrationFromPathname(pathname);
if (!integration || !selected || workspaces.length === 0) return null;
@@ -209,154 +49,42 @@ function IntegrationCopyConfigAction() {
);
}
-/**
- * Status entry in the mobile settings menu. Extracted from SettingsMobileMenu to
- * keep that function under the max-lines-per-function limit.
- */
-function SettingsMobileStatusButton({
- issueSeverity,
- issueDescription,
- dotClass,
- onClick,
-}: {
- issueSeverity: string;
- issueDescription: string | null;
- dotClass: string | undefined;
- onClick: () => void;
-}) {
- const { t } = useTranslation();
- return (
-
-
- {t("common:status")}
- {dotClass && (
-
- )}
-
- );
-}
-
-function SettingsMobileMenu({ pathname }: { pathname: string }) {
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
- const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer();
- const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity);
- // `connectionIssueDetails` lives in a directory that has not been migrated yet
- // and returns English, so the severity is mapped to a catalog key here rather
- // than interpolating its `description` into an otherwise-translated label.
- const issueDescription =
- issueSeverity === "none"
- ? null
- : t(
- issueSeverity === "lost"
- ? "settings:connectionLostDescription"
- : "settings:connectionUnstableDescription",
- );
-
- const closeOnLinkClick = (event: MouseEvent) => {
- if (event.target instanceof Element && event.target.closest("a[href]")) {
- setOpen(false);
- }
- };
- const openStatus = () => {
- setOpen(false);
- requestAnimationFrame(openStatusDrawer);
- };
-
- return (
-
-
-
-
- {issueDetails && (
-
- )}
-
-
-
-
- {t("common:settings")}
-
-
- {statusDrawerEnabled && (
-
- )}
- setOpen(false)}
- >
-
- {t("settings:home")}
-
-
-
-
-
-
-
- );
-}
+// Either spelling: `workspaces` is canonical, `workspace` the legacy path the
+// route table redirects. Both are matched because `integrationFromPathname`
+// matches both — a plural-only parse here left the copy action rendered with no
+// routed workspace, silently sourcing from the active one instead.
+const WORKSPACE_SCOPED_SETTINGS = /^\/settings\/workspaces?\//;
function workspaceIdFromPathname(pathname: string): string | null {
- const match = pathname.match(/^\/settings\/workspace\/([^/]+)(?:\/|$)/);
+ const match = pathname.match(/^\/settings\/workspaces?\/([^/]+)(?:\/|$)/);
return safeDecodePathSegment(match?.[1]);
}
+/**
+ * Which workspace the copy action reads its configuration *from*.
+ *
+ * An unscoped `/settings/integrations/` genuinely means "the active
+ * workspace" — the route table redirects it into that workspace's tab — so the
+ * active workspace is the right source there.
+ *
+ * A route that names a workspace is different: if that workspace does not
+ * resolve (deleted since the URL was bookmarked, or a malformed segment) then
+ * falling back would copy credentials out of a workspace the URL never
+ * mentioned, with nothing on screen saying so. There is no safe substitute, so
+ * the action stays hidden.
+ */
+function copySourceWorkspaceId(
+ pathname: string,
+ workspaces: Array<{ id: string }>,
+ activeId: string | null,
+): string | null {
+ if (WORKSPACE_SCOPED_SETTINGS.test(pathname)) {
+ const routed = workspaceIdFromPathname(pathname);
+ return routed && workspaces.some((workspace) => workspace.id === routed) ? routed : null;
+ }
+ return activeId ?? workspaces[0]?.id ?? null;
+}
+
function SettingsShell({
title,
backHref,
@@ -368,38 +96,54 @@ function SettingsShell({
title: string;
backHref: string;
backLabel: string;
- parents: Array<{ label: string; href: string }>;
+ parents: ParentCrumb[];
showIntegrationCopyAction: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
+ // A settings form floats its Save action above the fold and needs room to
+ // scroll clear of it. #2466 moved that action from the viewport to this
+ // column (`placement="content"`), which brought it closer to the bottom and
+ // so shrank the reservation. The index floats only the search field, a third
+ // of the height, and the form's padding there is dead scroll below the last
+ // row.
+ const contentBottomPadding =
+ pathname === "/settings"
+ ? "pb-[calc(5.25rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))]"
+ : "pb-[calc(8rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))] md:pb-[calc(5.5rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))]";
return (
-
+ {/* The positioning ancestor `placement="content"` resolves against: it
+ wraps the topbar and the scroll container, so the floating Save bar
+ centres on the settings column rather than on the viewport (which
+ would sit it under the sidebar). Upstream used the `` this
+ shell used to own; `PageShell` renders no landmark of its own, since
+ `AppShell` already owns the page's one ``. */}
+
- }
showStatusTrigger={false}
className="h-10"
actions={showIntegrationCopyAction ? : undefined}
- />
- {/* Scroll the content, not the topbar: min-h-0 lets this flex child
- shrink below its content height so overflow-y-auto can take effect. */}
-
{children}
-
+
-
+
);
}
diff --git a/apps/web/components/settings/settings-menu-mode-card.tsx b/apps/web/components/settings/settings-menu-mode-card.tsx
new file mode 100644
index 0000000000..3e759d3b35
--- /dev/null
+++ b/apps/web/components/settings/settings-menu-mode-card.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
+import { Label } from "@kandev/ui/label";
+import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
+import { SETTINGS_MENU_MODES, type SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
+
+/**
+ * Copy per option. Keys, not text: `t()` has to resolve at render, and a
+ * module-level `t()` would freeze at the boot locale.
+ */
+const MODE_COPY: Record = {
+ flat: {
+ labelKey: "settings:settingsMenuFlat",
+ descriptionKey: "settings:settingsMenuFlatDescription",
+ },
+ accordion: {
+ labelKey: "settings:settingsMenuAccordion",
+ descriptionKey: "settings:settingsMenuAccordionDescription",
+ },
+ persistent: {
+ labelKey: "settings:settingsMenuPersistent",
+ descriptionKey: "settings:settingsMenuPersistentDescription",
+ },
+};
+
+export function SettingsMenuModeCard({
+ value,
+ isDirty,
+ onChange,
+}: {
+ value: SettingsMenuMode;
+ isDirty: boolean;
+ onChange: (mode: SettingsMenuMode) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("settings:settingsMenuShape")}
+
+
+
+ {t("settings:settingsMenuShape")}
+ onChange(next as SettingsMenuMode)}
+ className="grid gap-2"
+ >
+ {SETTINGS_MENU_MODES.map((mode) => (
+
+
+
+ {t(MODE_COPY[mode].labelKey)}
+
+ {t(MODE_COPY[mode].descriptionKey)}
+
+
+
+ ))}
+
+
+ {t("settings:settingsMenuShapePerDevice")}
+
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/settings-page-nav.tsx b/apps/web/components/settings/settings-page-nav.tsx
new file mode 100644
index 0000000000..55e0d1fa90
--- /dev/null
+++ b/apps/web/components/settings/settings-page-nav.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { SettingsTree } from "@/components/app-sidebar/sections/settings/settings-tree";
+
+/**
+ * The settings tree as touch rows: the whole page at `/settings` on a phone,
+ * where there is no sidebar to hold it. The wrapper compacts the sidebar-styled
+ * tree, and the search field floats in thumb reach instead of sitting above a
+ * list the user has to scroll past to reach it again.
+ */
+export function SettingsPageNav({ pathname }: { pathname: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/settings-page-template.tsx b/apps/web/components/settings/settings-page-template.tsx
index 3e2d11ed23..0a76dca81a 100644
--- a/apps/web/components/settings/settings-page-template.tsx
+++ b/apps/web/components/settings/settings-page-template.tsx
@@ -12,6 +12,14 @@ import {
type SettingsPageTemplateProps = {
title: string;
description?: string;
+ /**
+ * Replaces the default title/description block. For a page that is a *section*
+ * of a larger one rather than a page in its own right — the workspace Secrets
+ * tab, which heads itself like its five sibling tabs instead of like a
+ * top-level settings page. `title` is still required: it names the save
+ * contributor.
+ */
+ header?: React.ReactNode;
isDirty: boolean;
cardIsDirty?: boolean;
saveStatus: "idle" | "loading" | "success" | "error";
@@ -29,6 +37,7 @@ type SettingsPageTemplateProps = {
export function SettingsPageTemplate({
title,
description,
+ header,
isDirty,
cardIsDirty = isDirty,
onSave,
@@ -55,12 +64,12 @@ export function SettingsPageTemplate({
return (
-
+ {header ?? (
{title}
{description &&
{description}
}
-
+ )}
diff --git a/apps/web/components/settings/settings-section.tsx b/apps/web/components/settings/settings-section.tsx
index 0fefcdf214..ad58a63158 100644
--- a/apps/web/components/settings/settings-section.tsx
+++ b/apps/web/components/settings/settings-section.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
+import { Separator } from "@kandev/ui/separator";
import { useSettingsTargetRegistration } from "./settings-target-provider";
type SettingsSectionProps = {
@@ -12,6 +13,13 @@ type SettingsSectionProps = {
action?: ReactNode;
children: ReactNode;
discoveryTargetId?: string;
+ /**
+ * Rule the heading off from the body. For a section that *is* its page — the
+ * workspace Repositories and Workflows tabs, which have no page heading of
+ * their own — so it carries the same line under its title that the sibling
+ * tabs have under theirs.
+ */
+ divided?: boolean;
};
export function SettingsSection({
@@ -23,6 +31,7 @@ export function SettingsSection({
action,
children,
discoveryTargetId,
+ divided = false,
}: SettingsSectionProps) {
const registerTarget = useSettingsTargetRegistration(discoveryTargetId);
return (
@@ -43,6 +52,7 @@ export function SettingsSection({
{action && {action}
}
+ {divided &&
}
{children}
);
diff --git a/apps/web/components/settings/shell-settings-card.tsx b/apps/web/components/settings/shell-settings-card.tsx
index cdb262af7e..b32b9f5466 100644
--- a/apps/web/components/settings/shell-settings-card.tsx
+++ b/apps/web/components/settings/shell-settings-card.tsx
@@ -8,7 +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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
const AUTO_SHELL = "auto";
const CUSTOM_SHELL = "custom";
diff --git a/apps/web/components/settings/sprites-settings.tsx b/apps/web/components/settings/sprites-settings.tsx
index 5026da0f8d..dc182458f1 100644
--- a/apps/web/components/settings/sprites-settings.tsx
+++ b/apps/web/components/settings/sprites-settings.tsx
@@ -23,7 +23,7 @@ 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 { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { SettingsCard } from "./settings-card";
const SPRITES_TOKEN_ENV_VAR = "SPRITES_API_TOKEN";
diff --git a/apps/web/components/settings/startup-page-settings-card.tsx b/apps/web/components/settings/startup-page-settings-card.tsx
index f163183b7e..e211d8d2dd 100644
--- a/apps/web/components/settings/startup-page-settings-card.tsx
+++ b/apps/web/components/settings/startup-page-settings-card.tsx
@@ -6,7 +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";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
/**
* Holds catalog KEYS, not copy. A module-scope table is evaluated once at import,
diff --git a/apps/web/components/settings/system-metrics-settings-card.tsx b/apps/web/components/settings/system-metrics-settings-card.tsx
index 549c0c4753..2014f0b399 100644
--- a/apps/web/components/settings/system-metrics-settings-card.tsx
+++ b/apps/web/components/settings/system-metrics-settings-card.tsx
@@ -10,7 +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 { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { useTranslation } from "react-i18next";
const METRIC_OPTIONS: Array<{ id: SystemMetricId; labelKey: string }> = [
diff --git a/apps/web/components/settings/system/about-settings.tsx b/apps/web/components/settings/system/about-settings.tsx
new file mode 100644
index 0000000000..33d9fb24d5
--- /dev/null
+++ b/apps/web/components/settings/system/about-settings.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Separator } from "@kandev/ui/separator";
+import { useTranslation } from "react-i18next";
+import { SettingsTarget } from "@/components/settings/settings-target";
+import { AboutCard } from "@/components/settings/system/about-card";
+import { LicensesList } from "@/components/settings/system/licenses-list";
+import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system";
+import type { LicenseEntry } from "@/lib/types/system";
+
+/** About: the former About and Licenses pages as one page. */
+export function AboutSettings({ licenses }: { licenses: LicenseEntry[] }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
{t("system:navLicenses")}
+
+ {t("system:licensesPageDescription")}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/system/data-storage-settings.tsx b/apps/web/components/settings/system/data-storage-settings.tsx
new file mode 100644
index 0000000000..5ae9f59b90
--- /dev/null
+++ b/apps/web/components/settings/system/data-storage-settings.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Separator } from "@kandev/ui/separator";
+import { useTranslation } from "react-i18next";
+import { SettingsTarget } from "@/components/settings/settings-target";
+import { BackupsTable } from "@/components/settings/system/backups-table";
+import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card";
+import { LogViewer } from "@/components/settings/system/log-viewer";
+import { StorageMaintenanceSettings } from "@/components/settings/system/storage/storage-maintenance-settings";
+import { BACKUP_DIR, BACKUP_SQL_COMMAND } from "@/components/settings/system/system-route-shell";
+import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system";
+
+function SectionHeading({ title, description }: { title: string; description?: string }) {
+ return (
+
+
{title}
+ {description &&
{description}
}
+
+ );
+}
+
+/** Data & storage: the former Database, Backups, Storage and Logs pages as one page. */
+export function DataStorageSettings() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/system/system-invisible-copy.test.tsx b/apps/web/components/settings/system/system-invisible-copy.test.tsx
index db4883e84a..152b4b79c4 100644
--- a/apps/web/components/settings/system/system-invisible-copy.test.tsx
+++ b/apps/web/components/settings/system/system-invisible-copy.test.tsx
@@ -4,7 +4,10 @@ import { activateLocale, t } from "@/lib/i18n";
import type { HealthCheckSummary, HealthIssue } from "@/lib/types/health";
import { HealthIssuesCard } from "./health-issues-card";
import { JobProgressIndicator } from "./job-progress-indicator";
-import { SystemGroup } from "@/components/app-sidebar/sections/settings/system-group";
+import { SETTINGS_MENU_SECTIONS } from "@/components/app-sidebar/sections/settings/settings-tree";
+
+const SYSTEM_MENU_ITEMS =
+ SETTINGS_MENU_SECTIONS.find((section) => section.id === "system")?.items ?? [];
afterEach(cleanup);
@@ -107,28 +110,16 @@ describe("HealthIssuesCard issue count", () => {
});
/**
- * `BASE_ITEMS` / `AUTH_ITEMS` are SCREAMING_CASE, which the guard skips
- * entirely: it reported one finding here (`label="System"`) while ten nav
- * labels sat in those two tables. They now resolve at render from `labelKey`.
+ * `BASE_ITEMS` / `AUTH_ITEMS` were SCREAMING_CASE, which the guard skips
+ * entirely. The menu config still is, so the same check applies: every System
+ * row label must resolve through the catalog.
*/
-describe("SystemGroup nav labels", () => {
- it("renders every System route label through the catalog", () => {
- render(
);
- const labels = [
- "Status",
- "Feature Toggles",
- "Database",
- "Backups",
- "Storage",
- "Logs",
- "Updates",
- "About",
- "Licenses",
- "Users",
- ];
- for (const label of labels) {
- expect(screen.getByText(label)).toBeTruthy();
- }
+describe("System menu labels", () => {
+ it("renders every System row label through the catalog", () => {
+ const labels = SYSTEM_MENU_ITEMS.map((item) => t(item.labelKey));
+ expect(labels).toEqual(
+ expect.arrayContaining(["Status", "Data & Logs", "Feature Toggles", "Updates", "About"]),
+ );
});
});
@@ -178,12 +169,10 @@ describe("copy the guard cannot see, under the pseudo-locale", () => {
expect(description).toMatch(ACCENTED);
});
- it("accents every System nav label", () => {
- render(
);
- const leaves = document.querySelectorAll("a");
- expect(leaves.length).toBeGreaterThan(0);
- for (const leaf of leaves) {
- expect(leaf.textContent ?? "").toMatch(ACCENTED);
+ it("accents every System menu label", () => {
+ expect(SYSTEM_MENU_ITEMS.length).toBeGreaterThan(0);
+ for (const item of SYSTEM_MENU_ITEMS) {
+ expect(t(item.labelKey)).toMatch(ACCENTED);
}
});
});
diff --git a/apps/web/components/settings/system/system-route-copy.test.ts b/apps/web/components/settings/system/system-route-copy.test.ts
index 4314ec14e0..db093543b9 100644
--- a/apps/web/components/settings/system/system-route-copy.test.ts
+++ b/apps/web/components/settings/system/system-route-copy.test.ts
@@ -39,8 +39,11 @@ const ROUTE_COPY: Array<{ route: string; titleKey: string; title: string; descri
description: "Database driver, size, and available maintenance controls.",
},
{
+ // /settings/system/logs now redirects into Data & Storage, whose Logs
+ // section titles itself with `system:navLogs`. The description key is
+ // unchanged, so the sentence users read is still pinned below.
route: "logs",
- titleKey: "settings:logsPageTitle",
+ titleKey: "system:navLogs",
title: "Logs",
description: "Create a diagnostic ZIP with frontend and backend logs.",
},
@@ -74,7 +77,7 @@ describe("System route headers keep their pre-migration English", () => {
it.each(ROUTE_COPY)("$route", ({ titleKey, title, description }) => {
expect(t(titleKey)).toBe(title);
const descriptionKey =
- titleKey === "settings:logsPageTitle"
+ titleKey === "system:navLogs"
? "settings:logsPageDescription"
: `system:${camelRoute(title)}PageDescription`;
expect(t(descriptionKey)).toBe(description);
diff --git a/apps/web/components/settings/task-behavior-settings.tsx b/apps/web/components/settings/task-behavior-settings.tsx
new file mode 100644
index 0000000000..60795b5070
--- /dev/null
+++ b/apps/web/components/settings/task-behavior-settings.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { IconMessage } from "@tabler/icons-react";
+import { Separator } from "@kandev/ui/separator";
+import { useTranslation } from "react-i18next";
+import { TaskActionsSettings } from "@/components/settings/general-settings";
+import { SettingsSection } from "@/components/settings/settings-section";
+import { SettingsTarget } from "@/components/settings/settings-target";
+import { MessageQueueSettings } from "@/components/settings/system/message-queue-settings";
+import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
+
+/** Task behavior: the former Task Actions and Message Queue pages as one page. */
+export function TaskBehaviorSettings() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ }
+ title={t("system:messageQueueTitle")}
+ description={t("system:messageQueueDescription")}
+ >
+
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/terminal-editors-settings.tsx b/apps/web/components/settings/terminal-editors-settings.tsx
new file mode 100644
index 0000000000..b28fdca79d
--- /dev/null
+++ b/apps/web/components/settings/terminal-editors-settings.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { Separator } from "@kandev/ui/separator";
+import { EditorsSettings } from "@/components/settings/editors-settings";
+import { TerminalSettings } from "@/components/settings/terminal-settings";
+
+/** Terminal & Editors: the former Terminal and Editors pages as one page. */
+export function TerminalEditorsSettings() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/terminal-settings.tsx b/apps/web/components/settings/terminal-settings.tsx
index aee968b64a..3bed237964 100644
--- a/apps/web/components/settings/terminal-settings.tsx
+++ b/apps/web/components/settings/terminal-settings.tsx
@@ -31,7 +31,7 @@ import {
TERMINAL_FONT_FAMILY_TARGET,
TERMINAL_FONT_SIZE_TARGET,
TERMINAL_LINKS_TARGET,
-} from "@/lib/settings-discovery/catalog/general";
+} from "@/lib/settings-discovery/catalog/preferences";
const CUSTOM_VALUE = "__custom__";
/**
diff --git a/apps/web/components/settings/unread-divider-settings.tsx b/apps/web/components/settings/unread-divider-settings.tsx
index 3c1b105e10..0468b67f5f 100644
--- a/apps/web/components/settings/unread-divider-settings.tsx
+++ b/apps/web/components/settings/unread-divider-settings.tsx
@@ -7,7 +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 { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { useTranslation } from "react-i18next";
diff --git a/apps/web/components/settings/use-settings-breadcrumbs.ts b/apps/web/components/settings/use-settings-breadcrumbs.ts
new file mode 100644
index 0000000000..18d38c0613
--- /dev/null
+++ b/apps/web/components/settings/use-settings-breadcrumbs.ts
@@ -0,0 +1,95 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+ EXECUTOR_TYPE_MAP,
+ executorTypeLabel,
+} from "@/app/settings/executors/new/[type]/executor-types";
+import { useAppStore } from "@/components/state-provider";
+import { pluginRegistry } from "@/lib/plugins/registry";
+import { integrationTitle } from "./settings-breadcrumb-labels";
+import {
+ resolveSettingsBreadcrumbs,
+ type CrumbValues,
+ type SettingsBreadcrumbs,
+} from "./settings-breadcrumbs";
+
+/**
+ * Resolves the settings breadcrumb for `pathname`.
+ *
+ * The route table in `settings-breadcrumbs.ts` says *which* record names a
+ * crumb, and this hook is the only place that knows *where those names live*.
+ *
+ * Every lookup that is scoped by a parent in the URL checks that parent. A name
+ * read from the wrong workspace or the wrong executor would put a label on
+ * screen that the route never referred to, which is the same failure the
+ * integration copy action had: plausible, wrong, and silent.
+ */
+export function useSettingsBreadcrumbs(pathname: string): SettingsBreadcrumbs {
+ const { t } = useTranslation();
+ const workspaces = useAppStore((s) => s.workspaces.items);
+ const availableAgents = useAppStore((s) => s.availableAgents.items);
+ const settingsAgents = useAppStore((s) => s.settingsAgents.items);
+ const executors = useAppStore((s) => s.executors.items);
+ const automations = useAppStore((s) => s.automations.items);
+
+ const values = useMemo
(
+ () => ({
+ workspaceName: ({ workspaceId }) =>
+ workspaces.find((workspace) => workspace.id === workspaceId)?.name ?? null,
+
+ agentDisplayName: ({ agentName }) =>
+ availableAgents.find((agent) => agent.name === agentName)?.display_name ?? null,
+
+ agentProfileName: ({ agentName, agentProfileId }) => {
+ if (!agentProfileId) return null;
+ const scoped = agentName
+ ? settingsAgents.filter((agent) => agent.name === agentName)
+ : settingsAgents;
+ return (
+ scoped.flatMap((agent) => agent.profiles).find((profile) => profile.id === agentProfileId)
+ ?.name ?? null
+ );
+ },
+
+ automationName: ({ workspaceId, automationId }) => {
+ if (!automationId) return null;
+ const automation = automations.find((item) => item.id === automationId);
+ // The store holds one workspace's automations at a time (`useAutomations`
+ // refetches on switch), so a hit from another workspace is stale data.
+ if (!automation || (workspaceId && automation.workspace_id !== workspaceId)) return null;
+ return automation.name;
+ },
+
+ executorName: ({ executorId }) =>
+ executors.find((executor) => executor.id === executorId)?.name ?? null,
+
+ executorProfileName: ({ executorId, executorProfileId }) => {
+ if (!executorProfileId) return null;
+ for (const executor of executors) {
+ if (executorId && executor.id !== executorId) continue;
+ const profile = executor.profiles?.find((item) => item.id === executorProfileId);
+ if (profile) return profile.name;
+ }
+ return null;
+ },
+
+ // The same label the create page's own header shows.
+ executorTypeTitle: ({ executorType }) => {
+ const info = executorType ? EXECUTOR_TYPE_MAP[executorType] : undefined;
+ if (!info) return null;
+ return t("executors:newTypeProfile", { type: executorTypeLabel(info, t) });
+ },
+
+ integrationTitle: ({ integrationSlug }) => integrationTitle(integrationSlug),
+
+ pluginName: ({ pluginId }) =>
+ pluginId ? (pluginRegistry.getPluginName(pluginId) ?? null) : null,
+ }),
+ [workspaces, availableAgents, settingsAgents, executors, automations, t],
+ );
+
+ return resolveSettingsBreadcrumbs(pathname, t, values);
+}
diff --git a/apps/web/components/settings/workspaces/workspace-section-header.tsx b/apps/web/components/settings/workspaces/workspace-section-header.tsx
new file mode 100644
index 0000000000..1b88875baf
--- /dev/null
+++ b/apps/web/components/settings/workspaces/workspace-section-header.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import { workspaceSettingsTabSpec, type WorkspaceSettingsTab } from "./workspace-settings-shell";
+
+/**
+ * The heading every workspace settings tab opens with.
+ *
+ * The six tabs used to head themselves three different ways: three repeated the
+ * workspace's name — already the page title above the strip, so the tab you had
+ * just clicked went unnamed — while the rest named the section, only one of them
+ * with a mark. This is the shape they now share: the section's name and the mark
+ * from its own tab, over a line saying what the section is for.
+ *
+ * Name and mark come from the tab table rather than from each page, so a page
+ * cannot end up calling its section something the tab that opens it does not.
+ */
+export function WorkspaceSectionHeader({
+ tab,
+ description,
+ action,
+}: {
+ tab: WorkspaceSettingsTab;
+ description: string;
+ /** Section-level control, e.g. Automations' "New automation". */
+ action?: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const { labelKey, icon: Icon } = workspaceSettingsTabSpec(tab);
+
+ return (
+
+
+
+
+ {t(labelKey)}
+
+
{description}
+
+ {action}
+
+ );
+}
diff --git a/apps/web/components/settings/workspaces/workspace-section-links.tsx b/apps/web/components/settings/workspaces/workspace-section-links.tsx
new file mode 100644
index 0000000000..7c936e254f
--- /dev/null
+++ b/apps/web/components/settings/workspaces/workspace-section-links.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IconChevronRight } from "@tabler/icons-react";
+import { listAutomations } from "@/lib/api/domains/automation-api";
+import { listWorkflows } from "@/lib/api/domains/kanban-api";
+import { listRepositories } from "@/lib/api/domains/workspace-api";
+import { listSecrets } from "@/lib/api/domains/secrets-api";
+import { getAzureDevOpsConfig } from "@/lib/api/domains/azure-devops-api";
+import { fetchGitHubStatus } from "@/lib/api/domains/github-auth-api";
+import { getGitLabConfig } from "@/lib/api/domains/gitlab-api";
+import { getJiraConfig } from "@/lib/api/domains/jira-api";
+import { getLinearConfig } from "@/lib/api/domains/linear-api";
+import { listSentryInstances } from "@/lib/api/domains/sentry-api";
+import Link from "@/components/routing/app-link";
+import {
+ workspaceSettingsHref,
+ workspaceSettingsTabSpec,
+ type WorkspaceSettingsTab,
+} from "./workspace-settings-shell";
+import { cn } from "@kandev/ui/lib/utils";
+
+export type SectionCounts = {
+ repositories?: number;
+ workflows?: number;
+ integrations?: number;
+ automations?: number;
+ secrets?: number;
+};
+
+// One probe per integration service; counts the ones configured/connected for
+// this workspace. There is no aggregate endpoint, but the probes are tiny
+// config GETs and workspaces are few.
+async function countConfiguredIntegrations(workspaceId: string): Promise {
+ const probes: Array> = [
+ getAzureDevOpsConfig(workspaceId).then((config) => config !== null),
+ fetchGitHubStatus(workspaceId).then((status) => !!status.authenticated),
+ getGitLabConfig({ workspaceId }).then((config) => config !== null),
+ getJiraConfig({ workspaceId }).then((config) => config !== null),
+ getLinearConfig({ workspaceId }).then((config) => config !== null),
+ listSentryInstances(workspaceId).then((instances) => instances.length > 0),
+ ];
+ const results = await Promise.allSettled(probes);
+ return results.filter((result) => result.status === "fulfilled" && result.value).length;
+}
+
+/**
+ * Lazy per-workspace counts for the list page's rows. Fetched client-side per
+ * row: workspaces are few and the lists are small, so a burst of parallel
+ * requests per row beats a dedicated summary endpoint for now. Each count
+ * lands as soon as its request resolves — one slow endpoint (the integration
+ * probes can wait on external services) must not hold the others hostage.
+ * `settled` flips once every probe finished, fulfilled or not.
+ */
+export function useWorkspaceSectionCounts(workspaceId: string): {
+ counts: SectionCounts;
+ settled: boolean;
+} {
+ const [counts, setCounts] = useState({});
+ const [settled, setSettled] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setCounts({});
+ setSettled(false);
+ const apply = (patch: SectionCounts) => {
+ if (!cancelled) setCounts((prev) => ({ ...prev, ...patch }));
+ };
+ // The backend serializes empty lists as null — count defensively.
+ const probes = [
+ listRepositories(workspaceId)
+ .then((res) => apply({ repositories: (res.repositories ?? []).length }))
+ .catch(() => undefined),
+ listWorkflows(workspaceId)
+ .then((res) => apply({ workflows: (res.workflows ?? []).length }))
+ .catch(() => undefined),
+ listAutomations(workspaceId)
+ .then((automations) => apply({ automations: (automations ?? []).length }))
+ .catch(() => undefined),
+ countConfiguredIntegrations(workspaceId)
+ .then((integrations) => apply({ integrations }))
+ .catch(() => undefined),
+ listSecrets({ scope: "workspace", workspaceId })
+ .then((secrets) => apply({ secrets: (secrets ?? []).length }))
+ .catch(() => undefined),
+ ];
+ void Promise.allSettled(probes).then(() => {
+ if (!cancelled) setSettled(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ return { counts, settled };
+}
+
+type SectionStat = {
+ key: keyof SectionCounts;
+ tab: WorkspaceSettingsTab;
+};
+
+// Name and mark come from the tab table, so a tile and the tab it opens cannot
+// end up labelled or marked differently.
+const SECTION_STATS: SectionStat[] = [
+ { key: "repositories", tab: "repositories" },
+ { key: "workflows", tab: "workflows" },
+ { key: "integrations", tab: "integrations" },
+ { key: "automations", tab: "automations" },
+ { key: "secrets", tab: "secrets" },
+];
+
+/**
+ * A workspace's sections as count tiles: big count, chevron, label. Rendered
+ * above the row's whole-card overlay link (z-10), so both stay clickable
+ * without nesting anchors. Zero counts dim; unknown counts show an em dash.
+ */
+export function WorkspaceSectionStats({
+ workspaceId,
+ counts,
+ className,
+}: {
+ workspaceId: string;
+ counts: SectionCounts;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {SECTION_STATS.map(({ key, tab }) => {
+ const count = counts[key];
+ const { labelKey, icon: Icon } = workspaceSettingsTabSpec(tab);
+ return (
+
+
+
+ {count ?? "-"}
+
+
+
+ {/* gap-1, not gap-1.5: at the width these tiles sit at on a laptop
+ the longest label ("Automations") overflowed its box by a single
+ pixel once the mark took its place beside it. */}
+
+
+ {t(labelKey)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/components/settings/workspaces/workspace-settings-shell.test.tsx b/apps/web/components/settings/workspaces/workspace-settings-shell.test.tsx
new file mode 100644
index 0000000000..54ff2e2c1b
--- /dev/null
+++ b/apps/web/components/settings/workspaces/workspace-settings-shell.test.tsx
@@ -0,0 +1,173 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+
+const navigationMock = vi.hoisted(() => ({ push: vi.fn() }));
+
+vi.mock("@/lib/routing/client-router", () => ({
+ useRouter: () => ({ push: navigationMock.push }),
+}));
+
+// Same treatment as the sidebar picker's test: Radix dropdown primitives rely on
+// pointer/portal behaviour jsdom doesn't model, so render them as plain elements
+// and let `onSelect` fire on click.
+vi.mock("@kandev/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ disabled,
+ "data-testid": testId,
+ }: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ disabled?: boolean;
+ "data-testid"?: string;
+ }) => (
+ onSelect?.()}>
+ {children}
+
+ ),
+ DropdownMenuSeparator: () => ,
+}));
+
+const storeState = {
+ features: { office: false },
+ workspaces: {
+ items: [
+ { id: "w1", name: "Default Workspace", office_workflow_id: "" },
+ { id: "w2", name: "Office Workspace", office_workflow_id: "wf-office" },
+ ],
+ activeId: "w1",
+ },
+ setActiveWorkspace: vi.fn(),
+};
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
+}));
+
+import { WorkspaceSettingsShell } from "./workspace-settings-shell";
+
+const CURRENT_WORKSPACE_ITEM = "workspace-settings-switcher-item-w1";
+const OTHER_WORKSPACE_ITEM = "workspace-settings-switcher-item-w2";
+
+describe("WorkspaceSettingsShell — workspace switcher", () => {
+ beforeEach(() => {
+ navigationMock.push = vi.fn();
+ storeState.features.office = false;
+ storeState.workspaces.activeId = "w1";
+ storeState.setActiveWorkspace = vi.fn();
+ });
+
+ afterEach(cleanup);
+
+ it("keeps the current tab when switching to another workspace", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(OTHER_WORKSPACE_ITEM));
+
+ expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspaces/w2/secrets");
+ });
+
+ it("does not navigate when picking the workspace already open", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(CURRENT_WORKSPACE_ITEM));
+
+ expect(navigationMock.push).not.toHaveBeenCalled();
+ });
+
+ it("never changes the active workspace — this switcher only navigates", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId(CURRENT_WORKSPACE_ITEM));
+
+ expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspaces/w1");
+ expect(storeState.setActiveWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("badges the active workspace after its name, not the one being edited", () => {
+ storeState.workspaces.activeId = "w2";
+ render(
+
+
+ ,
+ );
+
+ const activeRow = screen.getByTestId(OTHER_WORKSPACE_ITEM).textContent ?? "";
+ expect(activeRow).toContain("Active");
+ expect(activeRow.indexOf("Active")).toBeGreaterThan(activeRow.indexOf("Office Workspace"));
+ expect(screen.getByTestId(CURRENT_WORKSPACE_ITEM).textContent).not.toContain("Active");
+ });
+
+ it("marks the header with the Active badge only on the active workspace's page", () => {
+ storeState.workspaces.activeId = "w1";
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("workspace-settings-active-badge").textContent).toBe("Active");
+ unmount();
+
+ storeState.workspaces.activeId = "w2";
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("workspace-settings-active-badge")).toBeNull();
+ });
+
+ it("names the open workspace on the trigger", () => {
+ render(
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId("workspace-settings-switcher");
+ expect(trigger.getAttribute("aria-label")).toBe("Switch workspace");
+ expect(trigger.textContent).toContain("Default Workspace");
+ });
+
+ it("falls back to a plain heading for an unknown workspace", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("workspace-settings-switcher")).toBeNull();
+ expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Workspace");
+ });
+
+ it("offers the office create item only when the office feature is on", () => {
+ storeState.features.office = true;
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByText("New office workspace"));
+
+ expect(navigationMock.push).toHaveBeenCalledWith("/office/setup?mode=new");
+ });
+});
diff --git a/apps/web/components/settings/workspaces/workspace-settings-shell.tsx b/apps/web/components/settings/workspaces/workspace-settings-shell.tsx
new file mode 100644
index 0000000000..3b448c2fd4
--- /dev/null
+++ b/apps/web/components/settings/workspaces/workspace-settings-shell.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { IconFolder } from "@tabler/icons-react";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
+import Link from "@/components/routing/app-link";
+import { useAppStore } from "@/components/state-provider";
+import { useFeature } from "@/hooks/domains/features/use-feature";
+import { useRouter } from "@/lib/routing/client-router";
+import {
+ WORKSPACE_SETTINGS_TABS,
+ workspaceSettingsHref,
+ type WorkspaceSettingsTab,
+} from "@/lib/settings/workspace-settings-tabs";
+import {
+ WorkspacePickerContent,
+ WorkspaceTrigger,
+ type WorkspaceItem,
+} from "@/components/workspaces/workspace-picker-content";
+import { cn } from "@kandev/ui/lib/utils";
+
+// The tab table and href builder are data — see `workspace-settings-tabs.ts`,
+// which the settings menu's Workspaces branch reads too. Re-exported here so
+// existing callers keep one import.
+// The badge is shared with the settings menu and the workspace list — see
+// `record-badges.tsx`. Re-exported so existing callers keep one import.
+import { ActiveWorkspaceBadge } from "@/components/settings/record-badges";
+
+export { ActiveWorkspaceBadge };
+
+export {
+ workspaceSettingsHref,
+ workspaceSettingsTabSpec,
+ type WorkspaceSettingsTab,
+} from "@/lib/settings/workspace-settings-tabs";
+
+/**
+ * The workspace switcher this page is headed by — the same control the sidebar
+ * header draws, so the two read as one design (see
+ * `workspace-picker-content.tsx`). It is a page-to-page navigator, not an
+ * activation control: picking a workspace opens that workspace's copy of the
+ * tab you are on and leaves the active workspace alone. The "Active" pill in
+ * the list is what tells the two apart.
+ */
+function WorkspaceSettingsSwitcher({
+ workspaceName,
+ workspaceId,
+ activeTab,
+}: {
+ workspaceName: string;
+ workspaceId: string;
+ activeTab: WorkspaceSettingsTab;
+}) {
+ const router = useRouter();
+ const officeEnabled = useFeature("office");
+ const workspaces = useAppStore((s) => s.workspaces.items);
+ const activeId = useAppStore((s) => s.workspaces.activeId);
+ const [open, setOpen] = useState(false);
+
+ const handleSelect = useCallback(
+ (next: WorkspaceItem) => {
+ if (next.id !== workspaceId) {
+ router.push(workspaceSettingsHref(next.id, activeTab));
+ }
+ setOpen(false);
+ },
+ [router, workspaceId, activeTab],
+ );
+
+ const handleNavigate = useCallback(
+ (href: string) => {
+ router.push(href);
+ setOpen(false);
+ },
+ [router],
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {/* The page-level mark: the closed picker names the workspace being
+ edited, so when that is also the active one, say so without opening
+ the menu. */}
+ {workspaceId === activeId && (
+
+
+
+ )}
+ >
+ );
+}
+
+/**
+ * The tabbed shell every workspace settings page renders through: the
+ * workspace name doubles as a switcher (move between workspaces without going
+ * back to the list — the breadcrumb's Workspaces crumb owns the way back),
+ * plus one tab per section. The menu holds no workspace rows, so this is the
+ * only workspace-level navigation surface.
+ */
+export function WorkspaceSettingsShell({
+ workspaceId,
+ activeTab,
+ children,
+}: {
+ workspaceId: string;
+ activeTab: WorkspaceSettingsTab;
+ children: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const workspaces = useAppStore((s) => s.workspaces.items);
+ const workspace = workspaces.find((item) => item.id === workspaceId);
+ const tabsRef = useRef(null);
+
+ // Each tab is its own route, so navigating remounts this shell and the
+ // strip snaps back to its start — on a phone the pill you just tapped
+ // scrolls out of view. Centre the active pill before paint instead; when
+ // the strip fits (desktop rail), there is nothing to scroll and this is a
+ // no-op.
+ useLayoutEffect(() => {
+ const nav = tabsRef.current;
+ if (!nav || nav.scrollWidth <= nav.clientWidth) return;
+ const pill = nav.querySelector('[aria-current="page"]');
+ if (!pill) return;
+ const navRect = nav.getBoundingClientRect();
+ const pillRect = pill.getBoundingClientRect();
+ nav.scrollLeft += pillRect.left - navRect.left - (nav.clientWidth - pillRect.width) / 2;
+ }, [activeTab]);
+
+ return (
+
+
+
+
+
+
+ {workspace ? (
+
+ ) : (
+
{t("common:workspace")}
+ )}
+
+
+ {/* Pills on a phone, an underline rail from `md` up — the same boundary
+ the sidebar uses, so the strip changes shape exactly when the sidebar
+ that would otherwise carry this navigation disappears. Scrolls
+ horizontally at both sizes; six sections outrun a phone's width, and
+ wrapping them would push the page content below the fold. */}
+
+ {WORKSPACE_SETTINGS_TABS.map(({ tab, labelKey }) => (
+
+ {t(labelKey)}
+
+ ))}
+
+
{children}
+
+ );
+}
diff --git a/apps/web/components/task/ensure-session-error.test.tsx b/apps/web/components/task/ensure-session-error.test.tsx
index 76e42b8112..4a1db1e5b8 100644
--- a/apps/web/components/task/ensure-session-error.test.tsx
+++ b/apps/web/components/task/ensure-session-error.test.tsx
@@ -23,7 +23,7 @@ describe("describeEnsureError", () => {
expect(info?.isAgentProfileMissing).toBe(true);
expect(info?.action).toEqual({
label: "Open workspace settings",
- href: "/settings/workspace/ws-1",
+ href: "/settings/workspaces/ws-1",
});
});
@@ -71,7 +71,7 @@ describe("EnsureSessionErrorBanner", () => {
);
expect(screen.getByText("No agent profile configured")).toBeTruthy();
const link = screen.getByTestId("ensure-session-error-action") as HTMLAnchorElement;
- expect(link.getAttribute("href")).toBe("/settings/workspace/ws-1");
+ expect(link.getAttribute("href")).toBe("/settings/workspaces/ws-1");
});
it("invokes onRetry when the retry button is clicked", () => {
diff --git a/apps/web/components/task/ensure-session-error.tsx b/apps/web/components/task/ensure-session-error.tsx
index 9b7eaa1420..2007bf8341 100644
--- a/apps/web/components/task/ensure-session-error.tsx
+++ b/apps/web/components/task/ensure-session-error.tsx
@@ -31,7 +31,10 @@ export function describeEnsureError(
detail: t("task:noAgentProfileConfiguredDetail"),
isAgentProfileMissing: true,
action: workspaceId
- ? { label: t("task:openWorkspaceSettings"), href: `/settings/workspace/${workspaceId}` }
+ ? {
+ label: t("task:openWorkspaceSettings"),
+ href: `/settings/workspaces/${workspaceId}`,
+ }
: null,
};
}
diff --git a/apps/web/components/workspaces/workspace-picker-content.tsx b/apps/web/components/workspaces/workspace-picker-content.tsx
new file mode 100644
index 0000000000..b12f65b578
--- /dev/null
+++ b/apps/web/components/workspaces/workspace-picker-content.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { forwardRef, type ComponentPropsWithoutRef } from "react";
+import { useTranslation } from "react-i18next";
+import { IconBriefcase, IconChevronDown, IconLayoutKanban, IconPlus } from "@tabler/icons-react";
+import { DropdownMenuItem, DropdownMenuSeparator } from "@kandev/ui/dropdown-menu";
+import { ActiveWorkspaceBadge } from "@/components/settings/record-badges";
+import { cn } from "@/lib/utils";
+
+/**
+ * The one workspace switcher design, shared by the sidebar header picker and
+ * the workspace settings pages. Both surfaces draw the same trigger and the
+ * same rows — a switcher that reads differently depending on where it is
+ * opened is worse than two obviously different controls.
+ *
+ * Only behaviour differs: the sidebar picker changes the active workspace,
+ * while the settings switcher navigates between settings pages. That is why
+ * selection is a callback here rather than logic baked into the rows.
+ */
+
+export type WorkspaceType = "kanban" | "office";
+
+export type WorkspaceItem = {
+ id: string;
+ name: string;
+ office_workflow_id?: string | null;
+};
+
+export function workspaceType(workspace: WorkspaceItem | undefined): WorkspaceType {
+ return workspace?.office_workflow_id ? "office" : "kanban";
+}
+
+/** Returns a catalog key, not copy: this is a plain function with no hook. */
+function workspaceTypeLabelKey(type: WorkspaceType) {
+ return type === "office" ? "sidebar:office" : "sidebar:kanban";
+}
+
+function WorkspaceTypeIcon({ type, className }: { type: WorkspaceType; className: string }) {
+ if (type === "office") {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * The switcher's button: a bordered control carrying the current workspace
+ * name and a chevron. `nameClassName` exists for the sidebar's expand
+ * animation (`sidebar-fade-in`), which must not leak into the settings header.
+ *
+ * forwardRef + prop spread so `DropdownMenuTrigger asChild` can wire the
+ * trigger (ref, onClick, aria-*, data-state) onto the underlying button.
+ */
+export const WorkspaceTrigger = forwardRef<
+ HTMLButtonElement,
+ ComponentPropsWithoutRef<"button"> & {
+ activeName: string;
+ chevronTestId?: string;
+ nameClassName?: string;
+ }
+>(function WorkspaceTrigger(
+ { activeName, chevronTestId, nameClassName, className, ...props },
+ ref,
+) {
+ const { t } = useTranslation();
+ return (
+
+ {activeName}
+
+
+ );
+});
+
+export type WorkspacePickerContentProps = {
+ workspaces: WorkspaceItem[];
+ activeId: string | null;
+ itemTestIdPrefix: string;
+ officeEnabled: boolean;
+ onWorkspaceSelect: (workspace: WorkspaceItem) => void;
+ onNavigate: (href: string) => void;
+};
+
+export function WorkspacePickerContent({
+ workspaces,
+ activeId,
+ itemTestIdPrefix,
+ officeEnabled,
+ onWorkspaceSelect,
+ onNavigate,
+}: WorkspacePickerContentProps) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+function WorkspaceList({
+ workspaces,
+ activeId,
+ itemTestIdPrefix,
+ onWorkspaceSelect,
+}: Pick<
+ WorkspacePickerContentProps,
+ "workspaces" | "activeId" | "itemTestIdPrefix" | "onWorkspaceSelect"
+>) {
+ const { t } = useTranslation();
+ if (workspaces.length === 0) {
+ return {t("sidebar:noWorkspaces")} ;
+ }
+
+ return workspaces.map((ws) => {
+ const type = workspaceType(ws);
+ return (
+ onWorkspaceSelect(ws)}
+ className="cursor-pointer gap-2"
+ >
+
+ {ws.name}
+ {/* The same "Active" pill the settings menu and the workspace list
+ draw, sitting with the type badge in the row's qualifier column
+ rather than splitting the icon from the name. */}
+ {ws.id === activeId && }
+
+ {t(workspaceTypeLabelKey(type))}
+
+
+ );
+ });
+}
+
+function WorkspaceCreateItems({
+ officeEnabled,
+ onNavigate,
+}: Pick) {
+ const { t } = useTranslation();
+ if (!officeEnabled) {
+ return (
+ onNavigate("/settings/workspaces")}
+ >
+
+ {t("sidebar:addWorkspace")}
+
+ );
+ }
+
+ return (
+ <>
+ onNavigate("/settings/workspaces")}
+ >
+
+ {t("sidebar:newKanbanWorkspace")}
+
+ onNavigate("/office/setup?mode=new")}
+ >
+
+ {t("sidebar:newOfficeWorkspace")}
+
+ >
+ );
+}
diff --git a/apps/web/e2e/helpers/settings-menu.ts b/apps/web/e2e/helpers/settings-menu.ts
new file mode 100644
index 0000000000..5e875884ab
--- /dev/null
+++ b/apps/web/e2e/helpers/settings-menu.ts
@@ -0,0 +1,25 @@
+import { expect, type Page } from "@playwright/test";
+
+export const SETTINGS_APPEARANCE_PATH = "/settings/preferences/appearance";
+/** The settings takeover's left panel — the menu every mode renders into. */
+export const SETTINGS_TAKEOVER_TESTID = "app-sidebar-settings-mode";
+
+/**
+ * Choose a settings menu mode through the real control, so a spec exercises
+ * the same preview → save path a user does rather than seeding localStorage
+ * behind it.
+ *
+ * `flat` is the default and renders no branches at all, so any spec asserting
+ * on a branch's rows (a workspace's tabs, its integrations) has to opt into a
+ * tree mode first.
+ */
+export async function setSettingsMenuMode(
+ testPage: Page,
+ mode: "flat" | "accordion" | "persistent",
+): Promise {
+ await testPage.goto(SETTINGS_APPEARANCE_PATH);
+ await testPage.getByTestId(`settings-menu-mode-${mode}`).click();
+ const floatingSave = testPage.getByTestId("settings-floating-save");
+ await floatingSave.getByRole("button", { name: "Save changes" }).click();
+ await expect(floatingSave).not.toBeVisible({ timeout: 15_000 });
+}
diff --git a/apps/web/e2e/pages/automations-page.ts b/apps/web/e2e/pages/automations-page.ts
index d82e97e936..a0d74f9a8c 100644
--- a/apps/web/e2e/pages/automations-page.ts
+++ b/apps/web/e2e/pages/automations-page.ts
@@ -43,12 +43,12 @@ export class AutomationsPage {
}
async goto() {
- await this.page.goto(`/settings/workspace/${this.workspaceId}/automations`);
+ await this.page.goto(`/settings/workspaces/${this.workspaceId}/automations`);
await this.listPage.waitFor({ state: "visible", timeout: 15_000 });
}
async gotoNew() {
- await this.page.goto(`/settings/workspace/${this.workspaceId}/automations/new`);
+ await this.page.goto(`/settings/workspaces/${this.workspaceId}/automations/new`);
await this.editor.waitFor({ state: "visible", timeout: 15_000 });
}
diff --git a/apps/web/e2e/pages/github-auth-settings-page.ts b/apps/web/e2e/pages/github-auth-settings-page.ts
index 451bf51a8d..047d42f94c 100644
--- a/apps/web/e2e/pages/github-auth-settings-page.ts
+++ b/apps/web/e2e/pages/github-auth-settings-page.ts
@@ -5,7 +5,7 @@ export class GitHubAuthSettingsPage {
async goto(workspaceId: string, query = "") {
await this.page.goto(
- `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations/github${query}`,
+ `/settings/workspaces/${encodeURIComponent(workspaceId)}/integrations/github${query}`,
);
}
diff --git a/apps/web/e2e/pages/gitlab-settings-page.ts b/apps/web/e2e/pages/gitlab-settings-page.ts
index ffa87a2cc4..e09b61694f 100644
--- a/apps/web/e2e/pages/gitlab-settings-page.ts
+++ b/apps/web/e2e/pages/gitlab-settings-page.ts
@@ -15,7 +15,7 @@ export class GitLabSettingsPage {
async goto(workspaceId?: string) {
const path = workspaceId
- ? `/settings/workspace/${workspaceId}/integrations/gitlab`
+ ? `/settings/workspaces/${workspaceId}/integrations/gitlab`
: "/settings/integrations/gitlab";
await this.page.goto(path);
await this.hostInput.waitFor();
diff --git a/apps/web/e2e/pages/layout-settings-page.ts b/apps/web/e2e/pages/layout-settings-page.ts
index 10dff6c952..1998817626 100644
--- a/apps/web/e2e/pages/layout-settings-page.ts
+++ b/apps/web/e2e/pages/layout-settings-page.ts
@@ -12,17 +12,17 @@ export class LayoutSettingsPage {
}
async open(): Promise {
- await this.page.goto("/settings/general/layouts");
+ await this.page.goto("/settings/preferences/layouts");
await expect(this.root).toBeVisible();
}
- async openFromMobileMenu(): Promise {
- await this.page.goto("/settings/general/terminal");
- await this.page.getByTestId("settings-mobile-menu-button").click();
- const menu = this.page.getByTestId("settings-mobile-menu");
- await expect(menu).toBeVisible();
- await menu.getByRole("link", { name: "Layouts", exact: true }).click();
- await expect(this.page).toHaveURL(/\/settings\/general\/layouts$/);
+ /** Reach Layouts the way a phone user does: from the /settings index page. */
+ async openFromSettingsIndex(): Promise {
+ await this.page.goto("/settings");
+ const index = this.page.getByTestId("settings-index");
+ await expect(index).toBeVisible();
+ await index.getByRole("link", { name: /^Layouts\b/ }).click();
+ await expect(this.page).toHaveURL(/\/settings\/preferences\/layouts$/);
await expect(this.root).toBeVisible();
}
diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts
index b352273184..b6b6f8b246 100644
--- a/apps/web/e2e/pages/linear-settings-page.ts
+++ b/apps/web/e2e/pages/linear-settings-page.ts
@@ -29,7 +29,7 @@ export class LinearSettingsPage {
}
async gotoWorkspace(workspaceId: string) {
- await this.page.goto(`/settings/workspace/${workspaceId}/integrations/linear`);
+ await this.page.goto(`/settings/workspaces/${workspaceId}/integrations/linear`);
await this.secretInput.waitFor({ state: "visible" });
}
}
diff --git a/apps/web/e2e/pages/workflow-settings-page.ts b/apps/web/e2e/pages/workflow-settings-page.ts
index 4b8a93bc7d..a9f7ba80e3 100644
--- a/apps/web/e2e/pages/workflow-settings-page.ts
+++ b/apps/web/e2e/pages/workflow-settings-page.ts
@@ -20,7 +20,7 @@ export class WorkflowSettingsPage {
}
async goto(workspaceId: string) {
- await this.page.goto(`/settings/workspace/${workspaceId}/workflows`);
+ await this.page.goto(`/settings/workspaces/${workspaceId}/workflows`);
// Wait for a client-rendered element to confirm hydration is complete
// (networkidle is unreliable with persistent WebSocket connections)
await expect(this.addWorkflowButton).toBeVisible();
diff --git a/apps/web/e2e/scripts/run-e2e.sh b/apps/web/e2e/scripts/run-e2e.sh
index 7a9b3372a8..f71ceabd53 100755
--- a/apps/web/e2e/scripts/run-e2e.sh
+++ b/apps/web/e2e/scripts/run-e2e.sh
@@ -105,7 +105,9 @@ build_fe() {
build_backend_host() {
log "building backend (host)"
- make -C "$BACKEND_DIR" build >/dev/null || die "backend build failed"
+ local targets=(build)
+ [[ "$PROJECT" == containers ]] && targets+=(build-agentctl-linux build-mock-agent-linux)
+ make -C "$BACKEND_DIR" "${targets[@]}" >/dev/null || die "backend build failed"
}
# Packages the plugin-fixture SDK plugin (cmd/plugin-fixture) into
diff --git a/apps/web/e2e/tests/automations-settings.spec.ts b/apps/web/e2e/tests/automations-settings.spec.ts
index b0e200aec8..583f923bf7 100644
--- a/apps/web/e2e/tests/automations-settings.spec.ts
+++ b/apps/web/e2e/tests/automations-settings.spec.ts
@@ -309,7 +309,9 @@ test.describe("Automations settings page", () => {
await apiClient.seedAutomationRun(automation.id, "skipped");
// Navigate to the editor page for this automation.
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/automations/${automation.id}`);
+ await testPage.goto(
+ `/settings/workspaces/${seedData.workspaceId}/automations/${automation.id}`,
+ );
await testPage.getByTestId("automation-editor").waitFor({ state: "visible", timeout: 15_000 });
// Scroll to the bottom to ensure Recent Runs is visible.
@@ -400,7 +402,9 @@ test.describe("Automations settings page", () => {
await apiClient.archiveTask(archivedTask.id);
await apiClient.seedAutomationTaskSession(cancelledTask.id, "CANCELLED");
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/automations/${automation.id}`);
+ await testPage.goto(
+ `/settings/workspaces/${seedData.workspaceId}/automations/${automation.id}`,
+ );
await testPage.getByTestId("automation-editor").waitFor({ state: "visible", timeout: 15_000 });
const scrollContainer = testPage.getByTestId("settings-scroll-container");
diff --git a/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
index b6be4a80bc..7f09f70904 100644
--- a/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
@@ -39,7 +39,9 @@ test.describe("Mobile voice mode", () => {
// The five cards rendered by `voice-mode-settings.tsx` at mobile width.
await expect(testPage.getByText("Enable Voice Input")).toBeVisible();
await expect(testPage.getByText("Transcription Engine")).toBeVisible();
- await expect(testPage.getByText("Behavior")).toBeVisible();
+ // Exact: the settings menu's "Task Behavior" row is mounted (display-hidden)
+ // at this width, and a substring match would collide with it.
+ await expect(testPage.getByText("Behavior", { exact: true })).toBeVisible();
await expect(testPage.getByText("Whisper Web Model")).toBeVisible();
await expect(testPage.getByText(/Shortcut$/)).toBeVisible();
// (The legacy `button[data-sidebar="trigger"]` mobile settings-sidebar
diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts
index b4a43daf68..7814944b3b 100644
--- a/apps/web/e2e/tests/command-panel.spec.ts
+++ b/apps/web/e2e/tests/command-panel.spec.ts
@@ -129,12 +129,14 @@ test.describe("Command Panel", () => {
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.getByText("Settings › Terminal & Editors", { 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$/);
+ await expect(testPage).toHaveURL(
+ /\/settings\/preferences\/terminal-editors#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();
diff --git a/apps/web/e2e/tests/docker/storage-maintenance.spec.ts b/apps/web/e2e/tests/docker/storage-maintenance.spec.ts
index dd8c140e27..7c0a4e784a 100644
--- a/apps/web/e2e/tests/docker/storage-maintenance.spec.ts
+++ b/apps/web/e2e/tests/docker/storage-maintenance.spec.ts
@@ -23,7 +23,7 @@ async function openStorageSettings(page: Page): Promise {
// document request once if the first load is interrupted under CI load.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
- await page.goto("/settings/system/storage", {
+ await page.goto("/settings/system/data-storage", {
waitUntil: "commit",
timeout: 20_000,
});
diff --git a/apps/web/e2e/tests/i18n/language-switch.spec.ts b/apps/web/e2e/tests/i18n/language-switch.spec.ts
index 9e89124839..28e148398d 100644
--- a/apps/web/e2e/tests/i18n/language-switch.spec.ts
+++ b/apps/web/e2e/tests/i18n/language-switch.spec.ts
@@ -11,7 +11,7 @@ import { test, expect } from "../../fixtures/test-base";
* See docs/specs/platform/i18n.md and docs/i18n.md.
*/
-const APPEARANCE_URL = "/settings/general/appearance";
+const APPEARANCE_URL = "/settings/preferences/appearance";
/** Latin letters carrying diacritics, as produced by scripts/generate-pseudo-locale.mjs. */
const ACCENTED = /[À-ɏ]/;
diff --git a/apps/web/e2e/tests/i18n/mobile-language-switch.spec.ts b/apps/web/e2e/tests/i18n/mobile-language-switch.spec.ts
index a2672eaa77..2cad0bdeb9 100644
--- a/apps/web/e2e/tests/i18n/mobile-language-switch.spec.ts
+++ b/apps/web/e2e/tests/i18n/mobile-language-switch.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
-const APPEARANCE_URL = "/settings/general/appearance";
+const APPEARANCE_URL = "/settings/preferences/appearance";
test.describe("mobile i18n language switcher", () => {
test("switches to Chinese, persists through reload, and restores English", async ({
diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
index 9684f0b81c..5abf93f12d 100644
--- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
+++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
@@ -148,12 +148,12 @@ type Screen = {
const SCREENS: Screen[] = [
{
name: "settings — appearance",
- url: "/settings/general/appearance",
+ url: "/settings/preferences/appearance",
anchor: "[data-testid=theme-settings-card]",
},
{
name: "settings — notifications",
- url: "/settings/general/notifications",
+ url: "/settings/preferences/notifications",
anchor: "[data-testid=notification-sound-group]",
// Provider names are rows in the notification_providers table. The backend
// seeds these two (apps/backend/internal/notifications/service/service.go)
@@ -163,24 +163,46 @@ const SCREENS: Screen[] = [
},
{
name: "settings — secrets",
- url: "/settings/general/secrets",
+ url: "/settings/secrets",
anchor: "[data-testid=secrets-settings-body]",
},
{
- name: "settings — terminal",
- url: "/settings/general/terminal",
+ name: "settings — terminal & editors",
+ url: "/settings/preferences/terminal-editors",
anchor: "[data-testid=terminal-font-size-card]",
+ // Every entry is a `label` or a `binary` from `LSP_LANGUAGE_OPTIONS`
+ // (components/settings/lsp-language-options.ts), whose header states the
+ // rule these follow: a language name and an executable name are shown
+ // verbatim, and translating either would name a server that does not exist.
+ // The qualifier around them ("experimental") and the frame around the
+ // binaries in the `Auto-start {{language}} language server` aria-labels DO
+ // go through `t()`, and the attribute pass still checks that frame after
+ // these tokens are stripped. `Go` is absent on purpose: it is under the
+ // four-letter `wordlike` threshold, so it is never reported and adding it
+ // would only widen the mask; `gopls` is over it, so it is listed.
+ allow: [
+ "TypeScript / JavaScript",
+ "typescript-language-server",
+ "gopls",
+ "Rust",
+ "rust-analyzer",
+ "Python",
+ "pyright-langserver",
+ "Kotlin",
+ "kotlin-lsp",
+ ],
},
- {
- name: "settings — sprites",
- url: "/settings/general/sprites",
- anchor: "[data-testid=sprites-connection-card]",
- // Product name of the sandbox provider.
- allow: ["Sprites.dev"],
- },
+ // NOT YET: "settings — sprites". The entry existed at `/settings/general/sprites`
+ // with the `sprites-connection-card` anchor; this restructure folded that card
+ // into `/settings/executors` (SpritesSettings is rendered by the executors
+ // page), and that route is excluded below because the fixture's executor
+ // profile NAMES are user data. The card's copy is covered the moment the
+ // executors entry is unblocked — it does not need a screen of its own, and
+ // pointing this entry at `/settings/executors` would just re-import that
+ // screen's blocker under a name that hides it.
{
name: "settings — layouts",
- url: "/settings/general/layouts",
+ url: "/settings/preferences/layouts",
anchor: "[data-testid=layout-settings]",
// NOTE: everything here must be a PERSISTED name. This list is load-bearing
// in the wrong direction — a broad token also hides genuinely un-migrated
@@ -210,7 +232,7 @@ const SCREENS: Screen[] = [
},
{
name: "settings — keyboard shortcuts",
- url: "/settings/general/keyboard-shortcuts",
+ url: "/settings/preferences/keyboard-shortcuts",
anchor: "[data-testid=chat-submit-key-card]",
// Modifier/key names label a physical key and are out of scope for
// translation — the same rule the eslint guard's keyboard pattern encodes.
@@ -247,8 +269,8 @@ const SCREENS: Screen[] = [
],
},
{
- name: "settings — task actions",
- url: "/settings/general/task-actions",
+ name: "settings — task behavior",
+ url: "/settings/preferences/task-behavior",
anchor: "[data-testid=archive-confirmation-card]",
},
{
@@ -439,7 +461,7 @@ const SCREENS: Screen[] = [
// product names and config paths on External MCP, and `Ctrl+Shift+M`.
//
// NOT YET: "settings — workspace workflows"
- // (`/settings/workspace/:id/workflows`). The workflow editor's own copy is
+ // (`/settings/workspaces/:id/workflows`). The workflow editor's own copy is
// fully migrated and was verified with this oracle against a live instance,
// and the Workspaces branch of the nav it expands is now migrated too. What
// remains is that the route renders the workspace's own name and its
@@ -835,7 +857,7 @@ test.describe("i18n pseudo-locale coverage", () => {
* so it fired on three screens that were fine.
*/
test("the attribute pass recognizes migrated attribute copy", async ({ testPage }) => {
- const appearance = SCREENS.find((screen) => screen.url === "/settings/general/appearance");
+ const appearance = SCREENS.find((screen) => screen.url === "/settings/preferences/appearance");
if (!appearance) throw new Error("the appearance screen is no longer in SCREENS");
await activatePseudo(testPage, appearance.url);
diff --git a/apps/web/e2e/tests/integrations/azure-devops.spec.ts b/apps/web/e2e/tests/integrations/azure-devops.spec.ts
index 10268b84d0..0c089d7246 100644
--- a/apps/web/e2e/tests/integrations/azure-devops.spec.ts
+++ b/apps/web/e2e/tests/integrations/azure-devops.spec.ts
@@ -163,7 +163,7 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
}) => {
await apiClient.mockAzureDevOpsSeed(MOCK_STATE);
await testPage.goto(
- `/settings/workspace/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
+ `/settings/workspaces/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
);
const projectInput = testPage.locator("#azure-devops-project");
@@ -277,7 +277,7 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
await expect(testPage.getByTestId("azure-devops-feedback-detail")).toContainText("Build");
await testPage.goto(
- `/settings/workspace/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
+ `/settings/workspaces/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
);
await expect(testPage.getByTestId("azure-devops-watch-settings")).toBeVisible();
await testPage.getByTestId("azure-add-work-item-watch").click();
diff --git a/apps/web/e2e/tests/integrations/github-authentication.spec.ts b/apps/web/e2e/tests/integrations/github-authentication.spec.ts
index 83905d51b9..65bcb056d1 100644
--- a/apps/web/e2e/tests/integrations/github-authentication.spec.ts
+++ b/apps/web/e2e/tests/integrations/github-authentication.spec.ts
@@ -3,7 +3,7 @@ import { expectElementsNotToIntersect } from "../../helpers/layout-assertions";
import { GitHubAuthSettingsPage } from "../../pages/github-auth-settings-page";
const settingsPath = (workspaceId: string) =>
- `/settings/workspace/${workspaceId}/integrations/github`;
+ `/settings/workspaces/${workspaceId}/integrations/github`;
test.describe("GitHub workspace authentication", () => {
test("keeps actors isolated while switching workspaces and lists named CLI accounts", async ({
diff --git a/apps/web/e2e/tests/integrations/github-watch-reset.spec.ts b/apps/web/e2e/tests/integrations/github-watch-reset.spec.ts
index 060bfb12ad..209bce8f7e 100644
--- a/apps/web/e2e/tests/integrations/github-watch-reset.spec.ts
+++ b/apps/web/e2e/tests/integrations/github-watch-reset.spec.ts
@@ -147,13 +147,15 @@ test.describe("GitHub review watch reset", () => {
)
.toBeTruthy();
- await testPage.goto("/settings/integrations/github");
+ // The watch table is scoped to the routed workspace, and the install-level
+ // path redirects to whichever workspace is active — pin the seed workspace
+ // that owns the watch.
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
- // The settings page aggregates every workspace's watches into a flat
- // table — find the row for our PR by the repo name it shows, then click
- // its Reset button. Scoping the testid lookup to the row ensures the
- // test fails loudly if a future selector change clones the reset button
- // onto another row.
+ // Find the row for our PR by the repo name it shows, then click its Reset
+ // button. Scoping the testid lookup to the row ensures the test fails
+ // loudly if a future selector change clones the reset button onto another
+ // row.
const watchRow = testPage.getByRole("row", { name: /testorg\/testrepo/i });
const resetButton = watchRow.getByTestId("watch-reset-button");
await expect(resetButton).toBeVisible({ timeout: 10_000 });
diff --git a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts
index 9dc9ea1b46..c968d4a9e7 100644
--- a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts
@@ -25,7 +25,7 @@ test.describe("GitHub workspace settings", () => {
);
try {
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
const row = testPage.getByRole("row").filter({ hasText: "All repositories" });
await expect(row).toBeVisible();
@@ -84,7 +84,7 @@ test.describe("GitHub workspace settings", () => {
{ host: "github.com", login: "workspace-cli", active: true, state: "active" },
]);
await stubGitHubRateLimits(testPage, workspaceId);
- await testPage.goto(`/settings/workspace/${workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Inherit executor Git credentials",
@@ -294,7 +294,7 @@ test.describe("GitHub workspace settings", () => {
},
]);
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
await expect(testPage.getByTestId("github-integration-heading")).toBeVisible();
const issueWatchesHeading = testPage.getByRole("heading", { name: "Issue Watches" });
@@ -358,7 +358,10 @@ test.describe("GitHub workspace settings", () => {
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
- await testPage.goto("/settings/integrations/github");
+ // Pin the seed workspace in the URL: the install-level path redirects to
+ // the active workspace, which may be one another test created — the API
+ // assertions below read seedData.workspaceId.
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
await expect(testPage.getByTestId("github-integration-heading")).toBeVisible();
await testPage.getByTestId("github-scope-mode").click();
diff --git a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts
index 9fc6d84353..fc968ec4d0 100644
--- a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts
+++ b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
+import { SETTINGS_TAKEOVER_TESTID, setSettingsMenuMode } from "../../helpers/settings-menu";
// Covers docs/specs/integrations/enable-disable-toggle.md's nav-visibility
// scenarios: with "Hide disabled integrations from left panel navigation"
@@ -40,13 +41,20 @@ test.describe("hide disabled integrations from left panel navigation", () => {
// The Settings left panel's per-workspace Integrations tree is also part
// of left-panel navigation: with "hide disabled" off (default) the
// disabled-but-configured GitHub entry must stay listed there too.
+ // The tree is opt-in — `flat`, the default menu mode, renders no branches
+ // at all — so choose a tree mode before asserting on its rows.
+ await setSettingsMenuMode(testPage, "accordion");
const { workspaces } = await apiClient.listWorkspaces();
const workspaceId = workspaces[0].id;
- await testPage.goto(`/settings/workspace/${workspaceId}/integrations`);
- const settingsTree = testPage.getByTestId("app-sidebar-settings-mode");
- await expect(settingsTree.getByRole("link", { name: "GitHub", exact: true })).toBeVisible({
- timeout: 10_000,
- });
+ const integrationsPath = `/settings/workspaces/${workspaceId}/integrations`;
+ await testPage.goto(integrationsPath);
+ const settingsTree = testPage.getByTestId(SETTINGS_TAKEOVER_TESTID);
+ // By href, not by name: a workspace another spec created can itself be
+ // called "GitHub", and the row's own name gains a badge once the
+ // integration reports connected — neither of which changes its route.
+ const githubTreeRow = settingsTree.locator(`a[href="${integrationsPath}/github"]`);
+ const azureTreeRow = settingsTree.locator(`a[href="${integrationsPath}/azure-devops"]`);
+ await expect(githubTreeRow).toBeVisible({ timeout: 10_000 });
// Turn "hide disabled" on.
await testPage.goto("/settings/integrations");
@@ -66,11 +74,9 @@ test.describe("hide disabled integrations from left panel navigation", () => {
await expect(testPage.getByRole("link", { name: "GitHub", exact: true })).not.toBeVisible();
// The Settings left-panel Integrations tree hides it as well.
- await testPage.goto(`/settings/workspace/${workspaceId}/integrations`);
- await expect(settingsTree.getByRole("link", { name: "GitHub", exact: true })).not.toBeVisible();
- await expect(settingsTree.getByRole("link", { name: /Azure DevOps/ })).toBeVisible({
- timeout: 10_000,
- });
+ await testPage.goto(integrationsPath);
+ await expect(azureTreeRow).toBeVisible({ timeout: 10_000 });
+ await expect(githubTreeRow).toHaveCount(0);
// Re-enabling GitHub reveals it again even with "hide disabled" still on.
await testPage.goto("/settings/integrations");
diff --git a/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts b/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts
index f0e5407ac5..534f7aefde 100644
--- a/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts
+++ b/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts
@@ -8,7 +8,11 @@ test.describe("integrations index page enable/disable sliders", () => {
test("every integration row has a slider, toggling it does not navigate, and it stays in sync with the own-page slider", async ({
testPage,
}) => {
+ // The install-level index redirects to the active workspace's integrations
+ // tab — that page is now the integrations index the spec describes.
await testPage.goto("/settings/integrations");
+ await expect(testPage).toHaveURL(/\/settings\/workspaces\/[^/]+\/integrations$/);
+ const indexUrl = testPage.url();
// One slider per integration row.
const slugs = ["azure-devops", "github", "gitlab", "jira", "linear", "sentry"];
@@ -22,7 +26,7 @@ test.describe("integrations index page enable/disable sliders", () => {
// Toggling the slider must not navigate away from the index page.
await githubSwitch.click();
await expect(githubSwitch).toHaveAttribute("aria-checked", "false");
- await expect(testPage).toHaveURL(/\/settings\/integrations$/);
+ await expect(testPage).toHaveURL(indexUrl);
await testPage
.getByTestId("settings-floating-save")
diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts
index d199df8470..4ff66ace6d 100644
--- a/apps/web/e2e/tests/integrations/linear-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts
@@ -23,6 +23,7 @@ test.describe("Linear settings", () => {
test("saving the config persists across reload and shows the auth banner", async ({
testPage,
apiClient,
+ seedData,
}) => {
// Seed a single team so the dropdown post-save can populate without an
// empty-list flash. The default-team field is optional, but the team
@@ -30,13 +31,17 @@ test.describe("Linear settings", () => {
await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]);
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ // Pin the workspace: the install-level path redirects to the active
+ // workspace, which need not match the API client's probe target below.
+ await settings.gotoWorkspace(seedData.workspaceId);
await settings.secretInput.fill("lin_api_xxx");
await settings.saveButton.click();
await expect(settings.saveButton).toHaveCount(0);
// Wait for the async post-save probe to write lastOk=true before reloading.
- await apiClient.waitForIntegrationAuthHealthy("linear");
+ await apiClient.waitForIntegrationAuthHealthy("linear", {
+ workspaceId: seedData.workspaceId,
+ });
await testPage.reload();
await settings.secretInput.waitFor();
@@ -48,12 +53,13 @@ test.describe("Linear settings", () => {
test("workspace-scoped route scopes the saved credentials form", async ({
testPage,
apiClient,
+ seedData,
}) => {
const other = await apiClient.createWorkspace("Linear Secondary Workspace");
await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]);
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ await settings.gotoWorkspace(seedData.workspaceId);
await settings.secretInput.fill("lin_api_default");
await settings.saveButton.click();
@@ -94,7 +100,7 @@ test.describe("Linear settings", () => {
await settings.gotoWorkspace(other.id);
await expect(testPage).toHaveURL(
- new RegExp(`/settings/workspace/${other.id}/integrations/linear$`),
+ new RegExp(`/settings/workspaces/${other.id}/integrations/linear$`),
);
await expect(testPage).not.toHaveURL(/[?&]workspace=/);
await expect(settings.secretInput).toHaveValue("");
@@ -103,12 +109,16 @@ test.describe("Linear settings", () => {
test("copy config duplicates the credentials to another workspace", async ({
testPage,
apiClient,
+ seedData,
}) => {
const other = await apiClient.createWorkspace("Linear Copy Target Workspace");
await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]);
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ // Pin the source workspace: the install-level path redirects to the active
+ // workspace, which in a fresh context resolves to the newest workspace —
+ // the copy target we just created.
+ await settings.gotoWorkspace(seedData.workspaceId);
// Configure the source (default) workspace.
await settings.secretInput.fill("lin_api_source");
@@ -156,15 +166,20 @@ test.describe("Linear settings", () => {
test("seeded auth-health failure renders the failed banner on load", async ({
testPage,
apiClient,
+ seedData,
}) => {
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ // Pin the workspace so the UI save and the API-side health probe below
+ // target the same config row.
+ await settings.gotoWorkspace(seedData.workspaceId);
await settings.secretInput.fill("lin_api_xxx");
await settings.saveButton.click();
// Wait for the post-save probe to land BEFORE forcing the failure: the
// probe goroutine could otherwise overwrite our forced lastOk=false back
// to true a few ms after the mockLinearSetAuthHealth call.
- await apiClient.waitForIntegrationAuthHealthy("linear");
+ await apiClient.waitForIntegrationAuthHealthy("linear", {
+ workspaceId: seedData.workspaceId,
+ });
await apiClient.mockLinearSetAuthHealth({
ok: false,
diff --git a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts
index 9dbfce4865..076ed14511 100644
--- a/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts
@@ -128,7 +128,7 @@ test("mobile settings explain PAT scopes and link to the organization token page
testPage,
}) => {
await testPage.goto(
- `/settings/workspace/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
+ `/settings/workspaces/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
);
await testPage.getByTestId("azure-devops-organization").fill("https://dev.azure.com/acme");
@@ -231,7 +231,7 @@ test("mobile board opens a focused column editor without horizontal overflow", a
expect(viewportFits).toBe(true);
await testPage.goto(
- `/settings/workspace/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
+ `/settings/workspaces/${encodeURIComponent(seedData.workspaceId)}/integrations/azure-devops`,
);
await expect(testPage.getByTestId("azure-devops-watch-settings")).toBeVisible();
await testPage.getByTestId("azure-add-work-item-watch").click();
diff --git a/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts
index cc6177cd9e..9f899e2e2a 100644
--- a/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts
@@ -23,7 +23,7 @@ test.describe("Mobile GitHub authentication settings", () => {
});
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByText("mobile-refresh-user", { exact: true })).toBeVisible({
timeout: 15_000,
@@ -64,7 +64,7 @@ test.describe("Mobile GitHub authentication settings", () => {
});
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
await expect(testPage.getByRole("heading", { name: "Workspace GitHub access" })).toBeVisible({
timeout: 15_000,
@@ -133,7 +133,7 @@ test.describe("Mobile GitHub authentication settings", () => {
});
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
const personal = testPage.getByTestId("github-personal-identity");
diff --git a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts
index 3d5eb4e91f..37865cd04e 100644
--- a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts
@@ -17,7 +17,7 @@ test.describe("GitHub workspace settings on mobile", () => {
{ host: "github.com", login: "mobile-cli", active: true, state: "active" },
]);
await stubGitHubRateLimits(testPage, workspaceId);
- await testPage.goto(`/settings/workspace/${workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Inherit executor Git credentials",
@@ -179,7 +179,7 @@ test.describe("GitHub workspace settings on mobile", () => {
testPage,
seedData,
}) => {
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
const issueWatchesHeading = testPage.getByRole("heading", { name: "Issue Watches" });
const repositoryScopeHeading = testPage.getByRole("heading", {
diff --git a/apps/web/e2e/tests/integrations/mobile-integrations-index-layout.spec.ts b/apps/web/e2e/tests/integrations/mobile-integrations-index-layout.spec.ts
index 86940d1e8a..1ddcbc9822 100644
--- a/apps/web/e2e/tests/integrations/mobile-integrations-index-layout.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-integrations-index-layout.spec.ts
@@ -6,7 +6,7 @@ test.describe("integrations settings index layout on mobile", () => {
testPage,
seedData,
}) => {
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations`);
await expectStableIntegrationCardLayout(testPage);
});
diff --git a/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts
index 634dfaf3b3..351b96268d 100644
--- a/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts
@@ -2,17 +2,31 @@ import { test, expect } from "../../fixtures/test-base";
import { LinearSettingsPage } from "../../pages/linear-settings-page";
test.describe("Linear settings on mobile", () => {
- test("workspace-scoped route scopes the credentials form", async ({ testPage, apiClient }) => {
- const other = await apiClient.createWorkspace("Mobile Linear Workspace");
+ test("workspace-scoped route scopes the credentials form", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const target = await apiClient.createWorkspace("Mobile Linear Workspace");
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ await settings.gotoWorkspace(seedData.workspaceId);
await settings.secretInput.fill("lin_api_mobile");
await settings.saveButton.click();
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
- await settings.gotoWorkspace(other.id);
+ const targetResponse = await apiClient.rawRequest(
+ "GET",
+ `/api/v1/linear/config?workspace_id=${target.id}`,
+ );
+ const targetConfig =
+ targetResponse.status === 204
+ ? null
+ : ((await targetResponse.json()) as { hasSecret?: boolean });
+ expect(targetConfig?.hasSecret ?? false).toBe(false);
+
+ await settings.gotoWorkspace(target.id);
await expect(settings.secretInput).toHaveValue("");
await expect(settings.saveButton).toHaveCount(0);
diff --git a/apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts b/apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts
index 66a243676c..97f273d821 100644
--- a/apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts
+++ b/apps/web/e2e/tests/integrations/provider-auth-errors.spec.ts
@@ -147,7 +147,7 @@ test.describe("integration provider authentication errors", () => {
await surface.getByRole("button", { name: "Save changes" }).click();
await expect(testPage).toHaveURL(
- new RegExp(`/settings/workspace/${seedData.workspaceId}/integrations/github$`),
+ new RegExp(`/settings/workspaces/${seedData.workspaceId}/integrations/github$`),
);
await expect(testPage.getByText("GitHub token is invalid", { exact: true })).toBeVisible();
await expect(token).toHaveValue("ghp_invalid");
diff --git a/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts b/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
index 9ec815a4f7..cf1a4ea2c5 100644
--- a/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
+++ b/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
@@ -20,9 +20,15 @@ test.describe("Kanban topbar utilities", () => {
const settingsMode = testPage.getByTestId("app-sidebar-settings-mode");
await expect(settingsMode).toBeVisible();
- // Clicking a settings leaf navigates to its /settings/... page.
- await settingsMode.getByRole("link", { name: "Automations" }).click();
- await expect(testPage).toHaveURL(/\/settings\//);
+ // Clicking a settings leaf navigates to its /settings/... page. Pick a row
+ // from the static two-level menu: `flat` is the default mode and renders no
+ // branches, so workspace-nested leaves are not there to click.
+ await settingsMode.getByRole("link", { name: "Task Behavior" }).click();
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/task-behavior$/);
+
+ // While the sidebar shows the settings tree its Home row is gone, so the
+ // topbar home crumb stays visible even at desktop width.
+ await expect(testPage.getByTestId("topbar-phone-home")).toBeVisible();
});
test("system health button is hidden when there are no issues", async ({ testPage, backend }) => {
diff --git a/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts b/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts
index 893fa32b76..df03300116 100644
--- a/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts
+++ b/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts
@@ -205,7 +205,8 @@ test.describe("Toggle sidebar shortcut (global)", () => {
test("toggles the AppSidebar on a Settings page", async ({ testPage, apiClient, seedData }) => {
await bindToggleSidebar(apiClient, seedData);
- await testPage.goto("/settings/general");
+ // A leaf, not the `/settings/preferences` prefix, which only redirects here.
+ await testPage.goto("/settings/preferences/appearance");
await expectShortcutTogglesSidebar(testPage, testPage.getByTestId("app-sidebar"));
});
diff --git a/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts b/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts
index 509738ee95..635d3ac3d7 100644
--- a/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts
+++ b/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts
@@ -26,7 +26,7 @@ import {
removeFakeKotlinLsp,
} from "./lsp-e2e-helpers";
-const EDITORS_SETTINGS_PATH = "/settings/general/editors";
+const EDITORS_SETTINGS_PATH = "/settings/preferences/terminal-editors";
const RESERVED_SOURCE_PATH = "Main # query? 100%.kt";
const DEFINITION_PARENT_PATH = "nested/references";
const DEFINITION_TARGET_PATH = `${DEFINITION_PARENT_PATH}/Definition Target # query? 100%.kt`;
diff --git a/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts b/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts
index 15a6d611c4..edae275bab 100644
--- a/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts
+++ b/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts
@@ -165,7 +165,7 @@ test.describe("Mobile LSP boundaries", () => {
lsp_auto_start_languages: [],
lsp_status_location: "toolbar",
});
- await testPage.goto("/settings/general/editors");
+ await testPage.goto("/settings/preferences/terminal-editors");
await expect(testPage.getByRole("heading", { name: "Editors", exact: true })).toBeVisible();
const kotlinCard = testPage.getByTestId("lsp-language-card-kotlin");
diff --git a/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts b/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts
new file mode 100644
index 0000000000..d3eeceb527
--- /dev/null
+++ b/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts
@@ -0,0 +1,45 @@
+import { test, expect } from "../../fixtures/office-fixture";
+
+// Regression coverage for the PageShell migration: Office used to render a
+// title-only topbar with no menu and no home link, so on a phone the entire
+// Office section was unreachable and un-exitable (browser back excepted).
+test.describe("Office mobile navigation", () => {
+ test("offers office sections and a home row in the shared nav sheet", async ({
+ testPage,
+ officeSeed: _,
+ }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/office/tasks");
+
+ const trigger = testPage.getByTestId("app-nav-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+
+ const sheet = testPage.getByTestId("app-nav-sheet");
+ await expect(sheet).toBeVisible();
+
+ // Office's own sections render above the global destinations.
+ await expect(sheet.getByRole("link", { name: /Routines/ })).toBeVisible();
+ await expect(sheet.getByRole("link", { name: /Skills/ })).toBeVisible();
+ // Global destinations come from the navigation manifest.
+ await expect(sheet.getByRole("link", { name: "Stats" })).toBeVisible();
+ await expect(sheet.getByRole("link", { name: "Settings" })).toBeVisible();
+
+ // Home from inside Office lands on the office dashboard, not kanban.
+ await sheet.getByRole("link", { name: "Home", exact: true }).click();
+ await expect(sheet).not.toBeVisible();
+ await expect(testPage).toHaveURL(/\/office(?:\?.*)?$/);
+ });
+
+ test("navigates between office sections from the sheet", async ({ testPage, officeSeed: _ }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/office/tasks");
+
+ await testPage.getByTestId("app-nav-trigger").click();
+ const sheet = testPage.getByTestId("app-nav-sheet");
+ await sheet.getByRole("link", { name: /Routines/ }).click();
+
+ await expect(sheet).not.toBeVisible();
+ await expect(testPage).toHaveURL(/\/office\/routines$/);
+ });
+});
diff --git a/apps/web/e2e/tests/office/workspace-picker.spec.ts b/apps/web/e2e/tests/office/workspace-picker.spec.ts
index dc2242cad7..a027c2bcd1 100644
--- a/apps/web/e2e/tests/office/workspace-picker.spec.ts
+++ b/apps/web/e2e/tests/office/workspace-picker.spec.ts
@@ -63,6 +63,6 @@ test.describe("Office workspace picker", () => {
await testPage.getByTestId("sidebar-workspace-trigger").click();
await testPage.getByRole("menuitem", { name: "New kanban workspace" }).click();
- await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
+ await expect(testPage).toHaveURL(/\/settings\/workspaces$/, { timeout: 10_000 });
});
});
diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts
index 4e6bd6c7e6..7b78d1ae2f 100644
--- a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts
+++ b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts
@@ -139,7 +139,9 @@ test.describe("Mobile plugin navigation", () => {
const home = testPage.getByTestId("topbar-phone-home");
await expect(home).toBeVisible();
await home.click();
- await expect(testPage).toHaveURL(/\/\?home=overview$/);
+ // The crumb resolves through the navigation manifest, which keeps the
+ // active workspace instead of dropping to the workspace-less overview.
+ await expect(testPage).toHaveURL(/\/\?home=overview(&workspaceId=[^&]+)?$/);
// Desktop parity reference: the same item in the always-visible sidebar.
const desktop = await openDesktopClient(browser, backend.frontendUrl, backend.port);
diff --git a/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts b/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts
index da2997ab6c..88a48a9798 100644
--- a/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts
+++ b/apps/web/e2e/tests/pr/pr-open-shortcut.spec.ts
@@ -305,7 +305,7 @@ test.describe("Open-task-PR keyboard shortcut", () => {
});
test("shortcut is rebindable from the general settings page", async ({ testPage, prCapture }) => {
- await testPage.goto("/settings/general/keyboard-shortcuts");
+ await testPage.goto("/settings/preferences/keyboard-shortcuts");
const recorder = testPage.getByTestId("shortcut-recorder-OPEN_TASK_PR");
await recorder.scrollIntoViewIfNeeded();
await expect(recorder).toBeVisible();
diff --git a/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts
index b970637618..c5c593f1c1 100644
--- a/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts
+++ b/apps/web/e2e/tests/pr/pr-watcher-cleanup-policy.spec.ts
@@ -535,7 +535,9 @@ test.describe("PR watcher cleanup policy", () => {
},
]);
- await testPage.goto("/settings/integrations/github");
+ // Pin the seed workspace: cleanup runs against the routed workspace, and
+ // the install-level path redirects to whichever workspace is active.
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
const cleanupButton = testPage.getByRole("button", { name: /clean up merged/i });
await expect(cleanupButton).toBeVisible({ timeout: 10_000 });
await cleanupButton.click();
diff --git a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts
index 3e757a3513..effd841b76 100644
--- a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts
+++ b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts
@@ -26,7 +26,7 @@ function initializeRepository(directory: string, gitEnv: NodeJS.ProcessEnv): Git
}
test("@search keeps workspace modes out of non-task routes", async ({ testPage }) => {
- await testPage.goto("/settings/general");
+ await testPage.goto("/settings/preferences/appearance");
const dialog = testPage.getByRole("dialog");
await testPage.keyboard.press(`${MODIFIER}+Shift+f`);
diff --git a/apps/web/e2e/tests/settings/agent-profile-disable.spec.ts b/apps/web/e2e/tests/settings/agent-profile-disable.spec.ts
index c367545596..54158074fd 100644
--- a/apps/web/e2e/tests/settings/agent-profile-disable.spec.ts
+++ b/apps/web/e2e/tests/settings/agent-profile-disable.spec.ts
@@ -6,8 +6,9 @@ import { KanbanPage } from "../../pages/kanban-page";
*
* - The profile settings header toggle persists across reload.
* - A disabled profile is hidden from the new-task dialog agent selector.
- * - The /settings/agents profile list shows the toggle and re-enabling there
- * restores the profile to task creation.
+ * - The /settings/agents profile list reports the result as a Disabled badge on
+ * the row, and re-enabling restores the profile to task creation. The list has
+ * no toggle of its own: the header toggle is the single control.
*
* Uses the seeded default profile (like agent-profile-acp.spec.ts) rather
* than creating one — the profile editor page reads from the agents list
@@ -91,7 +92,7 @@ test.describe("Agent profile — enable/disable", () => {
}
});
- test("settings list toggle disables and re-enables the profile", async ({
+ test("the settings list reflects disabling and re-enabling a profile", async ({
testPage,
apiClient,
}) => {
@@ -101,32 +102,48 @@ test.describe("Agent profile — enable/disable", () => {
const agent = agents[0];
const profile = agent.profiles[0];
+ // The list row is a card whose whole surface is an overlay link named by
+ // the profile; matching the card through that link keeps the row unique
+ // even when one profile's name is a prefix of another's.
+ const row = testPage
+ .getByTestId("agent-profile-row")
+ .filter({ has: testPage.getByRole("link", { name: profile.name, exact: true }) });
+ const disabledBadge = row.getByText("Disabled", { exact: true });
+
+ // The list carries no toggle of its own — the header toggle on the profile
+ // page is the single control, and the row reports the result as a badge.
+ const setEnabledFromProfilePage = async (enable: boolean) => {
+ await row.getByTestId("agent-profile-row-link").click();
+ await expect(testPage).toHaveURL(new RegExp(`/settings/agents/.+/profiles/${profile.id}$`));
+ const headerToggle = testPage.getByTestId("profile-enabled-toggle");
+ await expect(headerToggle).toBeVisible({ timeout: 15_000 });
+ await expect(headerToggle).toHaveAttribute("data-state", enable ? "unchecked" : "checked");
+ await headerToggle.click();
+ await expect(headerToggle).toHaveAttribute("data-state", enable ? "checked" : "unchecked");
+
+ const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i });
+ await expect(saveButton).toBeEnabled({ timeout: 10_000 });
+ await saveButton.click();
+ await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 });
+
+ await testPage.goto("/settings/agents");
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ };
+
try {
await testPage.goto("/settings/agents");
- const rowToggle = testPage.getByTestId(`profile-enabled-toggle-${profile.id}`);
- await expect(rowToggle).toBeVisible({ timeout: 15_000 });
- await expect(rowToggle).toHaveAttribute("data-state", "checked");
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await expect(disabledBadge).toHaveCount(0);
- // Disabling from the list saves immediately (no save button) and must
- // not navigate away from the page.
- await rowToggle.click();
- await expect(rowToggle).toHaveAttribute("data-state", "unchecked");
- await expect(testPage).toHaveURL(/\/settings\/agents$/);
+ await setEnabledFromProfilePage(false);
+ await expect(disabledBadge).toBeVisible();
// Persisted across reload.
await testPage.reload();
- await expect(testPage.getByTestId(`profile-enabled-toggle-${profile.id}`)).toHaveAttribute(
- "data-state",
- "unchecked",
- { timeout: 15_000 },
- );
+ await expect(disabledBadge).toBeVisible({ timeout: 15_000 });
- // Re-enable from the list and confirm the state flips back.
- await testPage.getByTestId(`profile-enabled-toggle-${profile.id}`).click();
- await expect(testPage.getByTestId(`profile-enabled-toggle-${profile.id}`)).toHaveAttribute(
- "data-state",
- "checked",
- );
+ await setEnabledFromProfilePage(true);
+ await expect(disabledBadge).toHaveCount(0);
// Re-enabling restores the profile to task creation. With a single
// enabled profile the dialog auto-selects it and hides the selector;
diff --git a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts
index 25f8637b6e..6724a04c8a 100644
--- a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts
+++ b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts
@@ -25,7 +25,8 @@ test.describe("managed agent runtime updates", () => {
expect(profileActionBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
expect(Math.abs(profileActionBox!.y - triggerBox!.y)).toBeLessThanOrEqual(8);
- expect(triggerBox!.x).toBeGreaterThan(profileActionBox!.x);
+ // The update trigger leads the row; "Setup Profile" follows it.
+ expect(triggerBox!.x).toBeLessThan(profileActionBox!.x);
expect(profileActionBox!.height).toBe(28);
expect(triggerBox!.height).toBe(profileActionBox!.height);
diff --git a/apps/web/e2e/tests/settings/config-chat-popover.spec.ts b/apps/web/e2e/tests/settings/config-chat-popover.spec.ts
index b9db67a90d..600d7a714b 100644
--- a/apps/web/e2e/tests/settings/config-chat-popover.spec.ts
+++ b/apps/web/e2e/tests/settings/config-chat-popover.spec.ts
@@ -56,7 +56,7 @@ test.describe("Configuration Chat", () => {
const nextLayout = initialLayout === "tree" ? "flat" : "tree";
try {
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
const layout = testPage.getByTestId("changes-panel-layout-select");
await layout.click();
await testPage
diff --git a/apps/web/e2e/tests/settings/copy-files-symlink-helpers.ts b/apps/web/e2e/tests/settings/copy-files-symlink-helpers.ts
index f32bb88bd3..1f5df2dc21 100644
--- a/apps/web/e2e/tests/settings/copy-files-symlink-helpers.ts
+++ b/apps/web/e2e/tests/settings/copy-files-symlink-helpers.ts
@@ -10,7 +10,7 @@ export async function configureSymlinkAndCreateTask(options: {
title: string;
}) {
const { page, apiClient, seedData, title } = options;
- await page.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
+ await page.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
await expect(page.getByRole("button", { name: "Add Local Repository" })).toBeVisible();
const repositoriesResponse = await apiClient.rawRequest(
diff --git a/apps/web/e2e/tests/settings/github-app-registration.spec.ts b/apps/web/e2e/tests/settings/github-app-registration.spec.ts
index 124910c8a8..9c29a9e3cf 100644
--- a/apps/web/e2e/tests/settings/github-app-registration.spec.ts
+++ b/apps/web/e2e/tests/settings/github-app-registration.spec.ts
@@ -277,7 +277,7 @@ test.describe("Workspace GitHub App onboarding", () => {
await testPage.evaluate((path) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new Event("kandev:navigation"));
- }, `/settings/workspace/${otherWorkspace.id}/integrations/github`);
+ }, `/settings/workspaces/${otherWorkspace.id}/integrations/github`);
await expect(settings.automation().getByText("home-user", { exact: true })).toBeVisible({
timeout: 15_000,
});
diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
index 3ac1407345..5ddda16939 100644
--- a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
+++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
-const KEYBOARD_SETTINGS_PATH = "/settings/general/keyboard-shortcuts";
+const KEYBOARD_SETTINGS_PATH = "/settings/preferences/keyboard-shortcuts";
test.describe("Keyboard Shortcuts Settings", () => {
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/settings/mobile-config-chat-popover.spec.ts b/apps/web/e2e/tests/settings/mobile-config-chat-popover.spec.ts
index 4ef5d8b7da..36161facdc 100644
--- a/apps/web/e2e/tests/settings/mobile-config-chat-popover.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-config-chat-popover.spec.ts
@@ -11,7 +11,7 @@ test.describe("Mobile config chat popover", () => {
const nextLayout = initialLayout === "tree" ? "flat" : "tree";
try {
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
const layout = testPage.getByTestId("changes-panel-layout-select");
await layout.tap();
await testPage
diff --git a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts
index b9386ad2c1..75beeba4ee 100644
--- a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts
@@ -86,7 +86,7 @@ test.describe("Mobile general settings", () => {
prCapture,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const autoScrollControl = testPage.getByRole("switch", {
name: "Show transcript auto-scroll control",
@@ -128,7 +128,7 @@ test.describe("Mobile general settings", () => {
const nextLayout = initialLayout === "tree" ? "flat" : "tree";
try {
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
const layout = testPage.getByTestId("changes-panel-layout-select");
await layout.click();
await testPage
@@ -192,40 +192,40 @@ test.describe("Mobile general settings", () => {
}
});
- test("opens a dedicated General settings page from the overview", async ({ testPage }) => {
- await testPage.goto("/settings/general");
+ test("opens a dedicated Preferences page from the settings index", async ({ testPage }) => {
+ // `/settings` is the index on a phone: the settings menu as a page.
+ await testPage.goto("/settings");
- await expect(testPage.getByRole("link", { name: /Terminal/ })).toBeVisible({
+ await expect(testPage.getByRole("link", { name: /Terminal & Editors/ })).toBeVisible({
timeout: 15_000,
});
- await testPage.getByRole("link", { name: /Terminal/ }).click();
+ await testPage.getByRole("link", { name: /Terminal & Editors/ }).click();
- await expect(testPage).toHaveURL(/\/settings\/general\/terminal$/);
- await expect(testPage.getByRole("heading", { name: "Terminal", exact: true })).toBeVisible();
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/terminal-editors$/);
await expect(testPage.getByTestId("terminal-font-select")).toBeVisible();
await expect(testPage.getByTestId("terminal-font-size-input")).toBeVisible();
});
- test("opens Settings navigation and returns home from a nested settings page", async ({
- testPage,
- }) => {
- await testPage.goto("/settings/general/terminal");
+ test("moves between settings pages and back home without a nav drawer", async ({ testPage }) => {
+ await testPage.goto("/settings/preferences/terminal-editors");
- await expect(testPage.getByRole("heading", { name: "Terminal", exact: true })).toBeVisible();
+ await expect(testPage.getByTestId("terminal-font-select")).toBeVisible();
+ // Settings has no hamburger: the page is the navigation, reached through the
+ // breadcrumb's Settings crumb.
+ await expect(testPage.getByTestId("app-nav-trigger")).toHaveCount(0);
- await testPage.getByTestId("settings-mobile-menu-button").click();
- const menu = testPage.getByTestId("settings-mobile-menu");
- await expect(menu).toBeVisible();
+ await testPage.getByRole("link", { name: "Settings", exact: true }).click();
+ const index = testPage.getByTestId("settings-index");
+ await expect(index).toBeVisible();
- await menu.getByRole("link", { name: "Appearance" }).click();
+ await index.getByRole("link", { name: "Appearance" }).click();
- await expect(testPage).toHaveURL(/\/settings\/general\/appearance$/);
- await expect(menu).not.toBeVisible();
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/appearance$/);
await expect(testPage.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible();
- await testPage.getByTestId("settings-mobile-menu-button").click();
- await testPage.getByTestId("settings-mobile-menu").getByRole("link", { name: "Home" }).click();
+ // The topbar home crumb leaves the settings surface entirely.
+ await testPage.getByTestId("topbar-phone-home").click();
await expect(testPage).toHaveURL(/\/(?:\?.*)?$/);
await expect(testPage.getByTestId("kanban-board")).toBeVisible();
diff --git a/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts b/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts
index f23cfd18b3..4ccf55fdfe 100644
--- a/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts
@@ -28,7 +28,7 @@ test.describe("Mobile layout profiles", () => {
// before the user ever chose anything. See layout-editor-toolbar.tsx
// AddPanelAction for the fix (deferred, click-driven open state).
const layouts = new LayoutSettingsPage(testPage);
- await layouts.openFromMobileMenu();
+ await layouts.openFromSettingsIndex();
const trigger = testPage.getByTestId("layout-editor-add-panel").getByRole("button", {
name: "Add panel",
});
@@ -43,7 +43,7 @@ test.describe("Mobile layout profiles", () => {
prCapture,
}) => {
const layouts = new LayoutSettingsPage(testPage);
- await layouts.openFromMobileMenu();
+ await layouts.openFromSettingsIndex();
await expect(layouts.editor.locator(".dv-tab", { hasText: "PR Details" })).toHaveCount(0);
await layouts.addPanel("PR Details", true);
@@ -78,7 +78,7 @@ test.describe("Mobile layout profiles", () => {
apiClient,
}) => {
const layouts = new LayoutSettingsPage(testPage);
- await layouts.openFromMobileMenu();
+ await layouts.openFromSettingsIndex();
await assertNoDocumentHorizontalOverflow(testPage, "layouts page");
await assertNoDescendantOverflowsRight(layouts.root, "layouts settings");
diff --git a/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts b/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts
index 0d02cedde6..c78fd98afc 100644
--- a/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-notification-events.spec.ts
@@ -46,7 +46,7 @@ test.describe("Mobile notification event settings", () => {
);
try {
- await testPage.goto("/settings/general/notifications");
+ await testPage.goto("/settings/preferences/notifications");
const eventContainer = testPage.getByTestId("notification-events-mobile-list");
const turnFinished = testPage.getByRole("checkbox", {
diff --git a/apps/web/e2e/tests/settings/mobile-repository-secrets.spec.ts b/apps/web/e2e/tests/settings/mobile-repository-secrets.spec.ts
index f695e0e417..f1352dfac7 100644
--- a/apps/web/e2e/tests/settings/mobile-repository-secrets.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-repository-secrets.spec.ts
@@ -11,14 +11,15 @@ test.describe("Mobile repository secrets", () => {
prCapture,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}`);
- await testPage.getByTestId("settings-mobile-menu-button").click();
- const menu = testPage.getByTestId("settings-mobile-menu");
- await expect(menu).toBeVisible();
- await menu.locator(`a[href="/settings/workspace/${seedData.workspaceId}/secrets"]`).click();
+ // Workspace sections are reached through the shell's tab strip. Settings
+ // renders no nav sheet, so there is no menu button to open here.
+ const tabs = testPage.getByTestId("workspace-settings-tabs");
+ await expect(tabs).toBeVisible();
+ await tabs.getByRole("link", { name: "Secrets", exact: true }).click();
await expect(testPage).toHaveURL(
- new RegExp(`/settings/workspace/${seedData.workspaceId}/secrets$`),
+ new RegExp(`/settings/workspaces/${seedData.workspaceId}/secrets$`),
);
await testPage.getByRole("button", { name: "Add secret", exact: true }).click();
@@ -44,13 +45,12 @@ test.describe("Mobile repository secrets", () => {
if (!workspaceSecret) throw new Error("mobile workspace secret was not persisted");
try {
- await testPage.getByTestId("settings-mobile-menu-button").click();
await testPage
- .getByTestId("settings-mobile-menu")
+ .getByTestId("workspace-settings-tabs")
.getByRole("link", { name: "Repositories", exact: true })
.click();
await expect(testPage).toHaveURL(
- new RegExp(`/settings/workspace/${seedData.workspaceId}/repositories$`),
+ new RegExp(`/settings/workspaces/${seedData.workspaceId}/repositories$`),
);
await testPage.getByRole("heading", { name: "E2E Repo", exact: true }).click();
diff --git a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts
index 0eb68a3160..54e38d2b0a 100644
--- a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts
@@ -24,12 +24,8 @@ test.describe("Mobile resource metrics display", () => {
});
test("renders simplified metrics in the Status drawer", async ({ testPage }) => {
- await testPage.goto("/settings/general/terminal");
- await testPage.getByTestId("settings-mobile-menu-button").click();
- await testPage
- .getByTestId("settings-mobile-menu")
- .getByRole("link", { name: "Appearance" })
- .click();
+ await testPage.goto("/settings");
+ await testPage.getByTestId("settings-index").getByRole("link", { name: "Appearance" }).click();
const showMetrics = testPage.getByRole("switch", { name: "Show host metrics in status bar" });
const simplified = testPage.getByRole("switch", { name: "Simplified metrics" });
diff --git a/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts
index ea8fc540ec..5b32591e99 100644
--- a/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-settings-discovery.spec.ts
@@ -6,72 +6,50 @@ function settingsResult(scope: Locator, label: string) {
}
test.describe("Mobile settings discovery", () => {
- test("searches in the Settings sheet and reveals the exact control", async ({ testPage }) => {
+ test("searches from the settings index and reveals the exact control", async ({ testPage }) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto("/settings/general/appearance");
+ // The index is the settings navigation on a phone; the search field floats
+ // over it in thumb reach rather than living in a drawer.
+ await testPage.goto("/settings");
- 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 index = testPage.getByTestId("settings-index");
+ const scope = index.getByTestId("settings-search");
+ await expect(scope).toBeVisible();
+ const searchBoxBounds = await scope.boundingBox();
+ expect(searchBoxBounds).not.toBeNull();
+ expect(searchBoxBounds!.x).toBeGreaterThanOrEqual(0);
+ expect(searchBoxBounds!.x + searchBoxBounds!.width).toBeLessThanOrEqual(390);
- 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" });
+ const search = scope.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 clear = scope.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");
+ const result = settingsResult(
+ index.getByTestId("settings-search-results"),
+ "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$/);
+ await expect(testPage).toHaveURL(
+ /\/settings\/preferences\/terminal-editors#setting-terminal-font-size$/,
+ );
const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
- expect(
- await testPage
- .getByTestId("settings-scroll-container")
- .evaluate((element) => getComputedStyle(element).overflowY),
- ).toBe("auto");
});
});
diff --git a/apps/web/e2e/tests/settings/mobile-settings-index.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-index.spec.ts
new file mode 100644
index 0000000000..3bf00975cb
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-settings-index.spec.ts
@@ -0,0 +1,78 @@
+import { test, expect } from "../../fixtures/test-base";
+
+// Below `md` there is no sidebar, so `/settings` is the settings list itself —
+// the same tree the nav sheet shows, rendered as a real route rather than an
+// overlay the app has to open for you.
+test.describe("Settings index on a phone", () => {
+ test("renders the settings tree as the page and navigates from it", async ({ testPage }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/settings");
+
+ const index = testPage.getByTestId("settings-index");
+ await expect(index).toBeVisible();
+ // Stays on /settings: no desktop-style handoff, and nothing to go Back past.
+ await expect(testPage).toHaveURL(/\/settings$/);
+
+ await index.getByRole("link", { name: /Terminal & Editors/ }).click();
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/terminal-editors$/);
+ await expect(testPage.getByTestId("terminal-font-select")).toBeVisible();
+ });
+
+ test("offers no nav drawer anywhere in settings", async ({ testPage }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+
+ for (const path of [
+ "/settings",
+ "/settings/preferences/terminal-editors",
+ "/settings/prompts",
+ ]) {
+ await testPage.goto(path);
+ await expect(testPage.getByTestId("settings-scroll-container")).toBeVisible();
+ // A sheet here would offer the list the index page already is.
+ await expect(testPage.getByTestId("app-nav-trigger")).toHaveCount(0);
+ }
+ });
+
+ test("keeps the search field in thumb reach at the bottom of the index", async ({ testPage }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/settings");
+
+ // The desktop sidebar's tree is in the DOM too (hidden below md) and carries
+ // the same testid, so scope to the page's copy.
+ const search = testPage.getByTestId("settings-index").getByTestId("settings-search");
+ await expect(search).toBeVisible();
+
+ const [box, viewport] = [
+ await search.boundingBox(),
+ await testPage.evaluate(() => ({ w: innerWidth, h: innerHeight })),
+ ];
+ expect(box).not.toBeNull();
+ expect(box!.y).toBeGreaterThan(viewport.h / 2);
+ expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.h);
+
+ // Centred, and clear of the config-chat button that shares that corner —
+ // the field gives up width for both rather than trading one against the other.
+ const centreOffset = Math.abs(box!.x + box!.width / 2 - viewport.w / 2);
+ expect(centreOffset).toBeLessThanOrEqual(2);
+
+ const chat = testPage.getByRole("button", { name: "Configuration Chat" });
+ const chatBox = await chat.boundingBox();
+ expect(chatBox).not.toBeNull();
+ expect(box!.x + box!.width).toBeLessThanOrEqual(chatBox!.x);
+ // Sharing a centre line with it, so the pair reads as one row.
+ expect(box!.y + box!.height / 2).toBeCloseTo(chatBox!.y + chatBox!.height / 2, 0);
+
+ // And no dead scroll under the last row: the index floats a search field,
+ // not a settings form's Save action.
+ const container = testPage.getByTestId("settings-scroll-container");
+ const padding = await container.evaluate((el) => getComputedStyle(el).paddingBottom);
+ expect(Number.parseFloat(padding)).toBeLessThan(120);
+
+ // Still filters the list it floats over.
+ await search.getByRole("searchbox", { name: "Search settings" }).fill("terminal font size");
+ await expect(
+ testPage.getByTestId("settings-index").getByTestId("settings-search-results"),
+ ).toBeVisible();
+ });
+});
diff --git a/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts
index 357aedb967..771a3281f5 100644
--- a/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts
@@ -14,11 +14,8 @@ test.describe("Mobile settings navigation", () => {
const takeover = testPage.getByTestId("app-sidebar-settings-mode");
await expect(takeover).toBeHidden();
-
- await testPage.getByTestId("settings-mobile-menu-button").click();
- await expect(
- testPage.getByTestId("settings-mobile-menu").locator('a[href="/settings/integrations"]'),
- ).toBeVisible();
+ await expect(testPage.getByTestId("settings-mobile-menu-button")).toHaveCount(0);
+ await expect(testPage.getByTestId("settings-mobile-menu")).toHaveCount(0);
});
test("shows the workspace breadcrumb and orders secrets after automations", async ({
@@ -26,15 +23,17 @@ test.describe("Mobile settings navigation", () => {
seedData,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/secrets`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/secrets`);
await expect(testPage.getByRole("navigation", { name: "Breadcrumb" })).toContainText(
"E2E Workspace",
);
- await testPage.getByTestId("settings-mobile-menu-button").click();
- const menu = testPage.getByTestId("settings-mobile-menu");
- const hrefs = await menu
+ // The tab strip is the only workspace-level navigation: the settings menu
+ // holds no workspace rows, and Settings renders no nav sheet at all — on a
+ // phone `/settings` is the menu, as its own route.
+ const hrefs = await testPage
+ .getByTestId("workspace-settings-tabs")
.locator("a")
.evaluateAll((links) =>
links
@@ -43,9 +42,9 @@ test.describe("Mobile settings navigation", () => {
);
const automationsIndex = hrefs.indexOf(
- `/settings/workspace/${seedData.workspaceId}/automations`,
+ `/settings/workspaces/${seedData.workspaceId}/automations`,
);
- const secretsIndex = hrefs.indexOf(`/settings/workspace/${seedData.workspaceId}/secrets`);
+ const secretsIndex = hrefs.indexOf(`/settings/workspaces/${seedData.workspaceId}/secrets`);
expect(automationsIndex).toBeGreaterThanOrEqual(0);
expect(secretsIndex).toBeGreaterThan(automationsIndex);
});
diff --git a/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts
index d3d8f691f1..d8630e4542 100644
--- a/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
-const APPEARANCE_PATH = "/settings/general/appearance";
+const APPEARANCE_PATH = "/settings/preferences/appearance";
test.describe("Mobile startup page", () => {
test("uses a touch-friendly saved preference, resumes the task, and returns to overview", async ({
diff --git a/apps/web/e2e/tests/settings/mobile-workspace-settings-switcher.spec.ts b/apps/web/e2e/tests/settings/mobile-workspace-settings-switcher.spec.ts
new file mode 100644
index 0000000000..723430c346
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-workspace-settings-switcher.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from "../../fixtures/test-base";
+
+/**
+ * Same control, phone presentation: the shared dropdown gets the global
+ * inset bottom-sheet treatment below 640px, so the rows are reachable with a
+ * thumb instead of being a desktop popover squeezed against the header.
+ */
+test.describe("Mobile workspace settings switcher", () => {
+ test("opens as a bottom sheet and switches workspace on the same tab", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const other = await apiClient.createWorkspace("Mobile Switcher Workspace");
+
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
+ await testPage.getByTestId("workspace-settings-switcher").click();
+
+ const menu = testPage.locator('[data-slot="dropdown-menu-content"]:visible');
+ const activeRow = menu.getByTestId(`workspace-settings-switcher-item-${seedData.workspaceId}`);
+ const targetRow = menu.getByTestId(`workspace-settings-switcher-item-${other.id}`);
+ await expect(activeRow).toBeVisible();
+ await expect(activeRow).toContainText("Active");
+ await menu.evaluate((element) =>
+ Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)),
+ );
+
+ const [menuBox, rowBox, viewport] = await Promise.all([
+ menu.boundingBox(),
+ targetRow.boundingBox(),
+ testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
+ ]);
+ if (!menuBox || !rowBox) throw new Error("mobile workspace switcher has no layout box");
+ expect(menuBox.x).toBeGreaterThanOrEqual(7);
+ expect(menuBox.x).toBeLessThanOrEqual(10);
+ expect(menuBox.width).toBeGreaterThanOrEqual(viewport.width - 20);
+ expect(viewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(7);
+ expect(viewport.height - (menuBox.y + menuBox.height)).toBeLessThanOrEqual(10);
+ expect(rowBox.height).toBeGreaterThanOrEqual(36);
+
+ await targetRow.tap();
+
+ await expect(testPage).toHaveURL(new RegExp(`/settings/workspaces/${other.id}/repositories$`));
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toContainText(
+ "Mobile Switcher Workspace",
+ );
+ });
+});
diff --git a/apps/web/e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts b/apps/web/e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts
new file mode 100644
index 0000000000..a8a3f48132
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-workspace-settings-tabs.spec.ts
@@ -0,0 +1,44 @@
+import { test, expect } from "../../fixtures/test-base";
+import type { Locator, Page } from "@playwright/test";
+
+/**
+ * On a phone the workspace settings tab strip is a horizontally scrolling row
+ * of pills. Every tab is its own route, so navigating remounts the strip —
+ * without the shell's scroll restoration the strip snaps back to its start
+ * and the pill you just tapped ends up off-screen.
+ */
+async function expectPillInsideStrip(strip: Locator, pill: Locator) {
+ const [stripBox, pillBox] = await Promise.all([strip.boundingBox(), pill.boundingBox()]);
+ if (!stripBox || !pillBox) throw new Error("tab strip or pill has no layout box");
+ // Fully inside the strip's viewport, not clipped at either edge.
+ expect(pillBox.x).toBeGreaterThanOrEqual(stripBox.x - 1);
+ expect(pillBox.x + pillBox.width).toBeLessThanOrEqual(stripBox.x + stripBox.width + 1);
+}
+
+async function expectActivePillVisible(testPage: Page, label: string) {
+ const strip = testPage.getByTestId("workspace-settings-tabs");
+ const pill = strip.locator('[aria-current="page"]');
+ await expect(pill).toContainText(label);
+ await expectPillInsideStrip(strip, pill);
+}
+
+test.describe("Mobile workspace settings tab strip", () => {
+ test("keeps the selected pill in view when landing on and switching tabs", async ({
+ testPage,
+ seedData,
+ }) => {
+ // Landing directly on the last tab: a fresh strip would sit at its start
+ // with the Secrets pill clipped off the right edge.
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/secrets`);
+ await expectActivePillVisible(testPage, "Secrets");
+
+ // Switching pills remounts the strip on the new route; the pill just
+ // tapped must still be in view rather than the strip jumping to its start.
+ const strip = testPage.getByTestId("workspace-settings-tabs");
+ await strip.getByRole("link", { name: "Automations" }).tap();
+ await expect(testPage).toHaveURL(
+ new RegExp(`/settings/workspaces/${seedData.workspaceId}/automations$`),
+ );
+ await expectActivePillVisible(testPage, "Automations");
+ });
+});
diff --git a/apps/web/e2e/tests/settings/repository-add-local-helpers.ts b/apps/web/e2e/tests/settings/repository-add-local-helpers.ts
index a14ab0b38c..669956b77b 100644
--- a/apps/web/e2e/tests/settings/repository-add-local-helpers.ts
+++ b/apps/web/e2e/tests/settings/repository-add-local-helpers.ts
@@ -37,7 +37,7 @@ export async function addExplicitLocalRepository(options: {
const relativeToBackendHome = path.relative(backend.tmpDir, repoPath);
expect(relativeToBackendHome.startsWith(`..${path.sep}`)).toBe(true);
- await page.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
+ await page.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
await page.getByRole("button", { name: "Add Local Repository" }).click();
const dialog = page.getByRole("dialog", { name: "Add Local Repository" });
diff --git a/apps/web/e2e/tests/settings/repository-delete.spec.ts b/apps/web/e2e/tests/settings/repository-delete.spec.ts
index fcbc4a5039..d2eccd2a3b 100644
--- a/apps/web/e2e/tests/settings/repository-delete.spec.ts
+++ b/apps/web/e2e/tests/settings/repository-delete.spec.ts
@@ -50,7 +50,7 @@ test.describe("Repository deletion", () => {
suffix,
});
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
const card = testPage.locator('[data-slot="card"]', { hasText: repo.name });
await expect(card).toBeVisible({ timeout: 15_000 });
@@ -120,7 +120,7 @@ test.describe("Repository deletion", () => {
)
.toBeGreaterThan(0);
- await testPage.goto(`/settings/workspace/${seedData.workspaceId}/repositories`);
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
const card = testPage.locator('[data-slot="card"]', { hasText: repo.name });
await expect(card).toBeVisible({ timeout: 15_000 });
diff --git a/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts b/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts
index 5027c804a3..15b855c784 100644
--- a/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts
+++ b/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts
@@ -24,7 +24,7 @@ test.describe("Resource metrics display", () => {
});
test("renders simplified metrics in the status bar", async ({ testPage }) => {
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
const showMetrics = testPage.getByRole("switch", { name: "Show host metrics in status bar" });
const simplified = testPage.getByRole("switch", { name: "Simplified metrics" });
if ((await showMetrics.getAttribute("aria-checked")) !== "true") await showMetrics.click();
diff --git a/apps/web/e2e/tests/settings/settings-discovery.spec.ts b/apps/web/e2e/tests/settings/settings-discovery.spec.ts
index d2d6c9fcd5..c3f9da4405 100644
--- a/apps/web/e2e/tests/settings/settings-discovery.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-discovery.spec.ts
@@ -1,7 +1,7 @@
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
-const APPEARANCE_PATH = "/settings/general/appearance";
+const APPEARANCE_PATH = "/settings/preferences/appearance";
function settingsSidebar(page: Page) {
return page.getByTestId("app-sidebar-settings-mode");
@@ -67,11 +67,13 @@ test.describe("Settings discovery", () => {
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(result).toContainText("Terminal & Editors");
await expect(sidebar.getByRole("link", { name: "Appearance" })).toHaveCount(0);
await result.click();
- await expect(testPage).toHaveURL(/\/settings\/general\/terminal#setting-terminal-font-size$/);
+ await expect(testPage).toHaveURL(
+ /\/settings\/preferences\/terminal-editors#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();
@@ -98,7 +100,9 @@ test.describe("Settings discovery", () => {
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).toHaveURL(
+ /\/settings\/preferences\/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");
diff --git a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
index cfa4ab6012..e6e62ce564 100644
--- a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
@@ -14,29 +14,45 @@ test.describe("Settings sidebar takeover", () => {
await expect(gear).toBeVisible();
await expect(takeover).toBeVisible();
- // Gear closes the takeover even though we're sitting on a settings page.
+ // Closing leaves the settings surface too: the sidebar and the main panel
+ // must not disagree (kanban navigation beside an open settings page).
await gear.click();
await expect(takeover).toHaveCount(0);
+ await expect(testPage).not.toHaveURL(/\/settings/);
// Gear opens the takeover again.
await gear.click();
await expect(takeover).toBeVisible();
- await expect(takeover.getByText("Active", { exact: true })).toBeVisible();
- await expect(takeover.getByRole("link", { name: "Repositories" })).toBeVisible();
- await expect(
- takeover.locator('a[href^="/settings/workspace/"][href$="/integrations"]').first(),
- ).toHaveAttribute("href", /\/settings\/workspace\/[^/]+\/integrations$/);
- await expect(takeover.locator('a[href="/settings/integrations"]')).toBeVisible();
+
+ // The menu is static: the Workspaces row is a page, and workspace data
+ // (rows, sections) lives on that page rather than in the menu.
+ await takeover.getByRole("link", { name: "Workspaces" }).click();
+ await expect(testPage).toHaveURL(/\/settings\/workspaces$/);
+ await expect(takeover.getByRole("link", { name: "Repositories" })).toHaveCount(0);
+ await expect(takeover.locator('a[href="/settings/integrations"]')).toHaveCount(0);
+ // Each workspace row links to its overview as a whole (overlay link) and
+ // still offers per-section quick links with counts.
+ const workspaceRow = testPage.getByTestId("workspace-list-item").first();
+ await expect(workspaceRow.getByTestId("workspace-overview-link")).toHaveAttribute(
+ "href",
+ /\/settings\/workspaces\/[^/]+$/,
+ );
+ await expect(workspaceRow.getByRole("link", { name: /Repositories/ })).toBeVisible();
+ await expect(workspaceRow.getByRole("link", { name: /Integrations/ })).toHaveAttribute(
+ "href",
+ /\/settings\/workspaces\/[^/]+\/integrations$/,
+ );
// Enter a section: navigates to a settings sub-page; takeover stays open.
await takeover.locator('a[href="/settings/agents"]').first().click();
await expect(testPage).toHaveURL(/\/settings\/agents/);
await expect(takeover).toBeVisible();
- // Clicking the gear again must close the tree even though we're still on a
- // settings page (the previous bug left it open).
+ // Clicking the gear again closes the tree and leaves the settings route,
+ // from a nested page too.
await gear.click();
await expect(takeover).toHaveCount(0);
+ await expect(testPage).not.toHaveURL(/\/settings/);
});
test("navigating off settings (Kandev brand → Home) closes the takeover", async ({
@@ -97,7 +113,9 @@ test.describe("Settings sidebar takeover", () => {
await testPage.getByTestId("sidebar-settings-gear").click();
- await expect(testPage).toHaveURL(/\/settings$/);
+ // The gear pushes `/settings`, which on desktop hands off to the settings
+ // page this device was last on — Appearance in a fresh profile.
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/appearance$/);
await expect(takeover).toBeVisible();
});
diff --git a/apps/web/e2e/tests/settings/settings-index.spec.ts b/apps/web/e2e/tests/settings/settings-index.spec.ts
new file mode 100644
index 0000000000..b7d47d1996
--- /dev/null
+++ b/apps/web/e2e/tests/settings/settings-index.spec.ts
@@ -0,0 +1,87 @@
+import { test, expect } from "../../fixtures/test-base";
+
+// `/settings` used to render the same card grid as `/settings/general`. On
+// desktop it now hands off to the settings page this device was last on, because
+// the sidebar already shows the settings tree.
+test.describe("Settings index on desktop", () => {
+ test("opens the default page on a device with no settings history", async ({ testPage }) => {
+ await testPage.goto("/settings");
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/appearance$/);
+ await expect(testPage.getByTestId("theme-settings-card")).toBeVisible();
+ });
+
+ test("returns to the page you were last on", async ({ testPage }) => {
+ await testPage.goto("/settings/preferences/terminal-editors");
+ await expect(testPage.getByTestId("terminal-font-select")).toBeVisible();
+
+ await testPage.goto("/settings");
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/terminal-editors$/);
+ await expect(testPage.getByTestId("terminal-font-select")).toBeVisible();
+ });
+
+ test("does not leave /settings in history for Back to land on", async ({ testPage }) => {
+ await testPage.goto("/settings/prompts");
+ // The visit is recorded once the settings shell mounts; navigating away
+ // inside the same frame would race that.
+ await expect(testPage.getByTestId("settings-scroll-container")).toBeVisible();
+
+ await testPage.goto("/settings");
+ await expect(testPage).toHaveURL(/\/settings\/prompts$/);
+
+ // A `push` here would send Back to /settings, which would redirect forward
+ // again and trap the user.
+ await testPage.goBack();
+
+ await expect(testPage).not.toHaveURL(/\/settings$/);
+ });
+
+ test("hands off when a phone-width window grows past the sidebar boundary", async ({
+ testPage,
+ }) => {
+ await testPage.setViewportSize({ width: 390, height: 844 });
+ await testPage.goto("/settings");
+ await expect(testPage.getByTestId("settings-index")).toBeVisible();
+
+ // The sidebar appears at md and brings its own copy of this tree; two
+ // identical menus side by side is what this route exists to avoid.
+ await testPage.setViewportSize({ width: 1280, height: 860 });
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/appearance$/);
+ await expect(testPage.getByTestId("settings-index")).toHaveCount(0);
+ });
+
+ test("marks only the active row, never the static section header", async ({ testPage }) => {
+ await testPage.goto("/settings/preferences/appearance");
+
+ const takeover = testPage.getByTestId("app-sidebar-settings-mode");
+ await expect(takeover.getByRole("link", { name: "Appearance" })).toBeVisible();
+
+ // Section headers are static text, not links, so exactly one row can claim
+ // the current location.
+ const active = takeover.locator("a[data-active='true']");
+ await expect(active).toHaveCount(1);
+ await expect(active).toHaveAccessibleName(/Appearance/);
+ });
+
+ test("redirects the /settings/general prefix to the Appearance page", async ({ testPage }) => {
+ await testPage.goto("/settings/general");
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/appearance$/);
+ });
+
+ test("keeps a record-scoped page out of the restore slot", async ({ testPage, seedData }) => {
+ // Workspace pages carry an id that may not exist next time; restoring one
+ // would land on a broken page, so the last id-free page wins.
+ await testPage.goto("/settings/prompts");
+ await expect(testPage.getByTestId("settings-scroll-container")).toBeVisible();
+
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`);
+ await expect(testPage.getByTestId("settings-scroll-container")).toBeVisible();
+
+ await testPage.goto("/settings");
+
+ await expect(testPage).toHaveURL(/\/settings\/prompts$/);
+ });
+});
diff --git a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts
index 36e9469d62..3f7360114b 100644
--- a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts
@@ -1,10 +1,12 @@
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
-const APPEARANCE_PATH = "/settings/general/appearance";
-const TERMINAL_PATH = "/settings/general/terminal";
-const NOTIFICATIONS_PATH = "/settings/general/notifications";
-const TASK_ACTIONS_PATH = "/settings/general/task-actions";
+const APPEARANCE_PATH = "/settings/preferences/appearance";
+const TERMINAL_PATH = "/settings/preferences/terminal-editors";
+const NOTIFICATIONS_PATH = "/settings/preferences/notifications";
+// The menu row the Terminal page now sits on; it was plain "Terminal" before
+// the settings restructure merged Terminal and Editors into one page.
+const TERMINAL_ROW = "Terminal & Editors";
const CLARIFICATION_REQUESTED = "session.clarification_requested";
const PROVIDER_NAME = "E2E semantic notifications";
@@ -26,75 +28,6 @@ async function seedNotificationProvider(
}
test.describe("Settings manual save", () => {
- test("persists host sleep inhibition only when Save changes is pressed", async ({
- testPage,
- apiClient,
- prCapture,
- }) => {
- const initialResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition");
- expect(initialResponse.ok).toBe(true);
- const initial = (await initialResponse.json()) as {
- settings: { enabled: boolean };
- };
- const next = !initial.settings.enabled;
-
- try {
- await testPage.goto(TASK_ACTIONS_PATH);
- const card = testPage.getByTestId("sleep-inhibition-settings");
- const toggle = card.getByRole("switch", { name: "Prevent idle system sleep" });
- await expect(card).toBeVisible();
- await expect(card).toContainText("Container, Kubernetes, remote-executor");
- const info = card.getByRole("button", { name: "How host sleep prevention works" });
- await info.hover();
- const infoTooltip = testPage.getByRole("tooltip");
- await expect(infoTooltip).toContainText("/usr/bin/caffeinate -i -w");
- await expect(infoTooltip).toContainText("SetThreadExecutionState");
- await expect(infoTooltip).toContainText("systemd-logind");
- await testPage.waitForTimeout(500);
- await prCapture.screenshot("sleep-inhibition-desktop-info", {
- caption: "Desktop host sleep prevention details in the hover tooltip",
- });
- if (initial.settings.enabled) await expect(toggle).toBeChecked();
- else await expect(toggle).not.toBeChecked();
-
- await toggle.click();
- await expect(card).toHaveAttribute("data-settings-dirty", "true");
- const beforeSaveResponse = await apiClient.rawRequest(
- "GET",
- "/api/v1/system/sleep-inhibition",
- );
- expect(((await beforeSaveResponse.json()) as typeof initial).settings.enabled).toBe(
- initial.settings.enabled,
- );
-
- const floatingSave = testPage.getByTestId("settings-floating-save");
- await testPage.waitForTimeout(1_000);
- await testPage
- .locator("[data-sonner-toast], [data-testid='toast-message']")
- .evaluateAll((toasts) => {
- for (const toast of toasts) (toast as HTMLElement).style.display = "none";
- });
- await prCapture.screenshot("sleep-inhibition-desktop-draft", {
- caption: "Task Actions sleep inhibition setting with Save changes pending",
- });
- await floatingSave.getByRole("button", { name: "Save changes" }).click();
- await expect(floatingSave).not.toBeVisible({ timeout: 15_000 });
-
- const savedResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition");
- expect(((await savedResponse.json()) as typeof initial).settings.enabled).toBe(next);
- await testPage.reload();
- const reloadedToggle = testPage
- .getByTestId("sleep-inhibition-settings")
- .getByRole("switch", { name: "Prevent idle system sleep" });
- if (next) await expect(reloadedToggle).toBeChecked();
- else await expect(reloadedToggle).not.toBeChecked();
- } finally {
- await apiClient.rawRequest("PATCH", "/api/v1/system/sleep-inhibition", {
- enabled: initial.settings.enabled,
- });
- }
- });
-
test("keeps Appearance changes local and guards dirty navigation", async ({
testPage,
apiClient,
@@ -165,7 +98,7 @@ test.describe("Settings manual save", () => {
.getByRole("option", { name: nextLayout === "tree" ? "Tree" : "Flat list" })
.click();
- await testPage.getByRole("link", { name: "Terminal", exact: true }).first().click();
+ await testPage.getByRole("link", { name: TERMINAL_ROW, exact: true }).first().click();
const navigationDialog = testPage.getByRole("alertdialog", {
name: "Save changes before leaving?",
});
@@ -173,7 +106,7 @@ test.describe("Settings manual save", () => {
await navigationDialog.getByRole("button", { name: "Continue editing" }).click();
await expect(testPage).toHaveURL(new RegExp(`${APPEARANCE_PATH}$`));
- await testPage.getByRole("link", { name: "Terminal", exact: true }).first().click();
+ await testPage.getByRole("link", { name: TERMINAL_ROW, exact: true }).first().click();
await expect(navigationDialog).toBeVisible();
await navigationDialog.getByRole("button", { name: "Save and leave" }).click();
await expect(testPage).toHaveURL(new RegExp(`${TERMINAL_PATH}$`));
@@ -228,7 +161,7 @@ test.describe("Settings manual save", () => {
const initialAutoScrollControl = initial.settings.show_transcript_auto_scroll_control;
try {
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const autoScrollControl = testPage.getByRole("switch", {
name: "Show transcript auto-scroll control",
});
diff --git a/apps/web/e2e/tests/settings/settings-menu-mode.spec.ts b/apps/web/e2e/tests/settings/settings-menu-mode.spec.ts
new file mode 100644
index 0000000000..d13a676cfe
--- /dev/null
+++ b/apps/web/e2e/tests/settings/settings-menu-mode.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from "../../fixtures/test-base";
+import {
+ SETTINGS_APPEARANCE_PATH as APPEARANCE_PATH,
+ SETTINGS_TAKEOVER_TESTID as TAKEOVER,
+ setSettingsMenuMode as setMenuMode,
+} from "../../helpers/settings-menu";
+
+// The settings menu shape is a per-device preference (localStorage), so these
+// specs assert against the sidebar and a reload rather than the API — there is
+// no server state to read back, which is the point of the setting.
+test.describe("Settings menu modes", () => {
+ test("previews the chosen shape in the sidebar before it is saved", async ({ testPage }) => {
+ await testPage.goto(APPEARANCE_PATH);
+ const takeover = testPage.getByTestId(TAKEOVER);
+ // Flat is the default: the Workspaces row is a link and nothing more.
+ await expect(takeover.getByRole("button", { name: /Expand Workspaces/ })).toHaveCount(0);
+
+ await testPage.getByTestId("settings-menu-mode-accordion").click();
+
+ // Previewed immediately — the sidebar is on screen beside the control.
+ await expect(takeover.getByRole("button", { name: /Expand Workspaces/ })).toBeVisible();
+ await expect(testPage.getByTestId("settings-menu-mode-card")).toHaveAttribute(
+ "data-settings-dirty",
+ "true",
+ );
+ });
+
+ test("puts the menu back when the change is discarded", async ({ testPage }) => {
+ await testPage.goto(APPEARANCE_PATH);
+ const takeover = testPage.getByTestId(TAKEOVER);
+
+ await testPage.getByTestId("settings-menu-mode-persistent").click();
+ await expect(takeover.getByRole("button", { name: /Expand Workspaces/ })).toBeVisible();
+
+ await takeover.getByRole("link", { name: "Prompts", exact: true }).first().click();
+ const dialog = testPage.getByRole("alertdialog", { name: "Save changes before leaving?" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("button", { name: "Discard and leave" }).click();
+
+ await expect(takeover.getByRole("button", { name: /Expand Workspaces/ })).toHaveCount(0);
+ });
+
+ test("keeps one branch open at a time in accordion mode", async ({ testPage, seedData }) => {
+ await setMenuMode(testPage, "accordion");
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}`);
+ const takeover = testPage.getByTestId(TAKEOVER);
+
+ // The route opens its own branch; the others stay shut.
+ await expect(takeover.getByRole("button", { name: /Collapse Workspaces/ })).toBeVisible();
+ await expect(takeover.getByRole("link", { name: "Integrations", exact: true })).toBeVisible();
+ await expect(takeover.getByRole("button", { name: /Collapse Agents/ })).toHaveCount(0);
+
+ await takeover.getByRole("button", { name: /Expand Agents/ }).click();
+
+ await expect(takeover.getByRole("button", { name: /Collapse Agents/ })).toBeVisible();
+ await expect(takeover.getByRole("link", { name: "Integrations", exact: true })).toHaveCount(0);
+ });
+
+ test("keeps several branches open, and across a reload, in persistent mode", async ({
+ testPage,
+ seedData,
+ }) => {
+ await setMenuMode(testPage, "persistent");
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}`);
+ const takeover = testPage.getByTestId(TAKEOVER);
+
+ await expect(takeover.getByRole("button", { name: /Collapse Workspaces/ })).toBeVisible();
+ await takeover.getByRole("button", { name: /Expand Agents/ }).click();
+
+ // Both stay open — the difference from accordion.
+ await expect(takeover.getByRole("button", { name: /Collapse Workspaces/ })).toBeVisible();
+ await expect(takeover.getByRole("button", { name: /Collapse Agents/ })).toBeVisible();
+
+ await testPage.reload();
+
+ await expect(takeover.getByRole("button", { name: /Collapse Workspaces/ })).toBeVisible();
+ await expect(takeover.getByRole("button", { name: /Collapse Agents/ })).toBeVisible();
+ });
+
+ test("drills a workspace down to a single integration", async ({ testPage, seedData }) => {
+ await setMenuMode(testPage, "accordion");
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/integrations/github`);
+ const takeover = testPage.getByTestId(TAKEOVER);
+
+ // Settings › Workspaces › › Integrations › GitHub, the deepest
+ // branch the menu has — and the same chain the page's breadcrumb renders.
+ await expect(takeover.getByRole("link", { name: "GitHub", exact: true })).toBeVisible();
+
+ // Only the page you are on is marked, never the rows that merely contain it.
+ const active = takeover.locator("[data-active='true']");
+ await expect(active).toHaveCount(1);
+ await expect(active).toHaveAccessibleName("GitHub");
+ });
+});
diff --git a/apps/web/e2e/tests/settings/startup-page.spec.ts b/apps/web/e2e/tests/settings/startup-page.spec.ts
index 391aedfe22..6a7de879c1 100644
--- a/apps/web/e2e/tests/settings/startup-page.spec.ts
+++ b/apps/web/e2e/tests/settings/startup-page.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
-const APPEARANCE_PATH = "/settings/general/appearance";
+const APPEARANCE_PATH = "/settings/preferences/appearance";
test.describe("Startup page", () => {
test("saves the last-task choice, resumes it at bare home, and keeps Home on the overview", async ({
diff --git a/apps/web/e2e/tests/settings/workspace-delete-regular-mode.spec.ts b/apps/web/e2e/tests/settings/workspace-delete-regular-mode.spec.ts
index 0c9f3b5072..a79b1f26b9 100644
--- a/apps/web/e2e/tests/settings/workspace-delete-regular-mode.spec.ts
+++ b/apps/web/e2e/tests/settings/workspace-delete-regular-mode.spec.ts
@@ -16,8 +16,11 @@ test.describe("Workspace settings without the office feature", () => {
const workspaceName = `Regular Delete ${suffix}`;
const workspace = await apiClient.createWorkspace(workspaceName);
+ // The singular prefix is the legacy URL and redirects to `/settings/workspaces`;
+ // keeping it here covers that redirect as well as the deletion flow. The
+ // workspace name heads the page through the switcher trigger, not a heading.
await testPage.goto(`/settings/workspace/${workspace.id}`);
- await expect(testPage.getByRole("heading", { name: workspaceName })).toBeVisible({
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toHaveText(workspaceName, {
timeout: 15_000,
});
@@ -43,7 +46,7 @@ test.describe("Workspace settings without the office feature", () => {
await confirmButton.click();
expect((await deleteRequest).ok()).toBe(true);
- await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
+ await expect(testPage).toHaveURL(/\/settings\/workspaces$/, { timeout: 10_000 });
const { workspaces } = await apiClient.listWorkspaces();
expect(workspaces.some((item) => item.id === workspace.id)).toBe(false);
diff --git a/apps/web/e2e/tests/settings/workspace-delete.spec.ts b/apps/web/e2e/tests/settings/workspace-delete.spec.ts
index e7dbb7cb59..698ea0d2ae 100644
--- a/apps/web/e2e/tests/settings/workspace-delete.spec.ts
+++ b/apps/web/e2e/tests/settings/workspace-delete.spec.ts
@@ -12,16 +12,16 @@ test.describe("Workspace settings", () => {
const workspaceName = `Settings Kanban ${Date.now().toString(36)}`;
const taskTitle = `Kanban Bootstrap Task ${Date.now().toString(36)}`;
- await testPage.goto("/settings/workspace");
+ await testPage.goto("/settings/workspaces");
await testPage.getByRole("button", { name: "Add Workspace" }).click();
await testPage.getByLabel("Workspace Name").fill(workspaceName);
await testPage.locator("form").getByRole("button", { name: "Add Workspace" }).click();
// Follow the newly rendered workspace link rather than seeding or creating
- // it through the API: this covers the standard Settings creation flow.
- const workspaceLink = testPage.getByRole("link").filter({
- has: testPage.getByRole("heading", { name: workspaceName, exact: true }),
- });
+ // it through the API: this covers the standard Settings creation flow. The
+ // card's link is an empty overlay named by `aria-label` — the workspace
+ // name is a sibling `h4`, not the link's child.
+ const workspaceLink = testPage.getByRole("link", { name: workspaceName, exact: true });
await expect(workspaceLink).toBeVisible();
const workspaceHref = await workspaceLink.getAttribute("href");
const workspaceId = new URL(workspaceHref ?? "", "http://kandev.test").pathname.split("/")[3];
@@ -29,11 +29,14 @@ test.describe("Workspace settings", () => {
let createdTaskId: string | undefined;
try {
- await workspaceLink.click();
+ // The card's section tiles sit above the overlay link (z-10) and cover
+ // its centre, so click near a corner — the card padding, where the
+ // overlay is what a user's pointer actually reaches.
+ await workspaceLink.click({ position: { x: 8, y: 8 } });
- await expect(
- testPage.getByRole("heading", { name: workspaceName, exact: true }),
- ).toBeVisible();
+ // The workspace name heads the page through the switcher trigger, not a
+ // heading: the shell doubles it as the workspace-to-workspace navigator.
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toHaveText(workspaceName);
await testPage
.getByTestId("settings-scroll-container")
.getByRole("link", { name: "Workflows", exact: true })
@@ -94,8 +97,8 @@ test.describe("Workspace settings", () => {
const workspaceName = `Settings Delete ${suffix}`;
const workspace = await apiClient.createWorkspace(workspaceName);
- await testPage.goto(`/settings/workspace/${workspace.id}`);
- await expect(testPage.getByRole("heading", { name: workspaceName })).toBeVisible({
+ await testPage.goto(`/settings/workspaces/${workspace.id}`);
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toHaveText(workspaceName, {
timeout: 15_000,
});
@@ -127,7 +130,7 @@ test.describe("Workspace settings", () => {
// outcome: redirect to the workspace list and the workspace gone from the
// backend.
await confirmButton.click();
- await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
+ await expect(testPage).toHaveURL(/\/settings\/workspaces$/, { timeout: 10_000 });
const { workspaces } = await apiClient.listWorkspaces();
expect(workspaces.some((item) => item.id === workspace.id)).toBe(false);
@@ -156,8 +159,8 @@ test.describe("Workspace settings", () => {
content: "# Settings cleanup skill\n",
});
- await testPage.goto(`/settings/workspace/${onboarded.workspaceId}`);
- await expect(testPage.getByRole("heading", { name: workspaceName })).toBeVisible({
+ await testPage.goto(`/settings/workspaces/${onboarded.workspaceId}`);
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toHaveText(workspaceName, {
timeout: 15_000,
});
@@ -169,7 +172,7 @@ test.describe("Workspace settings", () => {
await confirmInput.fill(workspaceName);
await expect(confirmButton).toBeEnabled();
await confirmButton.click();
- await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 });
+ await expect(testPage).toHaveURL(/\/settings\/workspaces$/, { timeout: 10_000 });
const { workspaces } = await apiClient.listWorkspaces();
expect(workspaces.some((item) => item.id === onboarded.workspaceId)).toBe(false);
diff --git a/apps/web/e2e/tests/settings/workspace-settings-switcher.spec.ts b/apps/web/e2e/tests/settings/workspace-settings-switcher.spec.ts
new file mode 100644
index 0000000000..2d1359c067
--- /dev/null
+++ b/apps/web/e2e/tests/settings/workspace-settings-switcher.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from "../../fixtures/test-base";
+
+/**
+ * The workspace settings header is the same switcher the sidebar draws: the
+ * active workspace is marked by the "Active" pill after its name, next to the
+ * type badge, and picking another workspace opens that workspace's copy of the
+ * tab you are already on rather than sending you back to the list.
+ */
+test.describe("Workspace settings switcher", () => {
+ test("marks the active workspace and keeps the tab when switching", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const other = await apiClient.createWorkspace("Switcher Target Workspace");
+
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/secrets`);
+
+ const trigger = testPage.getByTestId("workspace-settings-switcher");
+ await expect(trigger).toContainText("E2E Workspace");
+ // The seeded workspace is the active one, so its page carries the badge
+ // beside the closed picker.
+ await expect(testPage.getByTestId("workspace-settings-active-badge")).toBeVisible();
+ await trigger.click();
+
+ // The seeded workspace is the active one, so it carries the badge; the
+ // freshly created workspace does not.
+ const activeRow = testPage.getByTestId(
+ `workspace-settings-switcher-item-${seedData.workspaceId}`,
+ );
+ const targetRow = testPage.getByTestId(`workspace-settings-switcher-item-${other.id}`);
+ await expect(activeRow).toBeVisible();
+ await expect(activeRow).toContainText("Active");
+ await expect(targetRow).not.toContainText("Active");
+
+ const activeText = (await activeRow.innerText()).replace(/\s+/g, " ");
+ expect(activeText.indexOf("Active")).toBeGreaterThan(activeText.indexOf("E2E Workspace"));
+ expect(activeText.indexOf("Active")).toBeLessThan(activeText.indexOf("Kanban"));
+
+ await targetRow.click();
+
+ await expect(testPage).toHaveURL(new RegExp(`/settings/workspaces/${other.id}/secrets$`));
+ await expect(testPage.getByTestId("workspace-settings-switcher")).toContainText(
+ "Switcher Target Workspace",
+ );
+ // The freshly created workspace is not the active one, so its page shows
+ // no header badge.
+ await expect(testPage.getByTestId("workspace-settings-active-badge")).toHaveCount(0);
+ });
+
+ test("switching settings pages does not change the active workspace", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const other = await apiClient.createWorkspace("Switcher Passive Workspace");
+
+ await testPage.goto(`/settings/workspaces/${seedData.workspaceId}`);
+ await testPage.getByTestId("workspace-settings-switcher").click();
+ await testPage.getByTestId(`workspace-settings-switcher-item-${other.id}`).click();
+ await expect(testPage).toHaveURL(new RegExp(`/settings/workspaces/${other.id}$`));
+
+ // Reopened on the other workspace's page, the badge still points at the
+ // seeded workspace: navigating here never activated anything.
+ await testPage.getByTestId("workspace-settings-switcher").click();
+ await expect(
+ testPage.getByTestId(`workspace-settings-switcher-item-${seedData.workspaceId}`),
+ ).toContainText("Active");
+ await expect(
+ testPage.getByTestId(`workspace-settings-switcher-item-${other.id}`),
+ ).not.toContainText("Active");
+ });
+});
diff --git a/apps/web/e2e/tests/system/backups-page.spec.ts b/apps/web/e2e/tests/system/backups-page.spec.ts
index 72faebe30f..679fd98a35 100644
--- a/apps/web/e2e/tests/system/backups-page.spec.ts
+++ b/apps/web/e2e/tests/system/backups-page.spec.ts
@@ -29,8 +29,8 @@ test.describe("System Backups page", () => {
}) => {
test.setTimeout(60_000);
- await testPage.goto("/settings/system/backups");
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Backups");
+ await testPage.goto("/settings/system/data-storage");
+ await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs");
await expect(testPage.getByTestId("system-backups-card")).toBeVisible();
// Empty state shows initially (no auto snapshots exist on this fresh boot path).
diff --git a/apps/web/e2e/tests/system/database-page.spec.ts b/apps/web/e2e/tests/system/database-page.spec.ts
index 7e35c25b57..4f3687aeb5 100644
--- a/apps/web/e2e/tests/system/database-page.spec.ts
+++ b/apps/web/e2e/tests/system/database-page.spec.ts
@@ -4,9 +4,9 @@ test.describe("System Database page", () => {
test("renders database stats and exposes maintenance buttons", async ({ testPage }) => {
test.setTimeout(60_000);
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Database");
+ await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
// Each stat row must render a non-empty value.
@@ -28,7 +28,7 @@ test.describe("System Database page", () => {
}) => {
test.setTimeout(60_000);
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
const vacuumButton = testPage.getByTestId("system-vacuum-button");
@@ -52,13 +52,13 @@ test.describe("System Database page", () => {
});
test("WAL row exposes an info tooltip trigger", async ({ testPage }) => {
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
await expect(testPage.getByTestId("system-db-wal-info")).toBeVisible();
});
test("clicking Factory Reset opens the confirmation modal", async ({ testPage }) => {
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
await testPage.getByTestId("system-factory-reset-button").click();
diff --git a/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts b/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts
index 8758cdab6e..45e6df1615 100644
--- a/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts
+++ b/apps/web/e2e/tests/system/factory-reset-dialog.spec.ts
@@ -4,7 +4,7 @@ test.describe("Factory Reset dialog", () => {
test("confirm button only enables on the literal 'RESET' token; cancel closes the modal", async ({
testPage,
}) => {
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
await testPage.getByTestId("system-factory-reset-button").click();
diff --git a/apps/web/e2e/tests/system/licenses-page.spec.ts b/apps/web/e2e/tests/system/licenses-page.spec.ts
index e82d10b43a..7751c7f79e 100644
--- a/apps/web/e2e/tests/system/licenses-page.spec.ts
+++ b/apps/web/e2e/tests/system/licenses-page.spec.ts
@@ -6,9 +6,9 @@ test.describe("System Licenses page", () => {
}) => {
test.setTimeout(60_000);
- await testPage.goto("/settings/system/licenses");
+ await testPage.goto("/settings/system/about");
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Licenses");
+ await expect(testPage.getByTestId("system-page-title")).toHaveText("About");
const card = testPage.getByTestId("system-licenses-card");
await expect(card).toBeVisible();
diff --git a/apps/web/e2e/tests/system/logs-page.spec.ts b/apps/web/e2e/tests/system/logs-page.spec.ts
index c4012e57e6..bb835f838c 100644
--- a/apps/web/e2e/tests/system/logs-page.spec.ts
+++ b/apps/web/e2e/tests/system/logs-page.spec.ts
@@ -8,9 +8,9 @@ test.describe("System Logs page", () => {
prCapture,
}) => {
test.setTimeout(45_000);
- await testPage.goto("/settings/system/logs");
+ await testPage.goto("/settings/system/data-storage");
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Logs");
+ await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs");
await expect(
testPage.getByText("Create a diagnostic ZIP with frontend and backend logs."),
).toBeVisible();
@@ -45,7 +45,7 @@ test.describe("System Logs page", () => {
prCapture,
}) => {
await testPage.setViewportSize({ width: 1440, height: 900 });
- await testPage.goto("/settings/system/logs");
+ await testPage.goto("/settings/system/data-storage");
await testPage.getByTestId("customize-diagnostic-bundle").click();
const dialog = testPage.getByTestId("diagnostic-bundle-dialog");
await expect(dialog).toBeVisible();
@@ -63,7 +63,7 @@ test.describe("System Logs page", () => {
testPage,
}) => {
await testPage.setViewportSize({ width: 1440, height: 900 });
- await testPage.goto("/settings/system/logs");
+ await testPage.goto("/settings/system/data-storage");
const noMessages = testPage.getByText(
"Standard bundles do not include chat transcripts, session messages, or agent messages.",
diff --git a/apps/web/e2e/tests/system/message-queue-settings.spec.ts b/apps/web/e2e/tests/system/message-queue-settings.spec.ts
index 5ed43e463b..302990737d 100644
--- a/apps/web/e2e/tests/system/message-queue-settings.spec.ts
+++ b/apps/web/e2e/tests/system/message-queue-settings.spec.ts
@@ -41,18 +41,18 @@ test.describe.serial("Message Queue general settings", () => {
mergeBaseline = undefined;
});
- test("admin navigates to Message Queue, saves, and reloads the live setting", async ({
+ test("admin navigates to Task behavior, saves, and reloads the live setting", async ({
testPage,
}) => {
const updated = baseline === 17 ? 18 : 17;
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
const settingsNav = testPage.getByTestId("app-sidebar-settings-mode");
- await settingsNav.getByRole("link", { name: "Message Queue" }).click();
+ await settingsNav.getByRole("link", { name: /^Task Behavior/ }).click();
await expect(testPage).toHaveURL(
- (url) => new URL(url).pathname === "/settings/general/message-queue",
+ (url) => new URL(url).pathname === "/settings/preferences/task-behavior",
);
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Message Queue");
+ await expect(testPage.getByText("Message Queue").first()).toBeVisible();
const input = testPage.getByTestId("message-queue-max-per-session");
await expect(input).toHaveValue(String(baseline));
@@ -126,7 +126,7 @@ test.describe.serial("Message Queue general settings", () => {
});
});
- await testPage.goto("/settings/general/message-queue");
+ await testPage.goto("/settings/preferences/task-behavior");
await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled();
await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText("41");
await expect(testPage.getByTestId("message-queue-source")).toHaveText("Environment");
@@ -137,7 +137,7 @@ test.describe.serial("Message Queue general settings", () => {
});
test("member can navigate to the setting but cannot edit it", async ({ testPage }) => {
- await testPage.goto("/settings/general/appearance");
+ await testPage.goto("/settings/preferences/appearance");
await testPage.waitForFunction(() => Boolean(window.__KANDEV_E2E_STORE__));
await testPage.evaluate(() => {
const store = window.__KANDEV_E2E_STORE__;
@@ -156,7 +156,7 @@ test.describe.serial("Message Queue general settings", () => {
});
});
const settingsNav = testPage.getByTestId("app-sidebar-settings-mode");
- await settingsNav.getByRole("link", { name: "Message Queue" }).click();
+ await settingsNav.getByRole("link", { name: /^Task Behavior/ }).click();
await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled();
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeDisabled();
diff --git a/apps/web/e2e/tests/system/mobile-database-page.spec.ts b/apps/web/e2e/tests/system/mobile-database-page.spec.ts
index e7251a7ba3..9fb37e6f8e 100644
--- a/apps/web/e2e/tests/system/mobile-database-page.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-database-page.spec.ts
@@ -2,9 +2,9 @@ import { test, expect } from "../../fixtures/test-base";
test.describe("Mobile System Database page", () => {
test("renders database stats and maintenance controls", async ({ testPage }) => {
- await testPage.goto("/settings/system/database");
+ await testPage.goto("/settings/system/data-storage");
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Database");
+ await expect(testPage.getByTestId("system-page-title")).toHaveText("Data & Logs");
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
// Maintenance buttons are only rendered for SQLite; this spec assumes a SQLite backend.
diff --git a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts
index c61f4f7ea6..cfa8c758a8 100644
--- a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts
@@ -6,7 +6,7 @@ test.describe("System Logs mobile", () => {
prCapture,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto("/settings/system/logs");
+ await testPage.goto("/settings/system/data-storage");
const action = testPage.getByTestId("customize-diagnostic-bundle");
await expect(action).toBeVisible();
@@ -29,7 +29,7 @@ test.describe("System Logs mobile", () => {
testPage,
}) => {
await testPage.setViewportSize({ width: 390, height: 844 });
- await testPage.goto("/settings/system/logs");
+ await testPage.goto("/settings/system/data-storage");
const customize = testPage.getByTestId("customize-diagnostic-bundle");
await expect(customize).toBeVisible();
diff --git a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
index fb432d4498..f43bb9440e 100644
--- a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts
@@ -1,7 +1,8 @@
import { test, expect } from "../../fixtures/test-base";
+import { expectElementsNotToIntersect } from "../../helpers/layout-assertions";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
-test("mobile navigation reaches Message Queue with touch-safe shared settings layout", async ({
+test("mobile navigation reaches the Message Queue section with touch-safe shared settings layout", async ({
testPage,
}) => {
const mobile = new MobileKanbanPage(testPage);
@@ -9,16 +10,14 @@ test("mobile navigation reaches Message Queue with touch-safe shared settings la
await mobile.mobileMenuButton.click();
const homeMenu = testPage.getByTestId("mobile-home-menu-card");
await homeMenu.getByRole("link", { name: "Settings" }).click();
- await testPage.getByTestId("settings-mobile-menu-button").click();
-
- const menu = testPage.getByTestId("settings-mobile-menu");
- await menu.getByRole("button", { name: "Expand General" }).click();
- await menu.getByRole("link", { name: "Message Queue" }).click();
+ // Settings lands on the /settings index; the queue lives on Task behavior.
+ const index = testPage.getByTestId("settings-index");
+ await index.getByRole("link", { name: /^Task Behavior/ }).click();
await expect(testPage).toHaveURL(
- (url) => new URL(url).pathname === "/settings/general/message-queue",
+ (url) => new URL(url).pathname === "/settings/preferences/task-behavior",
);
- await expect(testPage.getByTestId("system-page-title")).toHaveText("Message Queue");
+ await expect(testPage.getByText("Message Queue").first()).toBeVisible();
const input = testPage.getByTestId("message-queue-max-per-session");
await expect(input).toBeVisible();
@@ -43,8 +42,5 @@ test("mobile navigation reaches Message Queue with touch-safe shared settings la
await input.fill(current === "23" ? "24" : "23");
const saveBar = testPage.getByTestId("settings-floating-save");
await expect(saveBar).toBeVisible();
- const [inputAfterEdit, saveBox] = await Promise.all([input.boundingBox(), saveBar.boundingBox()]);
- expect(inputAfterEdit).not.toBeNull();
- expect(saveBox).not.toBeNull();
- expect(inputAfterEdit!.y + inputAfterEdit!.height).toBeLessThan(saveBox!.y);
+ await expectElementsNotToIntersect(input, saveBar);
});
diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
index 8fc299f0ea..4f74218b85 100644
--- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
+++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts
@@ -35,7 +35,7 @@ test.describe("Mobile storage maintenance", () => {
});
});
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
const trigger = testPage.getByTestId("storage-resource-temporary-artifacts-trigger");
await trigger.tap();
const cleanButton = testPage.getByTestId("storage-temporary-artifacts-clean");
@@ -86,7 +86,7 @@ test.describe("Mobile storage maintenance", () => {
});
});
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await testPage.getByTestId("storage-run-now").tap();
await expect(testPage.getByTestId("storage-busy")).toContainText("A test command is running");
await expect(testPage.getByTestId("storage-run-anyway")).toBeVisible();
@@ -118,10 +118,8 @@ test.describe("Mobile storage maintenance", () => {
await mobile.goto();
await mobile.mobileMenuButton.click();
await testPage.getByRole("link", { name: "Settings" }).click();
- await testPage.getByTestId("settings-mobile-menu-button").click();
- const settingsMenu = testPage.getByTestId("settings-mobile-menu");
- await settingsMenu.getByRole("button", { name: "Expand System" }).click();
- await settingsMenu.getByRole("link", { name: "Storage" }).click();
+ const index = testPage.getByTestId("settings-index");
+ await index.locator('a[href="/settings/system/data-storage"]').click();
await expect(testPage.getByTestId("storage-settings-page")).toBeVisible();
await expect(testPage.getByTestId("storage-disk-capacity-card")).toBeVisible();
@@ -217,13 +215,13 @@ test.describe("Mobile storage maintenance", () => {
await testPage.route(overviewPattern, holdOverview);
try {
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await overviewObserved;
const spinner = testPage.getByTestId("storage-overview-spinner");
await expect(spinner).toBeVisible();
await expect(testPage.getByText("Loading storage data…")).toBeVisible();
- await expect(testPage.getByTestId("storage-overview-card")).toBeInViewport();
+ await expect(testPage.getByTestId("storage-overview-card")).toBeVisible();
await expect(testPage.getByTestId("storage-policy-card")).toBeVisible();
await expect(testPage.getByTestId("storage-run-history")).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-card")).toBeVisible();
@@ -282,7 +280,7 @@ test.describe("Mobile storage maintenance", () => {
body: JSON.stringify({ job_id: "mobile-force-purge" }),
});
});
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("storage-quarantine-force-clear")).toBeVisible();
await testPage.getByTestId("storage-quarantine-card").scrollIntoViewIfNeeded();
await prCapture.screenshot("quarantine-actions", {
diff --git a/apps/web/e2e/tests/system/restore-dialog.spec.ts b/apps/web/e2e/tests/system/restore-dialog.spec.ts
index 27943b7be3..ffd9eedfa0 100644
--- a/apps/web/e2e/tests/system/restore-dialog.spec.ts
+++ b/apps/web/e2e/tests/system/restore-dialog.spec.ts
@@ -52,7 +52,7 @@ test.describe("Restore snapshot dialog", () => {
}
expect(found).toBeTruthy();
- await testPage.goto("/settings/system/backups");
+ await testPage.goto("/settings/system/data-storage");
await expect(testPage.getByTestId("system-backups-table")).toBeVisible({ timeout: 15_000 });
const firstRow = testPage.locator('[data-testid="system-backups-row"]').first();
diff --git a/apps/web/e2e/tests/system/sidebar-navigation.spec.ts b/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
index 230177ea09..d2d255a097 100644
--- a/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
+++ b/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
@@ -2,35 +2,41 @@ import { test, expect } from "../../fixtures/test-base";
const SYSTEM_ENTRIES: Array<{ href: string; label: string; title: string }> = [
{ href: "/settings/system/status", label: "Status", title: "Status" },
- { href: "/settings/system/database", label: "Database", title: "Database" },
- { href: "/settings/system/backups", label: "Backups", title: "Backups" },
- { href: "/settings/system/logs", label: "Logs", title: "Logs" },
+ { href: "/settings/system/data-storage", label: "Data & Logs", title: "Data & Logs" },
+ { href: "/settings/system/feature-toggles", label: "Feature Toggles", title: "Feature Toggles" },
{ href: "/settings/system/updates", label: "Updates", title: "Updates" },
{ href: "/settings/system/about", label: "About", title: "About" },
- { href: "/settings/system/licenses", label: "Licenses", title: "Licenses" },
];
test.describe("System sidebar navigation", () => {
- test("System group has 7 sub-entries that navigate correctly and no standalone Changelog entry remains", async ({
+ test("System section has exactly its page rows, which navigate correctly, and no standalone Changelog entry remains", async ({
testPage,
}) => {
test.setTimeout(120_000);
- // In the unified AppSidebar the settings nav is a gear-gated takeover, so
- // open it via the footer gear. It's a single-open accordion: a group's
- // sub-entries are only mounted while that group is the open one. Landing on
- // a System page route-syncs the System group open, so its sub-entries show.
+ // In the unified AppSidebar the settings nav is a gear-gated takeover; the
+ // menu is static, so every System row is visible from any settings page.
await testPage.goto("/settings/system/status");
await expect(testPage.getByTestId("app-sidebar-settings-mode")).toBeVisible();
- // Each sub-entry is present in the settings sidebar.
+ // Each row is present in the settings sidebar.
for (const entry of SYSTEM_ENTRIES) {
const link = testPage.locator(`a[href="${entry.href}"]`).first();
await expect(link).toBeVisible();
}
- // Standalone Changelog entry is NOT present.
+ // Standalone Changelog entry is NOT present, and neither are the merged
+ // pages' old rows (Database, Backups, Storage, Logs, Licenses).
await expect(testPage.locator('a[href="/settings/changelog"]')).toHaveCount(0);
+ for (const gone of [
+ "/settings/system/database",
+ "/settings/system/backups",
+ "/settings/system/storage",
+ "/settings/system/logs",
+ "/settings/system/licenses",
+ ]) {
+ await expect(testPage.locator(`a[href="${gone}"]`)).toHaveCount(0);
+ }
// Click each entry and confirm the URL + page title.
for (const entry of SYSTEM_ENTRIES) {
diff --git a/apps/web/e2e/tests/system/storage-maintenance.spec.ts b/apps/web/e2e/tests/system/storage-maintenance.spec.ts
index 0da226c413..ecd7c90b71 100644
--- a/apps/web/e2e/tests/system/storage-maintenance.spec.ts
+++ b/apps/web/e2e/tests/system/storage-maintenance.spec.ts
@@ -55,7 +55,7 @@ test.describe("System storage maintenance", () => {
});
});
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
const trigger = testPage.getByTestId("storage-resource-temporary-artifacts-trigger");
await expect(trigger).toContainText("0.05 GB");
await trigger.click();
@@ -85,7 +85,7 @@ test.describe("System storage maintenance", () => {
const overviewResponse = testPage.waitForResponse(
(response) => new URL(response.url()).pathname === "/api/v1/system/storage",
);
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await overviewResponse;
// The preceding temporary-artifact scenario loads and caches a Storage
// snapshot. Force a fresh read after seeding this test's cache so this
@@ -129,7 +129,7 @@ test.describe("System storage maintenance", () => {
});
test("reuses the cached snapshot until Analyze refreshes it", async ({ testPage }) => {
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
const analyzedTime = testPage.locator("time[datetime]").filter({ hasText: "Last analyzed" });
await expect(analyzedTime).toHaveText(/^Last analyzed .+/);
const initialAnalyzedAt = await analyzedTime.getAttribute("datetime");
@@ -198,7 +198,7 @@ test.describe("System storage maintenance", () => {
await testPage.route(overviewPattern, holdOverview);
try {
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await overviewObserved;
await expect(testPage.getByTestId("storage-policy-card")).toBeVisible();
@@ -233,7 +233,7 @@ test.describe("System storage maintenance", () => {
backend,
}) => {
const orphan = seedOrphanWorkspace(backend.tmpDir);
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
const overviewBox = await testPage.getByTestId("storage-overview-card").boundingBox();
const policyBox = await testPage.getByTestId("storage-policy-card").boundingBox();
expect(overviewBox).not.toBeNull();
@@ -343,7 +343,7 @@ test.describe("System storage maintenance", () => {
)
.toBe("RUNNING");
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
const responsePromise = testPage.waitForResponse(
(response) =>
response.request().method() === "POST" &&
@@ -434,7 +434,7 @@ test.describe("System storage maintenance", () => {
body: JSON.stringify({ job_id: "eligible-purge" }),
});
});
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await expect(
testPage.getByTestId("storage-quarantine-eligible-entry").getByText("Eligible now"),
).toBeVisible();
diff --git a/apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts b/apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts
index 2938a62bf3..c0721862af 100644
--- a/apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts
+++ b/apps/web/e2e/tests/task/agent-generated-task-titles.spec.ts
@@ -13,7 +13,7 @@ test.describe("Agent-generated task titles", () => {
const initialEnabled = Boolean(initial.settings.agent_generated_task_titles);
try {
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const toggle = testPage.getByRole("switch", { name: "Use the agent for new task titles" });
if (!(await toggle.isChecked())) await toggle.click();
await testPage
diff --git a/apps/web/e2e/tests/task/archive-confirmation-preference.spec.ts b/apps/web/e2e/tests/task/archive-confirmation-preference.spec.ts
index 7e4b8058b0..2b331d6a40 100644
--- a/apps/web/e2e/tests/task/archive-confirmation-preference.spec.ts
+++ b/apps/web/e2e/tests/task/archive-confirmation-preference.spec.ts
@@ -7,7 +7,7 @@ test.describe("Archive confirmation preference", () => {
apiClient,
seedData,
}) => {
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const toggle = testPage.getByRole("switch", { name: "Confirm before archiving tasks" });
await expect(toggle).toBeChecked();
await toggle.click();
diff --git a/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts b/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts
index 4afeff875a..d9c41ffe4e 100644
--- a/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts
+++ b/apps/web/e2e/tests/task/mcp-task-agent-profile-default.spec.ts
@@ -27,7 +27,7 @@ test.describe("MCP-created task agent profile default", () => {
default_agent_profile_id: workspaceProfile.id,
});
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
await expect(testPage.getByText("create_task_kandev", { exact: true })).toBeVisible();
await expect(testPage.getByText("spawn_session_kandev", { exact: true })).toBeVisible();
const mcpToolHelp = testPage.getByRole("button", {
diff --git a/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts b/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts
index 8a7d6f900e..3e414c7d49 100644
--- a/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts
@@ -16,7 +16,7 @@ test.describe("Agent-generated task titles on mobile", () => {
try {
await apiClient.saveUserSettings({ agent_generated_task_titles: true });
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
await expect(
testPage.getByRole("switch", { name: "Use the agent for new task titles" }),
).toBeChecked();
diff --git a/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts b/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts
index 9556147092..d74312d716 100644
--- a/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts
@@ -7,7 +7,7 @@ test.describe("Archive confirmation preference on mobile", () => {
apiClient,
seedData,
}) => {
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const toggle = testPage.getByRole("switch", { name: "Confirm before archiving tasks" });
await expect(toggle).toBeChecked();
await toggle.click();
diff --git a/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts b/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts
index 827e1bd14b..e4642cdfa2 100644
--- a/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-mcp-task-agent-profile-default.spec.ts
@@ -5,12 +5,14 @@ test.describe("MCP-created task agent profile default on mobile", () => {
testPage,
apiClient,
}) => {
- await testPage.goto("/settings/general");
- const taskActionsLink = testPage.getByRole("link", { name: /Task Actions/ });
- await expect(taskActionsLink).toBeVisible({ timeout: 15_000 });
- await taskActionsLink.tap();
+ await testPage.goto("/settings");
+ // Task Actions is a section inside the Task Behavior page now; the menu row
+ // that reaches it is the page, not the section.
+ const taskBehaviorLink = testPage.getByRole("link", { name: /Task Behavior/ });
+ await expect(taskBehaviorLink).toBeVisible({ timeout: 15_000 });
+ await taskBehaviorLink.tap();
- await expect(testPage).toHaveURL(/\/settings\/general\/task-actions$/);
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/task-behavior$/);
await expect(
testPage.getByRole("heading", { name: "Task Actions", exact: true }),
).toBeVisible();
diff --git a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts
index 73f68b2df8..3be4459e4f 100644
--- a/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-unread-divider-preference.spec.ts
@@ -7,7 +7,7 @@ test.describe("Unread divider preference on mobile", () => {
prCapture,
}, testInfo) => {
await apiClient.saveUserSettings({ unread_divider: true });
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" });
await expect(toggle).toBeChecked();
diff --git a/apps/web/e2e/tests/task/unarchive-storage-recovery.spec.ts b/apps/web/e2e/tests/task/unarchive-storage-recovery.spec.ts
index 6ada8ed2bb..85b67ad0a1 100644
--- a/apps/web/e2e/tests/task/unarchive-storage-recovery.spec.ts
+++ b/apps/web/e2e/tests/task/unarchive-storage-recovery.spec.ts
@@ -30,7 +30,7 @@ test("unarchive restores a quarantined workspace before branch recovery", async
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
fs.utimesSync(root, old, old);
- await testPage.goto("/settings/system/storage");
+ await testPage.goto("/settings/system/data-storage");
await testPage.getByTestId("storage-run-now").click();
await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
"data-job-state",
diff --git a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts
index a55c6de0e0..b8f07d87be 100644
--- a/apps/web/e2e/tests/task/unread-divider-preference.spec.ts
+++ b/apps/web/e2e/tests/task/unread-divider-preference.spec.ts
@@ -7,7 +7,7 @@ test.describe("Unread divider preference", () => {
prCapture,
}, testInfo) => {
await apiClient.saveUserSettings({ unread_divider: true });
- await testPage.goto("/settings/general/task-actions");
+ await testPage.goto("/settings/preferences/task-behavior");
const toggle = testPage.getByRole("switch", { name: "Show New divider in transcripts" });
await expect(toggle).toBeChecked();
diff --git a/apps/web/e2e/tests/terminal/terminal-settings.spec.ts b/apps/web/e2e/tests/terminal/terminal-settings.spec.ts
index 43f035be22..418da337b3 100644
--- a/apps/web/e2e/tests/terminal/terminal-settings.spec.ts
+++ b/apps/web/e2e/tests/terminal/terminal-settings.spec.ts
@@ -4,7 +4,7 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
-const TERMINAL_SETTINGS_PATH = "/settings/general/terminal";
+const TERMINAL_SETTINGS_PATH = "/settings/preferences/terminal-editors";
// The full font stack value for "JetBrains Mono" preset
const JB_MONO_STACK = '"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace';
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index ad4acb29d7..ad83cb4881 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -223,20 +223,9 @@ export const noLiteralStringOptions = {
export const i18nGuardFiles = [
// The i18n runtime itself.
"lib/i18n/**/*.{ts,tsx}",
- // Settings → General → Appearance, migrated end-to-end as the worked example:
- // the page, the two sections it owns, and the settings chrome around them.
- "app/settings/general/appearance/**/*.{ts,tsx}",
- // Settings → General → Notifications; the shared settings chrome it renders
- // is migrated with the page that owns it, not here.
- "app/settings/general/notifications/**/*.{ts,tsx}",
- // Settings → General → Secrets.
- "app/settings/general/secrets/**/*.{ts,tsx}",
- // Settings → General → Terminal; the shared settings chrome is migrated with
- // the page that owns it, not here.
- "app/settings/general/terminal/**/*.{ts,tsx}",
- "components/app-sidebar/sections/settings/general-group.tsx",
+ // Settings → Preferences — Appearance, Notifications, Terminal & Editors and
+ // friends render straight from components/settings via the SPA route table.
"components/settings/general-settings.tsx",
- "components/settings/general-nav.ts",
"components/settings/language-settings.tsx",
"components/settings/notification-events-table.tsx",
"components/settings/notification-permission-section.tsx",
@@ -254,39 +243,37 @@ export const i18nGuardFiles = [
"components/settings/startup-page-settings-card.tsx",
"components/settings/system-metrics-settings-card.tsx",
"components/settings/terminal-settings.tsx",
- // Settings → General → Editors: the page, its state/section components, the
- // custom-editor form, and the editable-card shell that form renders inside.
- // `editable-card.tsx` is shared with repository-card.tsx, which is not
- // migrated — the guard is per-file, so that stays unaffected.
- "app/settings/general/editors/**/*.{ts,tsx}",
+ // Settings → Preferences → Terminal & Editors: the merged page, its
+ // state/section components, the custom-editor form, and the editable-card
+ // shell that form renders inside. `editable-card.tsx` is shared with
+ // repository-card.tsx, which is not migrated — the guard is per-file, so
+ // that stays unaffected.
"components/settings/editable-card.tsx",
"components/settings/editor-form.tsx",
"components/settings/editors-settings-state.tsx",
"components/settings/editors-settings.tsx",
"components/settings/lsp-status-location-setting.tsx",
"components/settings/lsp-language-options.ts",
- // Settings → General → Sprites.
- "app/settings/general/sprites/**/*.{ts,tsx}",
+ // Sprites.dev config, now a section on the Executors page.
"components/settings/sprites-settings.tsx",
- // Settings → General → Layouts: the page and the whole layouts component
- // directory. `layout-editor-actions.ts` and `use-layout-settings.ts` hold no
+ // Settings → Preferences → Layouts: the whole layouts component directory.
+ // `layout-editor-actions.ts` and `use-layout-settings.ts` hold no
// JSX, so `mode: "jsx-only"` never inspects them — the entries record that
// they are migrated, but only the pseudo-locale can prove it stays that way.
- "app/settings/general/layouts/**/*.{ts,tsx}",
"components/settings/layouts/**/*.{ts,tsx}",
- // Settings → General → Resource Metrics, Keyboard Shortcuts and Task Actions.
+ // Settings → Preferences → Appearance, Keyboard Shortcuts and Task behavior.
// The pages render from general-settings.tsx (already listed above); these
- // entries add the routes plus the per-setting cards those two pages own.
+ // entries add the merged pages plus the per-setting cards those pages own.
// Shortcut *names* still come from `lib/keyboard/shortcut-overrides.ts`, a
// registry shared with the un-migrated voice-mode page — deliberately not
// migrated here.
- "app/settings/general/resource-metrics/**/*.{ts,tsx}",
- "app/settings/general/keyboard-shortcuts/**/*.{ts,tsx}",
- "app/settings/general/task-actions/**/*.{ts,tsx}",
+ "components/settings/task-behavior-settings.tsx",
+ "components/settings/terminal-editors-settings.tsx",
"components/settings/anchored-prompt-bar-settings.tsx",
"components/settings/archive-confirmation-settings.tsx",
"components/settings/keyboard-shortcuts-card.tsx",
"components/settings/mcp-task-agent-profile-default-settings.tsx",
+ "components/settings/settings-menu-mode-card.tsx",
"components/settings/unread-divider-settings.tsx",
// Settings → Integrations → GitHub, connection and authentication half: the
// page, the settings shell, the status/identity panels, the connection dialog
@@ -535,9 +522,8 @@ export const i18nGuardFiles = [
// `status`, the routing `tier`, the permission `apply_method`, the watcher
// `kind`, and every model/mode `id` — only their labels are copy, and those
// travel as catalog keys and resolve at render.
- "app/settings/agent/**/*.{ts,tsx}",
"app/settings/agents/**/*.{ts,tsx}",
- "components/app-sidebar/sections/settings/agents-group.tsx",
+ "components/settings/agents/**/*.{ts,tsx}",
"components/settings/add-tui-agent-dialog.tsx",
"components/settings/agent-login-dialog.tsx",
"components/settings/agent-profile-delete-dialog.tsx",
@@ -595,7 +581,6 @@ export const i18nGuardFiles = [
// `/root/.cache/go-build` placeholder. All are interpolated as values so
// the pseudo-locale cannot turn them into dead pointers.
// - `StorageBusyResource.label`, which the backend renders.
- "app/settings/system/storage/**/*.{ts,tsx}",
"components/settings/system/storage/**/*.{ts,tsx}",
"hooks/domains/system/use-storage-maintenance.ts",
// The shell every System route renders. It takes title/description/actions as
@@ -726,7 +711,6 @@ export const i18nGuardFiles = [
// same trap on its own dynamic route. `pnpm lint` is unchanged by the repair,
// so these directories were always clean — just unguarded. See docs/i18n.md
// ("An entry can be born dead").
- "app/settings/workspace/[[]id[]]/workflows/**/*.{ts,tsx}",
"app/settings/workspace/use-workflow-creation.ts",
"app/settings/workspace/workspace-workflows-client.tsx",
"app/settings/workspace/workspace-workflows-dialogs.tsx",
@@ -851,10 +835,9 @@ export const i18nGuardFiles = [
// backup filenames, and the `/backups/` placeholder — all
// interpolated as values so the pseudo-locale cannot turn them into dead
// pointers.
- "app/settings/system/**/*.{ts,tsx}",
- "components/app-sidebar/sections/settings/system-group.tsx",
"components/settings/changelog-list.tsx",
"components/settings/system/*.{ts,tsx}",
+ "components/settings/system/**/*.{ts,tsx}",
"hooks/domains/system/use-desktop-updater.ts",
"hooks/domains/system/use-kandev-restart.ts",
"hooks/domains/system/use-self-update.ts",
@@ -885,11 +868,17 @@ export const i18nGuardFiles = [
// can keep them that way.
"components/app-sidebar/*.{ts,tsx}",
"components/app-sidebar/sections/*.tsx",
- "components/app-sidebar/sections/settings/account-group.tsx",
- "components/app-sidebar/sections/settings/executors-group.tsx",
"components/app-sidebar/sections/settings/settings-nav-primitives.tsx",
"components/app-sidebar/sections/settings/settings-tree.tsx",
- "components/app-sidebar/sections/settings/workspaces-group.tsx",
+ // The optional tree modes the menu grows under Workspaces, Agents and
+ // Executors. `settings-menu-node.tsx` is the only one with JSX; the three
+ // `.ts` entries hold branch data, expansion state and the store read, and
+ // carry catalog *keys* rather than copy — `mode: "jsx-only"` never inspects
+ // them, so the entries record that they were read, not that lint guards them.
+ "components/app-sidebar/sections/settings/settings-menu-branches.ts",
+ "components/app-sidebar/sections/settings/settings-menu-node.tsx",
+ "components/app-sidebar/sections/settings/use-settings-menu-branches.ts",
+ "components/app-sidebar/sections/settings/use-settings-menu-expansion.ts",
"components/app-status-bar/**/*.{ts,tsx}",
"components/theme-toggle.tsx",
// The command palette's own copy. `group` doubles as the palette's Map key and
@@ -966,12 +955,12 @@ export const i18nGuardFiles = [
// here to break; the name travels into the sentence as an interpolated value.
"app/settings/workspace/page.tsx",
"app/settings/workspace/[[]id[]]/page.tsx",
- "app/settings/workspace/[[]id[]]/repositories/page.tsx",
"app/settings/workspace/workspace-edit-client.tsx",
"app/settings/workspace/workspace-not-found-card.tsx",
"app/settings/workspace/workspace-repositories-client.tsx",
"app/settings/workspace/workspace-repositories-dialog.tsx",
"app/settings/workspace/workspaces-page-client.tsx",
+ "components/settings/workspaces/**/*.{ts,tsx}",
"components/settings/repository-branch-template-help.tsx",
"components/settings/repository-card.tsx",
"components/settings/repository-copy-files-help.tsx",
@@ -1104,7 +1093,6 @@ export const i18nGuardFiles = [
// registry, owned by whoever migrates `lib/keyboard`; under the pseudo-locale
// it reads as an English word inside accented copy, which is the oracle's
// known weak spot rather than a miss here.
- "app/settings/changelog/**/*.{ts,tsx}",
"app/settings/external-mcp/**/*.{ts,tsx}",
"app/settings/prompts/**/*.{ts,tsx}",
"app/settings/utility-agents/**/*.{ts,tsx}",
@@ -1224,7 +1212,6 @@ export const i18nGuardFiles = [
// escaped form is what proves an entry is live.
"components/automations/*.{ts,tsx}",
"components/automations/trigger-configs/*.tsx",
- "app/settings/automations/page.tsx",
"app/settings/workspace/[[]id[]]/automations/**/*.tsx",
"hooks/domains/settings/use-automation-runs.ts",
// Settings → Plugins, Settings → Account, and the plural
diff --git a/apps/web/hooks/domains/integrations/use-enabled-integrations.ts b/apps/web/hooks/domains/integrations/use-enabled-integrations.ts
new file mode 100644
index 0000000000..56fde9ee77
--- /dev/null
+++ b/apps/web/hooks/domains/integrations/use-enabled-integrations.ts
@@ -0,0 +1,55 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useAzureDevOpsAvailable } from "@/hooks/domains/azure-devops/use-azure-devops-availability";
+import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
+import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
+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 { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations";
+
+export type IntegrationSlug = (typeof WORKSPACE_INTEGRATIONS)[number][0];
+
+/**
+ * Which of a workspace's integrations are actually connected.
+ *
+ * "Connected" is the backend's verdict, not a local preference: each probe
+ * reports true only once credentials are stored **and** the most recent health
+ * check passed (`useIntegrationAuthed`). Every probe starts at `false` and
+ * resets on a workspace switch, so a badge never appears before its
+ * integration has answered and never carries over from another workspace.
+ *
+ * The predicates are the ones the settings tree used before it flattened into
+ * a menu, kept identical so the badge still means what it used to. Note the
+ * asymmetry that carries over with them: most read the connection alone, while
+ * Sentry's `available` also requires its per-workspace toggle, so a connected
+ * Sentry whose toggle is off reports false.
+ */
+export function useEnabledIntegrations(workspaceId: string): ReadonlySet {
+ const azureDevOps = useAzureDevOpsAvailable(workspaceId);
+ const { status: githubStatus } = useGitHubStatus(workspaceId);
+ const gitlab = useGitLabAvailable();
+ const jira = useJiraAuthed(workspaceId);
+ const linear = useLinearAuthed(workspaceId);
+ const sentry = useSentryAvailable(workspaceId);
+ // GitHub reports a device-flow login and a configured PAT separately; either
+ // one is a working connection.
+ const github = Boolean(githubStatus?.authenticated || githubStatus?.token_configured);
+
+ return useMemo(() => {
+ // A `Record` over the slug union rather than a list of spreads: adding an
+ // integration to WORKSPACE_INTEGRATIONS then fails to compile here until it
+ // declares how it is probed, instead of silently never showing a badge.
+ const connected: Record = {
+ "azure-devops": azureDevOps,
+ github,
+ gitlab,
+ jira,
+ linear,
+ sentry,
+ };
+ return new Set(WORKSPACE_INTEGRATIONS.map(([slug]) => slug).filter((slug) => connected[slug]));
+ }, [azureDevOps, github, gitlab, jira, linear, sentry]);
+}
diff --git a/apps/web/hooks/domains/settings/use-settings-discovery.test.ts b/apps/web/hooks/domains/settings/use-settings-discovery.test.ts
index 46a67cce29..00d66871ea 100644
--- a/apps/web/hooks/domains/settings/use-settings-discovery.test.ts
+++ b/apps/web/hooks/domains/settings/use-settings-discovery.test.ts
@@ -49,17 +49,17 @@ describe("useSettingsDiscovery hook", () => {
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);
+ expect(result.current.some((entry) => entry.id === "account-security")).toBe(false);
mocks.authEnabled = true;
rerender();
- expect(result.current.some((entry) => entry.groupId === "account")).toBe(false);
+ expect(result.current.some((entry) => entry.id === "account-security")).toBe(false);
mocks.state.auth.mode = "enabled";
rerender();
- expect(result.current.some((entry) => entry.groupId === "account")).toBe(true);
+ expect(result.current.some((entry) => entry.id === "account-security")).toBe(true);
});
it("exposes user administration only to admins", () => {
diff --git a/apps/web/hooks/use-home-affordance.test.ts b/apps/web/hooks/use-home-affordance.test.ts
new file mode 100644
index 0000000000..debc3f7ba5
--- /dev/null
+++ b/apps/web/hooks/use-home-affordance.test.ts
@@ -0,0 +1,74 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useHomeAffordance } from "./use-home-affordance";
+
+const state = {
+ workspaces: { activeId: "ws-1" as string | null },
+ appSidebar: { settingsMode: false, collapsed: false },
+};
+
+let inOffice = false;
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
+}));
+
+vi.mock("@/hooks/use-in-office", () => ({
+ useInOffice: () => inOffice,
+}));
+
+describe("useHomeAffordance", () => {
+ beforeEach(() => {
+ state.workspaces.activeId = "ws-1";
+ state.appSidebar.settingsMode = false;
+ state.appSidebar.collapsed = false;
+ inOffice = false;
+ });
+
+ it("defaults to a phone-only crumb pointing at the workspace overview", () => {
+ const { result } = renderHook(() => useHomeAffordance());
+
+ expect(result.current.mode).toBe("phone");
+ expect(result.current.href).toBe("/?home=overview&workspaceId=ws-1");
+ });
+
+ it("drops the workspace param when no workspace is active", () => {
+ state.workspaces.activeId = null;
+
+ const { result } = renderHook(() => useHomeAffordance());
+
+ expect(result.current.href).toBe("/?home=overview");
+ });
+
+ it("returns none for root-variant pages", () => {
+ const { result } = renderHook(() => useHomeAffordance("root"));
+
+ expect(result.current.mode).toBe("none");
+ });
+
+ it("returns always while the expanded sidebar shows the settings tree", () => {
+ state.appSidebar.settingsMode = true;
+
+ const { result } = renderHook(() => useHomeAffordance());
+
+ expect(result.current.mode).toBe("always");
+ });
+
+ it("stays phone-only when settings mode is on but the sidebar is collapsed", () => {
+ // The takeover only renders expanded; the collapsed rail keeps its icons.
+ state.appSidebar.settingsMode = true;
+ state.appSidebar.collapsed = true;
+
+ const { result } = renderHook(() => useHomeAffordance());
+
+ expect(result.current.mode).toBe("phone");
+ });
+
+ it("lands on the office dashboard inside Office", () => {
+ inOffice = true;
+
+ const { result } = renderHook(() => useHomeAffordance());
+
+ expect(result.current.href).toBe("/office");
+ });
+});
diff --git a/apps/web/hooks/use-home-affordance.ts b/apps/web/hooks/use-home-affordance.ts
new file mode 100644
index 0000000000..ac862d2b5e
--- /dev/null
+++ b/apps/web/hooks/use-home-affordance.ts
@@ -0,0 +1,33 @@
+"use client";
+
+import { useAppStore } from "@/components/state-provider";
+import { useNavContext } from "@/hooks/use-app-destinations";
+import { homeDestinationHref } from "@/lib/navigation/core-destinations";
+
+export type HomeAffordance = {
+ mode: "none" | "phone" | "always";
+ href: string;
+};
+
+/**
+ * The one rule for whether a page shows a home crumb and where it leads.
+ *
+ * - `none` for root-variant pages — the surface *is* home.
+ * - `always` while the sidebar's settings takeover is active (expanded sidebar
+ * in settings mode): the sidebar's Home row is replaced by the settings tree,
+ * so desktop users would otherwise be left with only the unlabelled logo.
+ * - `phone` otherwise — below `md` the sidebar is hidden entirely.
+ *
+ * The href comes from the navigation manifest's home entry, so it follows the
+ * active workspace and lands on the office dashboard inside Office.
+ */
+export function useHomeAffordance(variant: "breadcrumb" | "root" = "breadcrumb"): HomeAffordance {
+ const ctx = useNavContext();
+ const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
+ const collapsed = useAppStore((s) => s.appSidebar.collapsed);
+ const href = homeDestinationHref(ctx);
+ if (variant === "root") return { mode: "none", href };
+ // The takeover only renders in the expanded sidebar (see AppSidebarNavigation),
+ // so a collapsed sidebar still shows its icon rail and needs no extra crumb.
+ return { mode: settingsMode && !collapsed ? "always" : "phone", href };
+}
diff --git a/apps/web/hooks/use-mobile-menu-sheet-state.ts b/apps/web/hooks/use-mobile-menu-sheet-state.ts
index 08d382d51c..069f8aebf7 100644
--- a/apps/web/hooks/use-mobile-menu-sheet-state.ts
+++ b/apps/web/hooks/use-mobile-menu-sheet-state.ts
@@ -1,6 +1,6 @@
"use client";
-import { useRef, useState } from "react";
+import { useRef } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useAppStore } from "@/components/state-provider";
@@ -125,7 +125,6 @@ export function useMobileMenuSheetState({
>) {
const contentRef = useRef(null);
const router = useRouter();
- const [improveOpen, setImproveOpen] = useState(false);
const { isMobile } = useResponsiveBreakpoint();
const {
workflows,
@@ -189,21 +188,6 @@ export function useMobileMenuSheetState({
event.preventDefault();
contentRef.current?.focus({ preventScroll: true });
};
- const openImproveKandev = () => {
- onOpenChange(false);
- requestAnimationFrame(() => setImproveOpen(true));
- };
- return {
- contentRef,
- router,
- improveOpen,
- setImproveOpen,
- isMobile,
- viewValue,
- handleViewChange,
- displayOptions,
- focusMenu,
- openImproveKandev,
- };
+ return { contentRef, isMobile, viewValue, handleViewChange, displayOptions, focusMenu };
}
diff --git a/apps/web/lib/desktop/command-actions.test.ts b/apps/web/lib/desktop/command-actions.test.ts
index aa976eba49..686cb24f6f 100644
--- a/apps/web/lib/desktop/command-actions.test.ts
+++ b/apps/web/lib/desktop/command-actions.test.ts
@@ -13,7 +13,7 @@ describe("desktop command actions", () => {
actions["open-settings"]();
- expect(navigate).toHaveBeenCalledWith("/settings/general");
+ expect(navigate).toHaveBeenCalledWith("/settings/preferences/appearance");
});
it("uses the shared New Task request instead of creating another dialog", () => {
@@ -53,7 +53,7 @@ describe("desktop command actions", () => {
listeners.get("open-settings")?.();
listeners.get("new-task")?.();
expect(closeContext).toHaveBeenCalledOnce();
- expect(navigate).toHaveBeenCalledWith("/settings/general");
+ expect(navigate).toHaveBeenCalledWith("/settings/preferences/appearance");
expect(requestNewTask).toHaveBeenCalledOnce();
stop();
expect(unlisten).toHaveBeenCalledTimes(3);
diff --git a/apps/web/lib/desktop/command-actions.ts b/apps/web/lib/desktop/command-actions.ts
index 2fbc060c1a..d201da2263 100644
--- a/apps/web/lib/desktop/command-actions.ts
+++ b/apps/web/lib/desktop/command-actions.ts
@@ -17,7 +17,7 @@ export function createDesktopCommandActions(
): DesktopCommandActions {
return {
"close-context": dependencies.closeContext,
- "open-settings": () => dependencies.navigate("/settings/general"),
+ "open-settings": () => dependencies.navigate("/settings/preferences/appearance"),
"new-task": dependencies.requestNewTask,
};
}
diff --git a/apps/web/lib/navigation/core-destinations.test.ts b/apps/web/lib/navigation/core-destinations.test.ts
index 58322b3808..c376f112f2 100644
--- a/apps/web/lib/navigation/core-destinations.test.ts
+++ b/apps/web/lib/navigation/core-destinations.test.ts
@@ -81,7 +81,24 @@ describe("navigation coverage guardrails", () => {
).toEqual([]);
});
- it("keeps the exemption list honest", () => {
+ it("keeps Home and Tasks in the mobile menu's primary section", () => {
+ // Settings, Office and plugin pages have neither kanban's brand link nor
+ // its View toggle — their nav sheet's Home and Tasks rows come from here.
+ // Kanban's drawer opts out at its call site via omitSections={["primary"]}.
+ const primary = resolveDestinations({
+ surface: "mobileMenu",
+ section: "primary",
+ ctx: NO_WORKSPACE_CONTEXT,
+ });
+
+ expect(ids(primary)).toEqual(["home", "tasks"]);
+ });
+
+ // Shape guard for whatever comes back into MOBILE_MENU_EXEMPTIONS: the list is
+ // empty today (the coverage test above is what carries the weight), so this
+ // asserts nothing until an entry is added — at which point it must name a real
+ // destination that genuinely has no mobileMenu surface, and say why.
+ it("holds any future exemption to naming a real destination and a reason", () => {
for (const [id, reason] of Object.entries(MOBILE_MENU_EXEMPTIONS)) {
const destination = APP_DESTINATIONS.find((entry) => entry.id === id);
expect(destination, `${id} is exempt but is not a destination`).toBeTruthy();
diff --git a/apps/web/lib/navigation/core-destinations.ts b/apps/web/lib/navigation/core-destinations.ts
index 6f3ebc4026..84d2324131 100644
--- a/apps/web/lib/navigation/core-destinations.ts
+++ b/apps/web/lib/navigation/core-destinations.ts
@@ -11,15 +11,16 @@
* the mobile menu without a recorded reason.
*
* What this manifest currently drives: the sidebar's integrations section and
- * footer insight buttons, the sidebar and mobile plugin nav groups, the mobile
- * menu's integrations and utility groups, and the command palette's Navigation
- * group.
+ * footer insight buttons, the sidebar and mobile plugin nav groups, the shared
+ * `AppNavSections` block (the mobile nav sheet and the kanban drawer's global
+ * tail), the topbar's home crumb (via `homeDestinationHref`), and the command
+ * palette's Navigation group.
*
* What it does not drive yet (deliberately, see the notes below): the sidebar's
- * primary nav and the mobile header/View-toggle affordances for Home and Tasks,
- * action-shaped controls (New task, Inbox, quick chat, the settings gear, the
- * Office↔Kanban switch), the settings tree, and Office's own navigation section.
- * Those stay bespoke until the shared-shell work lands.
+ * primary nav, action-shaped controls (New task, Inbox, quick chat, the
+ * settings gear, the Office↔Kanban switch), the settings tree, and Office's own
+ * navigation section — the latter two travel as `pageNav` through `PageShell`
+ * instead of flattening into this catalog.
*/
import {
IconBrandGithub,
@@ -33,8 +34,20 @@ import {
} from "@tabler/icons-react";
import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon";
import { linkToTaskOverview, linkToTasks } from "@/lib/links";
-import { EVERYWHERE, MENU_AND_PALETTE, PALETTE_ONLY, SIDEBAR_AND_MENU } from "./surface-policy";
-import type { Destination } from "./types";
+import { EVERYWHERE, MENU_AND_PALETTE, SIDEBAR_AND_MENU } from "./surface-policy";
+import type { Destination, NavContext } from "./types";
+
+/**
+ * Where "home" is for the current mode and workspace — the office dashboard
+ * inside Office, the workspace task overview otherwise. Exported so the
+ * topbar's home crumb (`useHomeAffordance`) and the manifest's home entry
+ * resolve one rule and cannot disagree.
+ */
+export function homeDestinationHref(ctx: NavContext): string {
+ return ctx.inOffice
+ ? "/office"
+ : linkToTaskOverview({ workspaceId: ctx.workspaceId ?? undefined });
+}
/**
* Not listed here, on purpose:
@@ -52,11 +65,12 @@ export const APP_DESTINATIONS: Destination[] = [
labelKey: "sidebar:home",
icon: IconHome,
section: "primary",
- href: (ctx) =>
- ctx.inOffice ? "/office" : linkToTaskOverview({ workspaceId: ctx.workspaceId ?? undefined }),
- // Palette-only: the sidebar's primary nav and the mobile header's brand link
- // already own "go home", so a second entry would duplicate them.
- surfaces: PALETTE_ONLY,
+ href: homeDestinationHref,
+ // The sidebar's primary nav still owns "go home" on desktop; the mobile
+ // menu offers it so shells without kanban's brand link (Settings, Office,
+ // plugin pages) keep a phone home row. Kanban's drawer opts out via
+ // omitSections.
+ surfaces: MENU_AND_PALETTE,
palette: {
id: "nav-home",
labelKey: "common:commandGoToHome",
@@ -72,9 +86,9 @@ export const APP_DESTINATIONS: Destination[] = [
icon: IconList,
section: "primary",
href: (ctx) => linkToTasks(ctx.workspaceId ?? undefined),
- // Palette-only: the sidebar's Tasks section and the mobile menu's View
- // toggle are the established ways to reach the task list.
- surfaces: PALETTE_ONLY,
+ // The sidebar's Tasks section still owns this on desktop; the mobile menu
+ // offers it for shells without kanban's View toggle.
+ surfaces: MENU_AND_PALETTE,
palette: {
id: "nav-tasks",
labelKey: "common:commandGoToAllTasks",
@@ -104,11 +118,14 @@ export const APP_DESTINATIONS: Destination[] = [
// The sidebar's gear also toggles the sidebar's settings takeover, so it
// stays bespoke; this entry serves the mobile menu and the palette.
surfaces: MENU_AND_PALETTE,
+ // No href override: the command lands on `/settings`, which resolves to the
+ // page you were last on (desktop) or the settings index (phone). It used to
+ // point at `/settings/general` to skip the card grid that `/settings`
+ // rendered; that grid is gone and `/settings/general` only redirects now.
palette: {
id: "nav-settings",
labelKey: "common:commandGoToSettings",
keywordsKey: "common:commandGoToSettingsKeywords",
- href: "/settings/general",
},
},
{
diff --git a/apps/web/lib/navigation/resolve-destinations.test.ts b/apps/web/lib/navigation/resolve-destinations.test.ts
index 68b9a89c1e..1d0e228dd7 100644
--- a/apps/web/lib/navigation/resolve-destinations.test.ts
+++ b/apps/web/lib/navigation/resolve-destinations.test.ts
@@ -95,12 +95,24 @@ describe("resolveDestinations", () => {
});
it("applies palette overrides for id, copy and href", () => {
+ const resolved = resolveDestinations({ surface: "palette", ctx: KANBAN });
+ const home = resolved.find((destination) => destination.id === "home");
+
+ // The palette's Home command has always gone to the workspace-less overview,
+ // while the row href follows the active workspace.
+ expect(home?.href).toBe("/?home=overview");
+ expect(home?.label).toBe("common:commandGoToHome");
+ expect(home?.palette?.id).toBe("nav-home");
+ });
+
+ it("falls back to the row href when the palette entry sets none", () => {
const resolved = resolveDestinations({ surface: "palette", ctx: KANBAN });
const settings = resolved.find((destination) => destination.id === "settings");
- expect(settings?.href).toBe("/settings/general");
+ // `/settings` resolves per surface (see `SettingsIndex`), so the command has
+ // no reason to name a page itself.
+ expect(settings?.href).toBe("/settings");
expect(settings?.label).toBe("common:commandGoToSettings");
- expect(settings?.palette?.id).toBe("nav-settings");
});
it("keeps the surface-agnostic href outside the palette", () => {
diff --git a/apps/web/lib/navigation/surface-policy.ts b/apps/web/lib/navigation/surface-policy.ts
index fe3acd6aa1..43e0088409 100644
--- a/apps/web/lib/navigation/surface-policy.ts
+++ b/apps/web/lib/navigation/surface-policy.ts
@@ -40,6 +40,7 @@ export const PALETTE_ONLY: NavSurface[] = ["palette"];
* claim a surface that never draws it — `core-destinations.test.ts` enforces that.
*/
export const MOBILE_MENU_SECTIONS: NavSection[] = [
+ "primary",
"plugins",
"integrations",
"insights",
@@ -51,15 +52,15 @@ export const MOBILE_MENU_UTILITY_SECTIONS: StaticNavSection[] = ["insights", "ut
/**
* Destinations deliberately not offered in the mobile menu, each with the mobile
- * affordance that owns it instead. Everything else must be offered there: the
- * sidebar is hidden below `md`, so an omission means unreachable on a phone —
- * which is exactly how `/stats` ended up with no phone entry point.
- * `core-destinations.test.ts` fails on any omission that is not listed here.
+ * affordance that owns it instead — the sidebar is hidden below `md`, so an
+ * omission means unreachable on a phone, which is exactly how `/stats` ended up
+ * with no phone entry point. `core-destinations.test.ts` fails on any omission
+ * that is not listed here. Empty today: every destination is offered, and a
+ * surface that already covers one opts out at its own call site instead (the
+ * kanban drawer passes `omitSections={["primary"]}` because its brand link and
+ * View toggle cover Home and Tasks).
*/
-export const MOBILE_MENU_EXEMPTIONS: Record = {
- home: "the mobile header's brand link is the phone's home affordance",
- tasks: "the mobile menu's View toggle switches between Kanban and List",
-};
+export const MOBILE_MENU_EXEMPTIONS: Record = {};
/** Catalog key the palette groups navigation commands under. */
export const PALETTE_NAVIGATION_GROUP_KEY = "common:commandGroupNavigation";
diff --git a/apps/web/lib/routing/client-router.ts b/apps/web/lib/routing/client-router.ts
index 1fa5c36ce6..dc270196a4 100644
--- a/apps/web/lib/routing/client-router.ts
+++ b/apps/web/lib/routing/client-router.ts
@@ -7,6 +7,13 @@ import { pushNavigationState, replaceNavigationState } from "./navigation-guard"
type NavigateOptions = {
scroll?: boolean;
+ /**
+ * Runs only once the navigation has actually committed. The unsaved-changes
+ * guard can cancel a `push`, and `push` itself returns void, so a caller that
+ * pairs navigation with local state (the sidebar's settings takeover) has no
+ * other way to tell "we moved" from "the user chose to stay".
+ */
+ onNavigated?: () => void;
};
export type AppRouter = {
@@ -57,6 +64,7 @@ function navigate(mode: "push" | "replace", href: string, options?: NavigateOpti
function finishNavigation(options?: NavigateOptions): void {
if (options?.scroll !== false) window.scrollTo(0, 0);
dispatchLocationChange();
+ options?.onNavigated?.();
}
/**
diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts
index fb30152d32..29988747e2 100644
--- a/apps/web/lib/settings-discovery/catalog.test.ts
+++ b/apps/web/lib/settings-discovery/catalog.test.ts
@@ -71,13 +71,15 @@ describe("settings discovery catalog invariants", () => {
);
});
- it("indexes stable integration, system, and account sections", () => {
+ it("indexes stable 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-database",
+ "system-backups",
+ "system-logs",
+ "system-licenses",
"system-storage-actions",
"system-storage-schedule",
"system-storage-workspaces",
@@ -88,6 +90,8 @@ describe("settings discovery catalog invariants", () => {
"account-sessions",
]),
);
+ // Integrations are workspace-scoped: never in the static catalog.
+ expect(ids.some((id) => id.startsWith("integration-"))).toBe(false);
});
it("has no parent cycles", () => {
@@ -163,7 +167,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
});
expect(resolved.find((entry) => entry.id === "workspace:workspace / one")?.href).toBe(
- "/settings/workspace/workspace%20%2F%20one",
+ "/settings/workspaces/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",
@@ -172,7 +176,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
"/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",
+ "/settings/workspaces/workspace%20%2F%20one#setting-workspace-workspace%20%2F%20one-name",
);
expect(
resolved.find((entry) => entry.id === "agent-profile:profile / one:environment-variables")
@@ -225,10 +229,11 @@ describe("resolveSettingsDiscovery visibility", () => {
executors: [],
});
- expect(hidden.some((entry) => entry.groupId === "account")).toBe(false);
+ expect(hidden.some((entry) => entry.id === "account-security")).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 === "account-security")).toBe(true);
+ expect(visible.some((entry) => entry.id === "account-tokens")).toBe(true);
expect(visible.some((entry) => entry.id === "system-users")).toBe(true);
});
@@ -250,7 +255,16 @@ describe("resolveSettingsDiscovery visibility", () => {
executors: [],
});
- expect(withoutWorkspace.some((entry) => entry.id === GITHUB_CONNECTION_ID)).toBe(false);
- expect(withWorkspace.some((entry) => entry.id === GITHUB_CONNECTION_ID)).toBe(true);
+ expect(
+ withoutWorkspace.some((entry) => entry.id.endsWith("integration-github-connection")),
+ ).toBe(false);
+ const connection = withWorkspace.find((entry) =>
+ entry.id.endsWith("integration-github-connection"),
+ );
+ expect(connection?.href).toBe(
+ "/settings/workspaces/workspace-1/integrations/github#setting-integration-github-connection",
+ );
+ // Per-workspace results carry the workspace name as their path prefix.
+ expect(connection?.breadcrumb[1]).toBe("Main");
});
});
diff --git a/apps/web/lib/settings-discovery/catalog.ts b/apps/web/lib/settings-discovery/catalog.ts
index aacde4087b..71d95bc3bb 100644
--- a/apps/web/lib/settings-discovery/catalog.ts
+++ b/apps/web/lib/settings-discovery/catalog.ts
@@ -1,4 +1 @@
-export {
- SETTINGS_DISCOVERY_DEFINITIONS,
- SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS,
-} from "./catalog/index";
+export * from "./catalog/index";
diff --git a/apps/web/lib/settings-discovery/catalog/account.ts b/apps/web/lib/settings-discovery/catalog/account.ts
index c73055f365..2597f1fbb0 100644
--- a/apps/web/lib/settings-discovery/catalog/account.ts
+++ b/apps/web/lib/settings-discovery/catalog/account.ts
@@ -8,21 +8,11 @@ export const ACCOUNT_SETTINGS_TARGETS = {
} 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",
+ groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
order: 810,
requires: "account",
@@ -31,8 +21,7 @@ export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "account-tokens",
kind: "page",
labelKey: "sidebar:apiTokens",
- parentId: "account",
- groupId: "account",
+ groupId: "access",
href: "/settings/account/tokens",
order: 820,
requires: "account",
@@ -42,7 +31,7 @@ export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:password",
parentId: "account-security",
- groupId: "account",
+ groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
targetId: ACCOUNT_SETTINGS_TARGETS.password,
order: 811,
@@ -53,7 +42,7 @@ export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "section",
labelKey: "settings:activeSessions",
parentId: "account-security",
- groupId: "account",
+ groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
targetId: ACCOUNT_SETTINGS_TARGETS.sessions,
order: 812,
diff --git a/apps/web/lib/settings-discovery/catalog/agents.ts b/apps/web/lib/settings-discovery/catalog/agents.ts
index 586d369f42..1b0b851868 100644
--- a/apps/web/lib/settings-discovery/catalog/agents.ts
+++ b/apps/web/lib/settings-discovery/catalog/agents.ts
@@ -1,6 +1,7 @@
import type { SettingsDiscoveryDefinition } from "../types";
export const AGENTS_SETTINGS_HREF = "/settings/agents";
+export const AGENTS_BROWSE_SETTINGS_HREF = `${AGENTS_SETTINGS_HREF}/browse`;
export const AGENT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
{
@@ -12,4 +13,13 @@ export const AGENT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
href: AGENTS_SETTINGS_HREF,
order: 300,
},
+ {
+ id: "agents-browse",
+ kind: "page",
+ labelKey: "agents:browseAvailableAgents",
+ parentId: "agents",
+ groupId: "agents",
+ href: AGENTS_BROWSE_SETTINGS_HREF,
+ order: 310,
+ },
];
diff --git a/apps/web/lib/settings-discovery/catalog/executors.ts b/apps/web/lib/settings-discovery/catalog/executors.ts
index 7aa6fd5cff..125c775a5d 100644
--- a/apps/web/lib/settings-discovery/catalog/executors.ts
+++ b/apps/web/lib/settings-discovery/catalog/executors.ts
@@ -1,4 +1,5 @@
import type { SettingsDiscoveryDefinition } from "../types";
+import { GENERAL_SETTINGS_TARGETS } from "./preferences";
export const EXECUTORS_SETTINGS_HREF = "/settings/executors";
@@ -8,8 +9,29 @@ export const EXECUTOR_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "page",
labelKey: "common:executors",
aliasesKey: "common:commandExecutorsSettingsKeywords",
- groupId: "executors",
+ groupId: "agents",
href: EXECUTORS_SETTINGS_HREF,
order: 400,
},
+ // Sprites.dev config lives on the Executors page: it configures that backend.
+ {
+ id: "sprites-connection",
+ kind: "section",
+ labelKey: "settings:connection",
+ parentId: "executors",
+ groupId: "agents",
+ href: EXECUTORS_SETTINGS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.spritesConnection,
+ order: 410,
+ },
+ {
+ id: "sprites-instances",
+ kind: "section",
+ labelKey: "settings:runningSprites",
+ parentId: "executors",
+ groupId: "agents",
+ href: EXECUTORS_SETTINGS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.spritesInstances,
+ order: 411,
+ },
];
diff --git a/apps/web/lib/settings-discovery/catalog/groups.ts b/apps/web/lib/settings-discovery/catalog/groups.ts
index a8d1d36609..f8da680349 100644
--- a/apps/web/lib/settings-discovery/catalog/groups.ts
+++ b/apps/web/lib/settings-discovery/catalog/groups.ts
@@ -1,11 +1,9 @@
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: "preferences", labelKey: "settings:preferences", order: 0 },
+ { id: "workspaces", labelKey: "settings:workspacesAndAccess", 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 },
+ { id: "access", labelKey: "settings:accessControl", order: 3 },
+ { id: "system", labelKey: "common:system", order: 4 },
];
diff --git a/apps/web/lib/settings-discovery/catalog/index.ts b/apps/web/lib/settings-discovery/catalog/index.ts
index d2fb38ed2f..1abf5f4119 100644
--- a/apps/web/lib/settings-discovery/catalog/index.ts
+++ b/apps/web/lib/settings-discovery/catalog/index.ts
@@ -1,14 +1,14 @@
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 { PREFERENCES_DISCOVERY_DEFINITIONS } from "./preferences";
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,
+ ...PREFERENCES_DISCOVERY_DEFINITIONS,
...WORKSPACE_DISCOVERY_DEFINITIONS,
...AGENT_DISCOVERY_DEFINITIONS,
...EXECUTOR_DISCOVERY_DEFINITIONS,
@@ -18,24 +18,60 @@ export const SETTINGS_DISCOVERY_DEFINITIONS = [
...ACCOUNT_DISCOVERY_DEFINITIONS,
];
+// Exclusion reasons shared by more than one path. These are maintainer-facing
+// notes, not UI copy, so they stay untranslated.
+const TO_APPEARANCE = "Redirects to the canonical Appearance page.";
+const TO_DATA_STORAGE = "Redirects to the canonical Data & storage page.";
+const TO_KEYBOARD_SHORTCUTS = "Redirects to the canonical Keyboard Shortcuts page.";
+const TO_TASK_BEHAVIOR = "Redirects to the canonical Task behavior page.";
+const TO_TERMINAL_EDITORS = "Redirects to the canonical Terminal & Editors page.";
+const TO_WORKSPACE_INTEGRATIONS = "Redirects into the active workspace's Integrations tab.";
+
export const SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS: Record = {
"/settings": "Owned by the top-level Go to Settings destination.",
- "/settings/general/changes-panel": "Redirects to the canonical Appearance page.",
- "/settings/general/chat-input": "Redirects to the canonical Keyboard Shortcuts page.",
- "/settings/general/resource-metrics": "Redirects to the canonical Appearance page.",
- "/settings/general/shell": "Redirects to the canonical Terminal page.",
+ "/settings/preferences": TO_APPEARANCE,
+ "/settings/general": "Legacy prefix; redirects to Preferences pages.",
+ "/settings/general/appearance": TO_APPEARANCE,
+ "/settings/general/changes-panel": TO_APPEARANCE,
+ "/settings/general/chat-input": TO_KEYBOARD_SHORTCUTS,
+ "/settings/general/editors": TO_TERMINAL_EDITORS,
+ "/settings/general/keyboard-shortcuts": TO_KEYBOARD_SHORTCUTS,
+ "/settings/general/layouts": "Redirects to the canonical Layouts page.",
+ "/settings/general/message-queue": TO_TASK_BEHAVIOR,
+ "/settings/general/notifications": "Redirects to the canonical Notifications page.",
+ "/settings/general/resource-metrics": TO_APPEARANCE,
+ "/settings/general/secrets": "Redirects to the canonical Secrets page.",
+ "/settings/general/shell": TO_TERMINAL_EDITORS,
+ "/settings/general/sprites": "Redirects to the Executors page, which owns Sprites.",
+ "/settings/general/task-actions": TO_TASK_BEHAVIOR,
+ "/settings/general/terminal": TO_TERMINAL_EDITORS,
+ "/settings/workspace": "Legacy path; redirects to the canonical Workspaces page.",
+ "/settings/automations": "Redirects into the active workspace's Automations tab.",
+ "/settings/integrations": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/azure-devops": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/github": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/gitlab": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/jira": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/linear": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/sentry": TO_WORKSPACE_INTEGRATIONS,
+ "/settings/integrations/slack": TO_WORKSPACE_INTEGRATIONS,
"/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/system/database": TO_DATA_STORAGE,
+ "/settings/system/backups": TO_DATA_STORAGE,
+ "/settings/system/storage": TO_DATA_STORAGE,
+ "/settings/system/logs": TO_DATA_STORAGE,
+ "/settings/system/licenses": "Redirects to the canonical About page.",
+ "/settings/system/message-queue": TO_TASK_BEHAVIOR,
"/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 "./preferences";
export * from "./standalone";
export * from "./system";
export * from "./workspaces";
diff --git a/apps/web/lib/settings-discovery/catalog/integrations.ts b/apps/web/lib/settings-discovery/catalog/integrations.ts
index 313a09f889..b135211662 100644
--- a/apps/web/lib/settings-discovery/catalog/integrations.ts
+++ b/apps/web/lib/settings-discovery/catalog/integrations.ts
@@ -18,43 +18,8 @@ export const INTEGRATION_SETTINGS_TARGETS = {
sentry: "setting-integration-sentry-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",
- },
- ];
- }),
-];
+// Integrations are workspace-scoped: their discovery entries are generated per
+// workspace in resolve.ts, so search results read " › Integrations › …".
+export const INTEGRATION_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [];
export const WORKSPACE_INTEGRATIONS = INTEGRATIONS;
diff --git a/apps/web/lib/settings-discovery/catalog/general.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts
similarity index 56%
rename from apps/web/lib/settings-discovery/catalog/general.ts
rename to apps/web/lib/settings-discovery/catalog/preferences.ts
index ef7b08d550..4288c71f71 100644
--- a/apps/web/lib/settings-discovery/catalog/general.ts
+++ b/apps/web/lib/settings-discovery/catalog/preferences.ts
@@ -3,17 +3,20 @@ 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 PREFERENCES_SETTINGS_HREF = "/settings/preferences";
+const APPEARANCE_ID = "preferences-appearance";
+export const APPEARANCE_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/appearance`;
+export const LAYOUTS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/layouts`;
+const TERMINAL_EDITORS_ID = "preferences-terminal-editors";
+export const TERMINAL_EDITORS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/terminal-editors`;
+const NOTIFICATIONS_ID = "preferences-notifications";
+export const NOTIFICATIONS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/notifications`;
+export const KEYBOARD_SHORTCUTS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/keyboard-shortcuts`;
+const TASK_BEHAVIOR_ID = "preferences-task-behavior";
+export const TASK_BEHAVIOR_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/task-behavior`;
export const GENERAL_SETTINGS_TARGETS = {
colorTheme: "setting-color-theme",
+ settingsMenuMode: "setting-settings-menu-mode",
startupPage: "setting-startup-page",
displayLanguage: "setting-display-language",
changesPanelLayout: "setting-changes-panel-layout",
@@ -32,26 +35,18 @@ export const GENERAL_SETTINGS_TARGETS = {
archiveConfirmation: "setting-archive-confirmation",
unreadMessages: "setting-unread-messages",
transcriptNavigation: "setting-transcript-navigation",
+ messageQueue: "setting-message-queue",
spritesConnection: "setting-sprites-connection",
spritesInstances: "setting-sprites-instances",
} as const;
-export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
+export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
{
- id: "general",
- kind: "page",
- labelKey: "settings:general",
- groupId: "general",
- href: GENERAL_SETTINGS_HREF,
- order: 0,
- },
- {
- id: GENERAL_APPEARANCE_ID,
+ id: APPEARANCE_ID,
kind: "page",
labelKey: "settings:appearance",
- parentId: "general",
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
order: 10,
},
{
@@ -59,62 +54,72 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:colorTheme",
aliasesKey: "settings:discoveryAliasesColorTheme",
- parentId: GENERAL_APPEARANCE_ID,
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.colorTheme,
order: 11,
},
+ {
+ id: "appearance-settings-menu",
+ kind: "control",
+ labelKey: "settings:settingsMenu",
+ aliasesKey: "settings:discoveryAliasesSettingsMenu",
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.settingsMenuMode,
+ order: 12,
+ },
{
id: "appearance-startup-page",
kind: "control",
labelKey: "settings:startupPage",
aliasesKey: "settings:discoveryAliasesStartupPage",
- parentId: GENERAL_APPEARANCE_ID,
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.startupPage,
- order: 12,
+ order: 13,
},
{
id: "appearance-display-language",
kind: "control",
labelKey: "settings:displayLanguage",
aliasesKey: "settings:discoveryAliasesDisplayLanguage",
- parentId: GENERAL_APPEARANCE_ID,
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.displayLanguage,
- order: 13,
+ order: 14,
},
{
id: "appearance-changes-panel-layout",
kind: "control",
labelKey: "settings:changesPanelLayout",
- parentId: GENERAL_APPEARANCE_ID,
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.changesPanelLayout,
- order: 14,
+ order: 15,
},
{
id: "appearance-resource-metrics",
kind: "control",
labelKey: "settings:resourceMetrics",
aliasesKey: "settings:discoveryAliasesResourceMetrics",
- parentId: GENERAL_APPEARANCE_ID,
- groupId: "general",
- href: GENERAL_APPEARANCE_HREF,
+ parentId: APPEARANCE_ID,
+ groupId: "preferences",
+ href: APPEARANCE_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.resourceMetrics,
- order: 15,
+ order: 16,
},
{
- id: "general-layouts",
+ id: "preferences-layouts",
kind: "page",
labelKey: "settings:layouts",
- parentId: "general",
- groupId: "general",
- href: "/settings/general/layouts",
+ groupId: "preferences",
+ href: LAYOUTS_SETTINGS_HREF,
order: 20,
},
{
@@ -122,19 +127,18 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "section",
labelKey: "settings:layoutProfiles",
aliasesKey: "settings:discoveryAliasesLayoutProfiles",
- parentId: "general-layouts",
- groupId: "general",
- href: "/settings/general/layouts",
+ parentId: "preferences-layouts",
+ groupId: "preferences",
+ href: LAYOUTS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.layoutProfiles,
order: 21,
},
{
- id: GENERAL_TERMINAL_ID,
+ id: TERMINAL_EDITORS_ID,
kind: "page",
- labelKey: "settings:terminal",
- parentId: "general",
- groupId: "general",
- href: GENERAL_TERMINAL_HREF,
+ labelKey: "settings:terminalAndEditors",
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
order: 30,
},
{
@@ -142,9 +146,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:preferredShell",
aliasesKey: "settings:discoveryAliasesPreferredShell",
- parentId: GENERAL_TERMINAL_ID,
- groupId: "general",
- href: GENERAL_TERMINAL_HREF,
+ parentId: TERMINAL_EDITORS_ID,
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.preferredShell,
order: 31,
},
@@ -153,9 +157,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:terminalFontSize",
aliasesKey: "settings:discoveryAliasesTerminalFontSize",
- parentId: GENERAL_TERMINAL_ID,
- groupId: "general",
- href: GENERAL_TERMINAL_HREF,
+ parentId: TERMINAL_EDITORS_ID,
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
targetId: TERMINAL_FONT_SIZE_TARGET,
order: 32,
},
@@ -164,9 +168,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:terminalFont",
aliasesKey: "settings:discoveryAliasesTerminalFont",
- parentId: GENERAL_TERMINAL_ID,
- groupId: "general",
- href: GENERAL_TERMINAL_HREF,
+ parentId: TERMINAL_EDITORS_ID,
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
targetId: TERMINAL_FONT_FAMILY_TARGET,
order: 33,
},
@@ -175,19 +179,29 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:terminalLinks",
aliasesKey: "settings:discoveryAliasesTerminalLinks",
- parentId: GENERAL_TERMINAL_ID,
- groupId: "general",
- href: GENERAL_TERMINAL_HREF,
+ parentId: TERMINAL_EDITORS_ID,
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
targetId: TERMINAL_LINKS_TARGET,
order: 34,
},
{
- id: GENERAL_NOTIFICATIONS_ID,
+ id: "editors-file-editor",
+ kind: "section",
+ labelKey: "settings:fileEditor",
+ aliasesKey: "settings:discoveryAliasesFileEditor",
+ parentId: TERMINAL_EDITORS_ID,
+ groupId: "preferences",
+ href: TERMINAL_EDITORS_SETTINGS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.fileEditor,
+ order: 35,
+ },
+ {
+ id: NOTIFICATIONS_ID,
kind: "page",
labelKey: "settings:notifications",
- parentId: "general",
- groupId: "general",
- href: GENERAL_NOTIFICATIONS_HREF,
+ groupId: "preferences",
+ href: NOTIFICATIONS_SETTINGS_HREF,
order: 40,
},
{
@@ -195,9 +209,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:desktopNotifications",
aliasesKey: "settings:discoveryAliasesDesktopNotifications",
- parentId: GENERAL_NOTIFICATIONS_ID,
- groupId: "general",
- href: GENERAL_NOTIFICATIONS_HREF,
+ parentId: NOTIFICATIONS_ID,
+ groupId: "preferences",
+ href: NOTIFICATIONS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.desktopNotifications,
order: 41,
},
@@ -206,9 +220,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:notificationSound",
aliasesKey: "settings:discoveryAliasesNotificationSound",
- parentId: GENERAL_NOTIFICATIONS_ID,
- groupId: "general",
- href: GENERAL_NOTIFICATIONS_HREF,
+ parentId: NOTIFICATIONS_ID,
+ groupId: "preferences",
+ href: NOTIFICATIONS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.notificationSound,
order: 42,
},
@@ -216,9 +230,9 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "notifications-providers",
kind: "section",
labelKey: "settings:externalProviders",
- parentId: GENERAL_NOTIFICATIONS_ID,
- groupId: "general",
- href: GENERAL_NOTIFICATIONS_HREF,
+ parentId: NOTIFICATIONS_ID,
+ groupId: "preferences",
+ href: NOTIFICATIONS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.notificationProviders,
order: 43,
},
@@ -226,170 +240,111 @@ export const GENERAL_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "notifications-events",
kind: "section",
labelKey: "settings:notificationEvents",
- parentId: GENERAL_NOTIFICATIONS_ID,
- groupId: "general",
- href: GENERAL_NOTIFICATIONS_HREF,
+ parentId: NOTIFICATIONS_ID,
+ groupId: "preferences",
+ href: NOTIFICATIONS_SETTINGS_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",
+ id: "preferences-keyboard-shortcuts",
kind: "page",
labelKey: "settings:keyboardShortcuts",
- parentId: "general",
- groupId: "general",
- href: "/settings/general/keyboard-shortcuts",
- order: 60,
+ groupId: "preferences",
+ href: KEYBOARD_SHORTCUTS_SETTINGS_HREF,
+ order: 50,
},
{
id: "keyboard-submit-shortcut",
kind: "control",
labelKey: "settings:submitShortcut",
aliasesKey: "settings:discoveryAliasesSubmitShortcut",
- parentId: "general-keyboard-shortcuts",
- groupId: "general",
- href: "/settings/general/keyboard-shortcuts",
+ parentId: "preferences-keyboard-shortcuts",
+ groupId: "preferences",
+ href: KEYBOARD_SHORTCUTS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.submitShortcut,
- order: 61,
+ order: 51,
},
{
id: "keyboard-command-shortcuts",
kind: "control",
labelKey: "settings:keyboardShortcuts",
aliasesKey: "settings:discoveryAliasesKeyboardShortcuts",
- parentId: "general-keyboard-shortcuts",
- groupId: "general",
- href: "/settings/general/keyboard-shortcuts",
+ parentId: "preferences-keyboard-shortcuts",
+ groupId: "preferences",
+ href: KEYBOARD_SHORTCUTS_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.keyboardShortcuts,
- order: 62,
+ order: 52,
},
{
- id: GENERAL_TASK_ACTIONS_ID,
+ id: TASK_BEHAVIOR_ID,
kind: "page",
- labelKey: "settings:taskActions",
- parentId: "general",
- groupId: "general",
- href: GENERAL_TASK_ACTIONS_HREF,
- order: 70,
+ labelKey: "settings:taskBehavior",
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
+ order: 60,
},
{
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,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.agentTaskProfile,
- order: 71,
+ order: 61,
},
{
id: "task-actions-generated-titles",
kind: "control",
labelKey: "settings:agentGeneratedTaskTitles",
- parentId: GENERAL_TASK_ACTIONS_ID,
- groupId: "general",
- href: GENERAL_TASK_ACTIONS_HREF,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.agentGeneratedTitles,
- order: 72,
+ order: 62,
},
{
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,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.archiveConfirmation,
- order: 73,
+ order: 63,
},
{
id: "task-actions-unread-messages",
kind: "control",
labelKey: "settings:unreadMessages",
- parentId: GENERAL_TASK_ACTIONS_ID,
- groupId: "general",
- href: GENERAL_TASK_ACTIONS_HREF,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.unreadMessages,
- order: 74,
+ order: 64,
},
{
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,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.transcriptNavigation,
- order: 75,
+ order: 65,
},
{
- id: "general-message-queue",
- kind: "page",
+ id: "task-behavior-message-queue",
+ kind: "section",
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,
+ parentId: TASK_BEHAVIOR_ID,
+ groupId: "preferences",
+ href: TASK_BEHAVIOR_SETTINGS_HREF,
+ targetId: GENERAL_SETTINGS_TARGETS.messageQueue,
+ order: 66,
},
];
diff --git a/apps/web/lib/settings-discovery/catalog/standalone.ts b/apps/web/lib/settings-discovery/catalog/standalone.ts
index 2cf7ff61d0..147640721c 100644
--- a/apps/web/lib/settings-discovery/catalog/standalone.ts
+++ b/apps/web/lib/settings-discovery/catalog/standalone.ts
@@ -5,6 +5,7 @@ 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";
+export const SECRETS_SETTINGS_HREF = "/settings/secrets";
const VOICE_MODE_DISCOVERY_ID = "voice-mode";
const UTILITY_AGENTS_DISCOVERY_ID = "utility-agents";
export const STANDALONE_SETTINGS_TARGETS = {
@@ -26,7 +27,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "page",
labelKey: "common:prompts",
aliasesKey: "common:commandPromptsSettingsKeywords",
- groupId: "tools",
+ groupId: "agents",
href: PROMPTS_SETTINGS_HREF,
order: 500,
},
@@ -34,7 +35,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: VOICE_MODE_DISCOVERY_ID,
kind: "page",
labelKey: "settings:voiceMode",
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
order: 510,
},
@@ -44,7 +45,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:voiceEnableTitle",
aliasesKey: "settings:discoveryAliasesVoiceEnable",
parentId: VOICE_MODE_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceEnable,
order: 511,
@@ -55,7 +56,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:voiceEngineTitle",
aliasesKey: "settings:discoveryAliasesVoiceEngine",
parentId: VOICE_MODE_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceEngine,
order: 512,
@@ -66,7 +67,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:voiceBehavior",
aliasesKey: "settings:discoveryAliasesVoiceBehavior",
parentId: VOICE_MODE_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceBehavior,
order: 513,
@@ -76,7 +77,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "control",
labelKey: "settings:voiceWhisperModelTitle",
parentId: VOICE_MODE_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceModel,
order: 514,
@@ -87,7 +88,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:discoveryVoiceShortcut",
aliasesKey: "settings:discoveryAliasesVoiceShortcut",
parentId: VOICE_MODE_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceShortcut,
order: 515,
@@ -96,7 +97,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: UTILITY_AGENTS_DISCOVERY_ID,
kind: "page",
labelKey: "settings:utilityAgents",
- groupId: "tools",
+ groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
order: 520,
},
@@ -106,7 +107,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:utilityDefaultModelTitle",
aliasesKey: "settings:discoveryAliasesUtilityDefaultModel",
parentId: UTILITY_AGENTS_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityDefaultModel,
order: 521,
@@ -117,7 +118,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:utilityActionsTitle",
aliasesKey: "settings:discoveryAliasesUtilityActions",
parentId: UTILITY_AGENTS_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityActions,
order: 522,
@@ -127,16 +128,24 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "section",
labelKey: "settings:utilityCustomAgentsTitle",
parentId: UTILITY_AGENTS_DISCOVERY_ID,
- groupId: "tools",
+ groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityCustomAgents,
order: 523,
},
+ {
+ id: "secrets",
+ kind: "page",
+ labelKey: "settings:globalSecrets",
+ groupId: "workspaces",
+ href: SECRETS_SETTINGS_HREF,
+ order: 525,
+ },
{
id: "external-mcp",
kind: "page",
labelKey: "common:externalMcp",
- groupId: "tools",
+ groupId: "workspaces",
href: EXTERNAL_MCP_SETTINGS_HREF,
order: 530,
},
@@ -146,7 +155,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
labelKey: "settings:externalMcpEndpoints",
aliasesKey: "settings:discoveryAliasesExternalMcpEndpoints",
parentId: "external-mcp",
- groupId: "tools",
+ groupId: "workspaces",
href: EXTERNAL_MCP_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.externalMcpEndpoints,
order: 531,
@@ -156,7 +165,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
kind: "section",
labelKey: "settings:externalMcpSnippets",
parentId: "external-mcp",
- groupId: "tools",
+ groupId: "workspaces",
href: EXTERNAL_MCP_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.externalMcpSnippets,
order: 532,
@@ -165,7 +174,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "plugins",
kind: "page",
labelKey: "common:plugins",
- groupId: "tools",
+ groupId: "workspaces",
href: PLUGINS_SETTINGS_HREF,
order: 540,
},
diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts
index 816282899c..86db4016a0 100644
--- a/apps/web/lib/settings-discovery/catalog/system.ts
+++ b/apps/web/lib/settings-discovery/catalog/system.ts
@@ -2,9 +2,14 @@ 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`;
+const SYSTEM_DATA_STORAGE_DISCOVERY_ID = "system-data-storage";
+export const SYSTEM_DATA_STORAGE_SETTINGS_HREF = `${SYSTEM_SETTINGS_HREF}/data-storage`;
+export const SYSTEM_ABOUT_SETTINGS_HREF = `${SYSTEM_SETTINGS_HREF}/about`;
export const SYSTEM_SETTINGS_TARGETS = {
+ database: "setting-system-database",
+ backups: "setting-system-backups",
+ logs: "setting-system-logs",
+ licenses: "setting-system-licenses",
storageActions: "setting-system-storage-actions",
storageSchedule: "setting-system-storage-schedule",
storageWorkspaces: "setting-system-storage-workspaces",
@@ -26,163 +31,153 @@ export function storageSettingsTarget(sectionId: string): string | undefined {
}
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",
+ id: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
kind: "page",
- labelKey: "system:navFeatureToggles",
- parentId: "system",
+ labelKey: "system:navDataStorage",
groupId: "system",
- href: "/settings/system/feature-toggles",
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
order: 620,
},
{
id: "system-database",
- kind: "page",
+ kind: "section",
labelKey: "system:navDatabase",
- parentId: "system",
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: "/settings/system/database",
- order: 630,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.database,
+ order: 621,
},
{
id: "system-backups",
- kind: "page",
+ kind: "section",
labelKey: "system:navBackups",
- parentId: "system",
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
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,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.backups,
+ order: 622,
},
{
id: "system-storage-actions",
kind: "section",
labelKey: "system:storageActionsTitle",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageActions,
- order: 651,
+ order: 623,
},
{
id: "system-storage-schedule",
kind: "section",
labelKey: "system:storageScheduleTitle",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageSchedule,
- order: 652,
+ order: 624,
},
{
id: "system-storage-workspaces",
kind: "section",
labelKey: "system:storageWorkspacesTitle",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageWorkspaces,
- order: 653,
+ order: 625,
},
{
id: "system-storage-go-cache",
kind: "section",
labelKey: "system:storageGoBuildCache",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageGoCache,
- order: 654,
+ order: 626,
},
{
id: "system-storage-docker",
kind: "section",
labelKey: "system:storageDockerTitle",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageDocker,
- order: 655,
+ order: 627,
},
{
id: "system-storage-quarantine",
kind: "section",
labelKey: "system:storageQuarantineSafetyTitle",
- parentId: SYSTEM_STORAGE_DISCOVERY_ID,
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: SYSTEM_STORAGE_SETTINGS_HREF,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
targetId: SYSTEM_SETTINGS_TARGETS.storageQuarantine,
- order: 656,
+ order: 628,
},
{
id: "system-logs",
- kind: "page",
+ kind: "section",
labelKey: "system:navLogs",
- parentId: "system",
+ parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID,
groupId: "system",
- href: "/settings/system/logs",
- order: 660,
+ href: SYSTEM_DATA_STORAGE_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.logs,
+ order: 629,
+ },
+ {
+ id: "system-feature-toggles",
+ kind: "page",
+ labelKey: "system:navFeatureToggles",
+ groupId: "system",
+ href: "/settings/system/feature-toggles",
+ order: 630,
},
{
id: "system-updates",
kind: "page",
labelKey: "system:navUpdates",
- parentId: "system",
groupId: "system",
href: "/settings/system/updates",
- order: 670,
+ order: 640,
},
{
id: "system-about",
kind: "page",
labelKey: "system:navAbout",
- parentId: "system",
groupId: "system",
- href: "/settings/system/about",
- order: 680,
+ href: SYSTEM_ABOUT_SETTINGS_HREF,
+ order: 650,
},
{
id: "system-licenses",
- kind: "page",
+ kind: "section",
labelKey: "system:navLicenses",
- parentId: "system",
+ parentId: "system-about",
groupId: "system",
- href: "/settings/system/licenses",
- order: 690,
+ href: SYSTEM_ABOUT_SETTINGS_HREF,
+ targetId: SYSTEM_SETTINGS_TARGETS.licenses,
+ order: 651,
},
{
id: "system-users",
kind: "page",
labelKey: "system:navUsers",
- parentId: "system",
- groupId: "system",
+ groupId: "access",
href: "/settings/system/users",
- order: 700,
+ order: 660,
requires: "users",
},
];
diff --git a/apps/web/lib/settings-discovery/catalog/workspaces.ts b/apps/web/lib/settings-discovery/catalog/workspaces.ts
index 7a7f20f124..e83374676f 100644
--- a/apps/web/lib/settings-discovery/catalog/workspaces.ts
+++ b/apps/web/lib/settings-discovery/catalog/workspaces.ts
@@ -1,6 +1,6 @@
import type { SettingsDiscoveryDefinition } from "../types";
-export const WORKSPACES_SETTINGS_HREF = "/settings/workspace";
+export const WORKSPACES_SETTINGS_HREF = "/settings/workspaces";
export const WORKSPACE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
{
@@ -12,13 +12,4 @@ export const WORKSPACE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
href: WORKSPACES_SETTINGS_HREF,
order: 200,
},
- {
- id: "automations",
- kind: "page",
- labelKey: "common:automations",
- parentId: "workspaces",
- groupId: "workspaces",
- href: "/settings/automations",
- order: 210,
- },
];
diff --git a/apps/web/lib/settings-discovery/resolve.ts b/apps/web/lib/settings-discovery/resolve.ts
index 85fa1cf19b..173a29b18e 100644
--- a/apps/web/lib/settings-discovery/resolve.ts
+++ b/apps/web/lib/settings-discovery/resolve.ts
@@ -5,6 +5,7 @@ import type {
} from "./types";
import { SETTINGS_DISCOVERY_DEFINITIONS } from "./catalog";
import { SETTINGS_DISCOVERY_GROUPS } from "./catalog/groups";
+import { INTEGRATION_SETTINGS_TARGETS, WORKSPACE_INTEGRATIONS } from "./catalog/integrations";
import {
agentProfileDiscoveryTarget,
executorProfileDiscoveryTarget,
@@ -17,7 +18,7 @@ function workspaceDefinitions(
const entries: SettingsDiscoveryDefinition[] = [];
for (const [workspaceIndex, workspace] of workspaces.entries()) {
const workspaceId = `workspace:${workspace.id}`;
- const href = `/settings/workspace/${encodeURIComponent(workspace.id)}`;
+ const href = `/settings/workspaces/${encodeURIComponent(workspace.id)}`;
const order = 10_000 + workspaceIndex * 100;
entries.push({
id: workspaceId,
@@ -52,6 +53,7 @@ function workspaceDefinitions(
["workflows", "workflows:workflows"],
["automations", "common:automations"],
["integrations", "common:integrations"],
+ ["secrets", "settings:secrets"],
].entries()) {
entries.push({
id: `${workspaceId}:${suffix}`,
@@ -63,6 +65,30 @@ function workspaceDefinitions(
order: order + index + 10,
});
}
+ // One entry per integration service so search reads " › Integrations › Jira".
+ for (const [index, [slug, label]] of WORKSPACE_INTEGRATIONS.entries()) {
+ const integrationId = `${workspaceId}:integration-${slug}`;
+ const integrationHref = `${href}/integrations/${slug}`;
+ entries.push({
+ id: integrationId,
+ kind: "page",
+ label,
+ parentId: `${workspaceId}:integrations`,
+ groupId: "workspaces",
+ href: integrationHref,
+ order: order + index + 20,
+ });
+ entries.push({
+ id: `${integrationId}-connection`,
+ kind: "section",
+ labelKey: "settings:connection",
+ parentId: integrationId,
+ groupId: "workspaces",
+ href: integrationHref,
+ targetId: INTEGRATION_SETTINGS_TARGETS[slug],
+ order: order + index + 21,
+ });
+ }
}
return entries;
}
@@ -128,7 +154,7 @@ function executorProfileDefinitions(
kind: "page",
label: profile.name,
parentId: "executors",
- groupId: "executors",
+ groupId: "agents",
href,
order,
});
@@ -143,7 +169,7 @@ function executorProfileDefinitions(
kind: "section",
labelKey,
parentId: profileId,
- groupId: "executors",
+ groupId: "agents",
href,
targetId: executorProfileDiscoveryTarget(
profile.id,
diff --git a/apps/web/lib/settings-discovery/target.test.ts b/apps/web/lib/settings-discovery/target.test.ts
index 6d3ea8bc21..683c21d2c3 100644
--- a/apps/web/lib/settings-discovery/target.test.ts
+++ b/apps/web/lib/settings-discovery/target.test.ts
@@ -12,6 +12,7 @@ afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
describe("settings target identity", () => {
@@ -101,4 +102,82 @@ describe("revealSettingsTarget", () => {
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" });
expect(document.activeElement).toBe(marked);
});
+
+ it("re-centers the target when surrounding content grows while settling", () => {
+ vi.useFakeTimers();
+ let callback: ResizeObserverCallback | undefined;
+ const observed: Element[] = [];
+ const disconnect = vi.fn(() => {
+ callback = undefined;
+ });
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(cb: ResizeObserverCallback) {
+ callback = cb;
+ }
+ observe = (el: Element) => void observed.push(el);
+ unobserve = vi.fn();
+ disconnect = disconnect;
+ },
+ );
+
+ const parent = document.createElement("div");
+ const target = document.createElement("div");
+ parent.appendChild(target);
+ document.body.appendChild(parent);
+ target.scrollIntoView = vi.fn();
+
+ revealSettingsTarget(target, { reducedMotion: true });
+ expect(observed).toContain(parent);
+
+ const resize = (el: Element, height: number) =>
+ callback?.([{ target: el, contentRect: { width: 100, height } }] as never, {} as never);
+
+ resize(parent, 100); // baseline notification — no shift yet
+ expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+ resize(parent, 400); // async content grew above the target
+ expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+ expect(target.scrollIntoView).toHaveBeenLastCalledWith({ behavior: "auto", block: "center" });
+
+ vi.advanceTimersByTime(3000); // settle window elapsed
+ resize(parent, 800);
+ expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+ expect(disconnect).toHaveBeenCalled();
+ });
+
+ it("stops re-centering as soon as the user interacts", () => {
+ vi.useFakeTimers();
+ let callback: ResizeObserverCallback | undefined;
+ const disconnect = vi.fn(() => {
+ callback = undefined;
+ });
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(cb: ResizeObserverCallback) {
+ callback = cb;
+ }
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = disconnect;
+ },
+ );
+
+ const target = document.createElement("div");
+ document.body.appendChild(target);
+ target.scrollIntoView = vi.fn();
+
+ revealSettingsTarget(target, { reducedMotion: true });
+ const resize = (height: number) =>
+ callback?.([{ target, contentRect: { width: 100, height } }] as never, {} as never);
+ resize(100);
+
+ window.dispatchEvent(new Event("wheel"));
+ expect(disconnect).toHaveBeenCalled();
+
+ resize(400);
+ expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/apps/web/lib/settings-discovery/target.ts b/apps/web/lib/settings-discovery/target.ts
index efb300aa40..7b186a1f43 100644
--- a/apps/web/lib/settings-discovery/target.ts
+++ b/apps/web/lib/settings-discovery/target.ts
@@ -13,9 +13,11 @@ export type SettingsTargetRegistry = {
type RevealOptions = {
highlightDurationMs?: number;
reducedMotion?: boolean;
+ settleDurationMs?: number;
};
const DEFAULT_HIGHLIGHT_DURATION_MS = 1400;
+const DEFAULT_SETTLE_DURATION_MS = 2500;
const highlightTimers = new WeakMap();
export function settingsTargetFromHash(hash: string): string | null {
@@ -83,6 +85,51 @@ export function revealSettingsTarget(element: HTMLElement, options: RevealOption
focusTargetWithin(element);
restartTargetHighlight(element, options.highlightDurationMs ?? DEFAULT_HIGHLIGHT_DURATION_MS);
+ keepTargetCentered(element, options.settleDurationMs ?? DEFAULT_SETTLE_DURATION_MS);
+}
+
+let cancelActiveSettle: (() => void) | null = null;
+
+/**
+ * Content around the target often loads asynchronously after the initial
+ * scroll (e.g. sections above it fetch data and grow), pushing the target out
+ * of view. Re-center it whenever an ancestor resizes during a short settle
+ * window, backing off as soon as the user scrolls or interacts.
+ */
+function keepTargetCentered(element: HTMLElement, durationMs: number): void {
+ cancelActiveSettle?.();
+ if (typeof ResizeObserver === "undefined" || durationMs <= 0) return;
+
+ const baselines = new Map();
+ const observer = new ResizeObserver((entries) => {
+ let shifted = false;
+ for (const entry of entries) {
+ const size = `${entry.contentRect.width}x${entry.contentRect.height}`;
+ if (baselines.has(entry.target) && baselines.get(entry.target) !== size) shifted = true;
+ baselines.set(entry.target, size);
+ }
+ if (shifted && element.isConnected) {
+ element.scrollIntoView?.({ behavior: "auto", block: "center" });
+ }
+ });
+ for (let node: HTMLElement | null = element; node; node = node.parentElement) {
+ observer.observe(node);
+ }
+
+ const interactionEvents = ["wheel", "touchstart", "pointerdown", "keydown"] as const;
+ const cancel = () => {
+ observer.disconnect();
+ window.clearTimeout(timer);
+ for (const type of interactionEvents) {
+ window.removeEventListener(type, cancel, { capture: true });
+ }
+ if (cancelActiveSettle === cancel) cancelActiveSettle = null;
+ };
+ for (const type of interactionEvents) {
+ window.addEventListener(type, cancel, { capture: true, passive: true });
+ }
+ const timer = window.setTimeout(cancel, durationMs);
+ cancelActiveSettle = cancel;
}
function focusTargetWithin(element: HTMLElement): void {
diff --git a/apps/web/lib/settings/agent-display-order.test.ts b/apps/web/lib/settings/agent-display-order.test.ts
new file mode 100644
index 0000000000..80c872065a
--- /dev/null
+++ b/apps/web/lib/settings/agent-display-order.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+
+import { detectedAgents, orderAgentsForDisplay, orphanedAgents } from "./agent-display-order";
+
+const CLAUDE = "claude-acp";
+const CODEX = "codex-acp";
+const MOCK = "mock-agent";
+
+const DISCOVERY = [
+ { name: CLAUDE, available: true },
+ { name: CODEX, available: false },
+ { name: MOCK, available: true },
+ { name: "gemini", available: false },
+];
+
+const SAVED = [
+ { name: CODEX, profiles: [{ id: "p1" }] },
+ { name: CLAUDE, profiles: [{ id: "p2" }] },
+ { name: MOCK, profiles: [{ id: "p3" }] },
+];
+
+describe("detectedAgents", () => {
+ it("keeps only what the scan found, in scan order", () => {
+ expect(detectedAgents(DISCOVERY).map((agent) => agent.name)).toEqual([CLAUDE, MOCK]);
+ });
+});
+
+describe("orphanedAgents", () => {
+ it("returns configured agents the scan no longer finds", () => {
+ const orphans = orphanedAgents(detectedAgents(DISCOVERY), SAVED);
+
+ expect(orphans.map((agent) => agent.name)).toEqual([CODEX]);
+ });
+
+ it("ignores an undetected agent that has nothing configured under it", () => {
+ // Nothing to preserve, so it earns no group of its own.
+ const saved = [{ name: "gemini", profiles: [] }];
+
+ expect(orphanedAgents(detectedAgents(DISCOVERY), saved)).toEqual([]);
+ });
+});
+
+describe("orderAgentsForDisplay", () => {
+ it("ranks by the backend's order, detected or not", () => {
+ // DISCOVERY is the backend's ranking: claude, codex, mock. Saved order is
+ // codex, claude, mock — what the menu used to render.
+ expect(orderAgentsForDisplay(DISCOVERY, SAVED).map((agent) => agent.name)).toEqual([
+ CLAUDE,
+ CODEX,
+ MOCK,
+ ]);
+ });
+
+ it("keeps an undetected agent in rank rather than flinging it to the end", () => {
+ // `codex` is ranked above `mock` and stays there even though the scan does
+ // not find it — otherwise the dev-only mock agent outranks a real one.
+ const ordered = orderAgentsForDisplay(DISCOVERY, SAVED).map((agent) => agent.name);
+
+ expect(ordered.indexOf(CODEX)).toBeLessThan(ordered.indexOf(MOCK));
+ });
+
+ it("keeps the saved order when the scan has not hydrated yet", () => {
+ expect(orderAgentsForDisplay([], SAVED).map((agent) => agent.name)).toEqual([
+ CODEX,
+ CLAUDE,
+ MOCK,
+ ]);
+ });
+
+ it("holds the saved order among agents the scan does not mention", () => {
+ const saved = [
+ { name: "zeta", profiles: [{ id: "p" }] },
+ { name: CLAUDE, profiles: [{ id: "p" }] },
+ { name: "alpha", profiles: [{ id: "p" }] },
+ ];
+
+ // `zeta` and `alpha` are unknown to the scan: detected first, then those two
+ // in the order they were saved — not alphabetised behind the app's back.
+ expect(orderAgentsForDisplay(DISCOVERY, saved).map((agent) => agent.name)).toEqual([
+ CLAUDE,
+ "zeta",
+ "alpha",
+ ]);
+ });
+
+ it("drops nothing and duplicates nothing", () => {
+ const ordered = orderAgentsForDisplay(DISCOVERY, SAVED);
+
+ expect(ordered).toHaveLength(SAVED.length);
+ expect(new Set(ordered.map((agent) => agent.name)).size).toBe(SAVED.length);
+ });
+});
diff --git a/apps/web/lib/settings/agent-display-order.ts b/apps/web/lib/settings/agent-display-order.ts
new file mode 100644
index 0000000000..2286fdf8fc
--- /dev/null
+++ b/apps/web/lib/settings/agent-display-order.ts
@@ -0,0 +1,62 @@
+/**
+ * The order agents are listed in, wherever they are listed.
+ *
+ * The backend already ranks every agent it knows about: each implementation
+ * declares a `DisplayOrder`, and `GET /agents/discovery` returns them sorted by
+ * it — Claude first, then Codex, with the dev-only mock agent last at 99. That
+ * ranking covers agents the scan did *not* find as well as the ones it did, so
+ * it is a complete order and not just an order over what happens to be
+ * installed on this machine.
+ *
+ * So neither surface invents an order: both rank saved agents by where the
+ * backend put them. The menu and the Agents page listing the same agents in
+ * different orders is what this module exists to prevent.
+ */
+
+export type DiscoveredAgent = { name: string; available: boolean };
+export type SavedAgent = { name: string; profiles: ReadonlyArray };
+
+/** Agents the scan currently detects, in rank order. */
+export function detectedAgents(discovery: ReadonlyArray): D[] {
+ return discovery.filter((agent) => agent.available);
+}
+
+/**
+ * Configured agents the scan no longer detects, in rank order. Only ones with
+ * profiles: an agent with nothing configured under it has nothing to preserve.
+ *
+ * The Agents page still renders these — via a synthetic discovery record — so a
+ * CLI going missing never hides the profiles configured against it.
+ */
+export function orphanedAgents(
+ detected: ReadonlyArray,
+ saved: ReadonlyArray,
+): S[] {
+ const detectedNames = new Set(detected.map((agent) => agent.name));
+ return saved.filter((agent) => agent.profiles.length > 0 && !detectedNames.has(agent.name));
+}
+
+/**
+ * `saved`, reordered by the backend's ranking.
+ *
+ * Ranks against the *whole* discovery list rather than the detected subset, so
+ * an agent whose CLI is missing keeps its place among the rest instead of being
+ * flung to the end — and so the dev-only mock agent, ranked last by the backend,
+ * lands last rather than in the middle of the agents you actually use.
+ *
+ * Agents the backend does not rank at all keep their saved order, after the
+ * ranked ones. A reorder never drops or duplicates an entry.
+ */
+export function orderAgentsForDisplay(
+ discovery: ReadonlyArray,
+ saved: ReadonlyArray,
+): S[] {
+ const rank = new Map();
+ discovery.forEach((agent, index) => rank.set(agent.name, index));
+ const unranked = rank.size;
+ return [...saved].sort(
+ (a, b) =>
+ (rank.get(a.name) ?? unranked + saved.indexOf(a)) -
+ (rank.get(b.name) ?? unranked + saved.indexOf(b)),
+ );
+}
diff --git a/apps/web/lib/settings/constants.ts b/apps/web/lib/settings/constants.ts
index d7f631e7e8..35ee322462 100644
--- a/apps/web/lib/settings/constants.ts
+++ b/apps/web/lib/settings/constants.ts
@@ -1,6 +1,12 @@
export const STORAGE_KEYS = {
BACKEND_URL: "kandev.settings.backendUrl",
ONBOARDING_COMPLETED: "kandev.onboarding.completed",
+ /** Last settings page opened on this device; where bare `/settings` resolves. */
+ LAST_SETTINGS_PATH: "kandev.settings.lastPath",
+ /** How the settings menu renders on this device: flat, accordion, persistent. */
+ SETTINGS_MENU_MODE: "kandev.settings.menuMode",
+ /** Branch keys left open in the persistent tree on this device. */
+ SETTINGS_MENU_EXPANDED: "kandev.settings.menuExpanded",
} as const;
export const DEFAULT_BACKEND_URL = "http://localhost:38429";
diff --git a/apps/web/lib/settings/integration-icons.ts b/apps/web/lib/settings/integration-icons.ts
new file mode 100644
index 0000000000..2482f46fe6
--- /dev/null
+++ b/apps/web/lib/settings/integration-icons.ts
@@ -0,0 +1,30 @@
+import type { ComponentType } from "react";
+import {
+ IconBrandGithub,
+ IconBrandGitlab,
+ IconBrandSentry,
+ IconHexagon,
+ IconTicket,
+} from "@tabler/icons-react";
+
+import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon";
+import { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations";
+
+type IntegrationSlug = (typeof WORKSPACE_INTEGRATIONS)[number][0];
+
+/**
+ * The product mark per integration slug.
+ *
+ * Typed as a `Record` over the catalog's own slug union rather than a loose
+ * lookup with a fallback: adding an integration to `WORKSPACE_INTEGRATIONS`
+ * then fails to compile until it has a mark, instead of silently rendering a
+ * generic one. The catalog owns slug → brand name; this owns slug → icon.
+ */
+export const INTEGRATION_ICONS: Record> = {
+ "azure-devops": AzureDevOpsIcon,
+ github: IconBrandGithub,
+ gitlab: IconBrandGitlab,
+ jira: IconTicket,
+ linear: IconHexagon,
+ sentry: IconBrandSentry,
+};
diff --git a/apps/web/lib/settings/last-settings-page.test.ts b/apps/web/lib/settings/last-settings-page.test.ts
new file mode 100644
index 0000000000..edbb61a952
--- /dev/null
+++ b/apps/web/lib/settings/last-settings-page.test.ts
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { STORAGE_KEYS } from "./constants";
+import {
+ DEFAULT_SETTINGS_PATH,
+ isRestorableSettingsPath,
+ readLastSettingsPath,
+ rememberSettingsPath,
+} from "./last-settings-page";
+
+const APPEARANCE_PATH = "/settings/general/appearance";
+const PROMPTS_PATH = "/settings/prompts";
+const WORKSPACE_ID = "9694f4e6-c4eb-4800-aa93-1d947907703e";
+const PLUGIN_PATH = "/settings/plugins/kandev-plugin-e2e";
+
+/** Stand-in for `SETTINGS_ROUTE_PATHS`, which the route table owns. */
+const KNOWN = new Set([
+ "/settings",
+ APPEARANCE_PATH,
+ "/settings/general/terminal",
+ // A redirect stub: static, so restorable — it replaces the URL on mount and
+ // the page it lands on is what gets recorded next.
+ "/settings/general/shell",
+ PROMPTS_PATH,
+ "/settings/plugins",
+ "/settings/system/status",
+]);
+
+describe("isRestorableSettingsPath", () => {
+ it("accepts static settings pages", () => {
+ expect(isRestorableSettingsPath(APPEARANCE_PATH, KNOWN)).toBe(true);
+ expect(isRestorableSettingsPath(PROMPTS_PATH, KNOWN)).toBe(true);
+ expect(isRestorableSettingsPath("/settings/system/status", KNOWN)).toBe(true);
+ });
+
+ it("accepts a redirect stub, which self-corrects on arrival", () => {
+ expect(isRestorableSettingsPath("/settings/general/shell", KNOWN)).toBe(true);
+ });
+
+ it("rejects bare /settings, which is the route doing the restoring", () => {
+ expect(isRestorableSettingsPath("/settings", KNOWN)).toBe(false);
+ expect(isRestorableSettingsPath("/settings/", KNOWN)).toBe(false);
+ });
+
+ it("rejects a path that is not a settings route at all", () => {
+ // The settings shell renders — and would therefore record — anything under
+ // /settings/, including paths that fall through to the not-ported fallback.
+ expect(isRestorableSettingsPath("/settings/does-not-exist", KNOWN)).toBe(false);
+ expect(isRestorableSettingsPath("/stats", KNOWN)).toBe(false);
+ expect(isRestorableSettingsPath("", KNOWN)).toBe(false);
+ });
+
+ it("rejects record-scoped and slug-scoped routes", () => {
+ // These resolve against workspaces, agents and plugins that can be deleted.
+ expect(
+ isRestorableSettingsPath(`/settings/workspace/${WORKSPACE_ID}/repositories`, KNOWN),
+ ).toBe(false);
+ expect(isRestorableSettingsPath("/settings/agents/claude-code", KNOWN)).toBe(false);
+ expect(isRestorableSettingsPath(PLUGIN_PATH, KNOWN)).toBe(false);
+ });
+});
+
+describe("remember/read round trip", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("returns the default before anything is recorded", () => {
+ expect(readLastSettingsPath(KNOWN)).toBe(DEFAULT_SETTINGS_PATH);
+ });
+
+ it("returns the last recorded page", () => {
+ rememberSettingsPath("/settings/general/terminal", KNOWN);
+ expect(readLastSettingsPath(KNOWN)).toBe("/settings/general/terminal");
+ });
+
+ it("keeps the previous page when an unrestorable one is visited", () => {
+ rememberSettingsPath(PROMPTS_PATH, KNOWN);
+ rememberSettingsPath(`/settings/workspace/${WORKSPACE_ID}/integrations`, KNOWN);
+ rememberSettingsPath(PLUGIN_PATH, KNOWN);
+ rememberSettingsPath("/settings/does-not-exist", KNOWN);
+
+ expect(readLastSettingsPath(KNOWN)).toBe(PROMPTS_PATH);
+ });
+
+ it("normalizes a trailing slash", () => {
+ rememberSettingsPath("/settings/plugins/", KNOWN);
+ expect(readLastSettingsPath(KNOWN)).toBe("/settings/plugins");
+ });
+
+ it("falls back when a stored static route has since been removed", () => {
+ rememberSettingsPath(PROMPTS_PATH, KNOWN);
+
+ // The release that drops a route drops it from the table too, so the stored
+ // value stops being restorable instead of resolving to the not-ported
+ // fallback on every visit to /settings.
+ const afterRemoval = new Set([...KNOWN].filter((path) => path !== PROMPTS_PATH));
+
+ expect(readLastSettingsPath(afterRemoval)).toBe(DEFAULT_SETTINGS_PATH);
+ });
+
+ it("falls back when a stored plugin page's plugin is gone", () => {
+ // Written by hand: `rememberSettingsPath` refuses to store this, but a value
+ // left by an older build that did store slugs must not survive either.
+ window.localStorage.setItem(STORAGE_KEYS.LAST_SETTINGS_PATH, JSON.stringify(PLUGIN_PATH));
+
+ expect(readLastSettingsPath(KNOWN)).toBe(DEFAULT_SETTINGS_PATH);
+ });
+
+ it("falls back when the stored value is bare /settings", () => {
+ window.localStorage.setItem(STORAGE_KEYS.LAST_SETTINGS_PATH, JSON.stringify("/settings"));
+ expect(readLastSettingsPath(KNOWN)).toBe(DEFAULT_SETTINGS_PATH);
+ });
+
+ it("falls back when the stored value is not JSON", () => {
+ window.localStorage.setItem(STORAGE_KEYS.LAST_SETTINGS_PATH, PROMPTS_PATH);
+ expect(readLastSettingsPath(KNOWN)).toBe(DEFAULT_SETTINGS_PATH);
+ });
+});
diff --git a/apps/web/lib/settings/last-settings-page.ts b/apps/web/lib/settings/last-settings-page.ts
new file mode 100644
index 0000000000..5e3d183b12
--- /dev/null
+++ b/apps/web/lib/settings/last-settings-page.ts
@@ -0,0 +1,65 @@
+import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
+import { STORAGE_KEYS } from "@/lib/settings/constants";
+
+/**
+ * Where `/settings` lands when this device has never opened a settings page.
+ * The first row of the Preferences section in the settings menu.
+ */
+export const DEFAULT_SETTINGS_PATH = "/settings/preferences/appearance";
+
+const SETTINGS_INDEX_PATH = "/settings";
+
+function normalize(pathname: string): string {
+ return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+}
+
+/**
+ * True when `pathname` is worth restoring the next time someone opens bare
+ * `/settings`.
+ *
+ * `knownPaths` is the static settings route table (`SETTINGS_ROUTE_PATHS`), and
+ * membership in it is the whole test. Restoring is a promise that the page will
+ * still be there, and only a static first-party route can keep it:
+ *
+ * - **Unknown paths** (`/settings/does-not-exist`, a route dropped in an
+ * upgrade) would otherwise stick, because the settings shell renders — and so
+ * records — even when the route falls through to `SettingsRouteFallback`.
+ * - **Record and slug routes** (`/settings/workspace//repositories`,
+ * `/settings/agents/`, a plugin's `/settings/plugins/`) resolve
+ * against data that can disappear. Deciding whether one still resolves needs
+ * the workspace/agent/plugin list, and landing on a page for something that
+ * was deleted is worse than landing on the default.
+ * - **`/settings` itself** is a route, so it needs excluding by hand: it is the
+ * route doing the restoring.
+ *
+ * Redirect stubs (`/settings/general/shell`, `/settings/system`, …) are
+ * deliberately kept: they are static routes that replace the URL on mount, so
+ * the next thing recorded is the page they land on.
+ */
+export function isRestorableSettingsPath(
+ pathname: string,
+ knownPaths: ReadonlySet,
+): boolean {
+ const path = normalize(pathname);
+ if (path === SETTINGS_INDEX_PATH) return false;
+ return knownPaths.has(path);
+}
+
+/** Record the settings page to return to. No-ops for paths we won't restore. */
+export function rememberSettingsPath(pathname: string, knownPaths: ReadonlySet): void {
+ const path = normalize(pathname);
+ if (!isRestorableSettingsPath(path, knownPaths)) return;
+ setLocalStorage(STORAGE_KEYS.LAST_SETTINGS_PATH, path);
+}
+
+/**
+ * The settings page bare `/settings` should resolve to on this device.
+ *
+ * Re-validated against `knownPaths` rather than trusted, so a page that existed
+ * when the value was written but has since moved or been removed falls back
+ * instead of resolving to a not-found surface forever.
+ */
+export function readLastSettingsPath(knownPaths: ReadonlySet): string {
+ const stored = getLocalStorage(STORAGE_KEYS.LAST_SETTINGS_PATH, "");
+ return isRestorableSettingsPath(stored, knownPaths) ? normalize(stored) : DEFAULT_SETTINGS_PATH;
+}
diff --git a/apps/web/lib/settings/settings-menu-mode.test.ts b/apps/web/lib/settings/settings-menu-mode.test.ts
new file mode 100644
index 0000000000..ef821475b3
--- /dev/null
+++ b/apps/web/lib/settings/settings-menu-mode.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { STORAGE_KEYS } from "@/lib/settings/constants";
+import {
+ DEFAULT_SETTINGS_MENU_MODE,
+ isTreeSettingsMenuMode,
+ readSettingsMenuExpandedKeys,
+ readSettingsMenuMode,
+ writeSettingsMenuExpandedKeys,
+ writeSettingsMenuMode,
+} from "./settings-menu-mode";
+
+const FLAT = "flat";
+const AGENTS_ROW_KEY = "row:/settings/agents";
+const AGENT_KEY = "agent:claude-code";
+
+describe("settings menu mode storage", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("defaults to the flat menu when this device has never chosen", () => {
+ expect(readSettingsMenuMode()).toBe(FLAT);
+ expect(DEFAULT_SETTINGS_MENU_MODE).toBe(FLAT);
+ });
+
+ it("round-trips a chosen mode", () => {
+ writeSettingsMenuMode("persistent");
+
+ expect(readSettingsMenuMode()).toBe("persistent");
+ });
+
+ it("falls back rather than trusting a mode that no longer exists", () => {
+ window.localStorage.setItem(STORAGE_KEYS.SETTINGS_MENU_MODE, JSON.stringify("outline"));
+
+ expect(readSettingsMenuMode()).toBe(FLAT);
+ });
+
+ it("survives an unparseable stored value", () => {
+ window.localStorage.setItem(STORAGE_KEYS.SETTINGS_MENU_MODE, "{not json");
+
+ expect(readSettingsMenuMode()).toBe(FLAT);
+ });
+
+ it("round-trips expanded branch keys", () => {
+ writeSettingsMenuExpandedKeys([AGENTS_ROW_KEY, AGENT_KEY]);
+
+ expect(readSettingsMenuExpandedKeys()).toEqual([AGENTS_ROW_KEY, AGENT_KEY]);
+ });
+
+ it("drops non-string entries instead of feeding them to the menu", () => {
+ window.localStorage.setItem(
+ STORAGE_KEYS.SETTINGS_MENU_EXPANDED,
+ JSON.stringify([AGENT_KEY, 7, null]),
+ );
+
+ expect(readSettingsMenuExpandedKeys()).toEqual([AGENT_KEY]);
+ });
+
+ it("treats a stored non-array as no open branches", () => {
+ window.localStorage.setItem(STORAGE_KEYS.SETTINGS_MENU_EXPANDED, JSON.stringify({}));
+
+ expect(readSettingsMenuExpandedKeys()).toEqual([]);
+ });
+
+ it("knows which modes render branches", () => {
+ expect(isTreeSettingsMenuMode(FLAT)).toBe(false);
+ expect(isTreeSettingsMenuMode("accordion")).toBe(true);
+ expect(isTreeSettingsMenuMode("persistent")).toBe(true);
+ });
+});
diff --git a/apps/web/lib/settings/settings-menu-mode.ts b/apps/web/lib/settings/settings-menu-mode.ts
new file mode 100644
index 0000000000..ab6d3c8ba1
--- /dev/null
+++ b/apps/web/lib/settings/settings-menu-mode.ts
@@ -0,0 +1,66 @@
+import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
+import { STORAGE_KEYS } from "@/lib/settings/constants";
+
+/**
+ * How the settings menu renders — a per-device preference.
+ *
+ * Deliberately *not* part of user settings: the menu belongs to the surface
+ * you are looking at, not to the account. A phone reaching the same install
+ * wants the flat list even when the desktop beside it wants the tree, and the
+ * value has to be readable before the store hydrates (the sidebar paints the
+ * menu on the first frame — a mode arriving one render later is a visible
+ * flip). Both rule out `updateUserSettings`, which is async and account-wide.
+ *
+ * `flat` is the default and is exactly the two-level menu
+ * (`settings-menu-sections.ts`) with no branches rendered at all.
+ */
+export const SETTINGS_MENU_MODES = ["flat", "accordion", "persistent"] as const;
+
+export type SettingsMenuMode = (typeof SETTINGS_MENU_MODES)[number];
+
+export const DEFAULT_SETTINGS_MENU_MODE: SettingsMenuMode = "flat";
+
+/** True when `mode` renders branches at all — i.e. anything but `flat`. */
+export function isTreeSettingsMenuMode(mode: SettingsMenuMode): boolean {
+ return mode !== "flat";
+}
+
+function isSettingsMenuMode(value: unknown): value is SettingsMenuMode {
+ return SETTINGS_MENU_MODES.includes(value as SettingsMenuMode);
+}
+
+/**
+ * The stored value, or the default. Re-validated rather than trusted: a mode
+ * removed in an upgrade would otherwise leave the menu rendering nothing.
+ */
+export function readSettingsMenuMode(): SettingsMenuMode {
+ const stored = getLocalStorage(
+ STORAGE_KEYS.SETTINGS_MENU_MODE,
+ DEFAULT_SETTINGS_MENU_MODE,
+ );
+ return isSettingsMenuMode(stored) ? stored : DEFAULT_SETTINGS_MENU_MODE;
+}
+
+export function writeSettingsMenuMode(mode: SettingsMenuMode): void {
+ setLocalStorage(STORAGE_KEYS.SETTINGS_MENU_MODE, mode);
+}
+
+/**
+ * Branch keys the persistent tree should reopen on this device.
+ *
+ * Keys are node identities, not routes, so a key naming a workspace, agent or
+ * executor that has since been deleted simply never matches a rendered branch:
+ * stale entries are inert rather than wrong. They are also never pruned — the
+ * expansion set is only ever added to or narrowed by an explicit collapse, and
+ * nothing here knows which records still exist — so the stored list grows by
+ * one string per deleted record and is cleared only by "Collapse all".
+ */
+export function readSettingsMenuExpandedKeys(): string[] {
+ const stored = getLocalStorage(STORAGE_KEYS.SETTINGS_MENU_EXPANDED, []) as unknown;
+ if (!Array.isArray(stored)) return [];
+ return stored.filter((key): key is string => typeof key === "string");
+}
+
+export function writeSettingsMenuExpandedKeys(keys: readonly string[]): void {
+ setLocalStorage(STORAGE_KEYS.SETTINGS_MENU_EXPANDED, [...keys]);
+}
diff --git a/apps/web/lib/settings/workspace-settings-tabs.ts b/apps/web/lib/settings/workspace-settings-tabs.ts
new file mode 100644
index 0000000000..2be9b8e823
--- /dev/null
+++ b/apps/web/lib/settings/workspace-settings-tabs.ts
@@ -0,0 +1,62 @@
+import type { ComponentType } from "react";
+import {
+ IconArrowsShuffle,
+ IconBolt,
+ IconGitBranch,
+ IconKey,
+ IconLayoutGrid,
+ IconPlugConnected,
+} from "@tabler/icons-react";
+
+import { WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/workspaces";
+
+/**
+ * The sections of a workspace's settings, as data.
+ *
+ * Extracted from `WorkspaceSettingsShell` so a second surface can read it
+ * without pulling in the shell's hooks: the settings menu's Workspaces branch
+ * lists exactly these tabs when the tree modes are on. Two surfaces naming the
+ * same sections is precisely how the menu and the page drift apart, so there is
+ * one table — the same reason `settings-menu-sections.ts` is data-only.
+ */
+
+export type WorkspaceSettingsTab =
+ | "overview"
+ | "repositories"
+ | "workflows"
+ | "integrations"
+ | "automations"
+ | "secrets";
+
+export function workspaceSettingsHref(workspaceId: string, tab: WorkspaceSettingsTab): string {
+ const base = `${WORKSPACES_SETTINGS_HREF}/${encodeURIComponent(workspaceId)}`;
+ return tab === "overview" ? base : `${base}/${tab}`;
+}
+
+export type WorkspaceTabSpec = {
+ tab: WorkspaceSettingsTab;
+ labelKey: string;
+ icon: ComponentType<{ className?: string }>;
+};
+
+/**
+ * `icon` is carried here rather than at the call sites that draw it, so no two
+ * surfaces can pick different marks for the same section. Four read this table:
+ * the tab strip, each tab's own page heading, the workspace list's section
+ * links, and the settings menu's Workspaces branch.
+ */
+export const WORKSPACE_SETTINGS_TABS: ReadonlyArray = [
+ { tab: "overview", labelKey: "workspaces:overview", icon: IconLayoutGrid },
+ { tab: "repositories", labelKey: "sidebar:repositories", icon: IconGitBranch },
+ { tab: "workflows", labelKey: "workflows:workflows", icon: IconArrowsShuffle },
+ { tab: "integrations", labelKey: "common:integrations", icon: IconPlugConnected },
+ { tab: "automations", labelKey: "common:automations", icon: IconBolt },
+ { tab: "secrets", labelKey: "settings:secrets", icon: IconKey },
+];
+
+/** The name and mark for a tab, for the surfaces that render one tab at a time. */
+export function workspaceSettingsTabSpec(tab: WorkspaceSettingsTab): WorkspaceTabSpec {
+ // Every member of the union has a row, so the fallback is unreachable; it
+ // exists so a future tab cannot crash a page before its row is added.
+ return WORKSPACE_SETTINGS_TABS.find((entry) => entry.tab === tab) ?? WORKSPACE_SETTINGS_TABS[0];
+}
diff --git a/apps/web/lib/state/slices/ui/settings-menu-actions.ts b/apps/web/lib/state/slices/ui/settings-menu-actions.ts
new file mode 100644
index 0000000000..e1fa7d9111
--- /dev/null
+++ b/apps/web/lib/state/slices/ui/settings-menu-actions.ts
@@ -0,0 +1,51 @@
+import type { StateCreator } from "zustand";
+import {
+ readSettingsMenuExpandedKeys,
+ readSettingsMenuMode,
+ writeSettingsMenuExpandedKeys,
+ writeSettingsMenuMode,
+} from "@/lib/settings/settings-menu-mode";
+import type { SettingsMenuState, UISlice } from "./types";
+
+export function loadSettingsMenuState(): SettingsMenuState {
+ const mode = readSettingsMenuMode();
+ return {
+ mode,
+ // Nothing is being previewed at boot, so the rendered mode *is* the saved
+ // one. They only diverge while the Appearance page holds an unsaved draft.
+ savedMode: mode,
+ expandedKeys: readSettingsMenuExpandedKeys(),
+ };
+}
+
+type ImmerSet = Parameters>[0];
+
+/**
+ * Mirrors the theme's preview/commit/restore trio (`components/theme/app-theme`).
+ * The settings sidebar is on screen beside the Appearance page, so changing the
+ * control repaints the menu immediately; only the shared save coordinator
+ * writes to storage, and discarding puts the rendered mode back.
+ */
+export function buildSettingsMenuActions(set: ImmerSet) {
+ return {
+ previewSettingsMenuMode: (mode: SettingsMenuState["mode"]) =>
+ set((draft) => {
+ draft.settingsMenu.mode = mode;
+ }),
+ commitSettingsMenuMode: (mode: SettingsMenuState["mode"]) =>
+ set((draft) => {
+ draft.settingsMenu.mode = mode;
+ draft.settingsMenu.savedMode = mode;
+ writeSettingsMenuMode(mode);
+ }),
+ restoreSettingsMenuMode: () =>
+ set((draft) => {
+ draft.settingsMenu.mode = draft.settingsMenu.savedMode;
+ }),
+ setSettingsMenuExpandedKeys: (keys: string[]) =>
+ set((draft) => {
+ draft.settingsMenu.expandedKeys = keys;
+ writeSettingsMenuExpandedKeys(keys);
+ }),
+ };
+}
diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts
index 3ae1d2b3a1..9c60023b91 100644
--- a/apps/web/lib/state/slices/ui/types.ts
+++ b/apps/web/lib/state/slices/ui/types.ts
@@ -1,5 +1,6 @@
import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection";
import type { HealthCheckSummary, HealthIssue, SystemHealthResponse } from "@/lib/types/health";
+import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
import type {
FilterClause,
GroupKey,
@@ -188,6 +189,22 @@ export type SidebarTaskPrefsState = {
syncPending?: boolean;
};
+/** Settings menu shape + open branches, per device (localStorage). */
+export type SettingsMenuState = {
+ /**
+ * What the menu renders right now: the saved mode, or the unsaved one the
+ * Appearance page is previewing. Read this everywhere the menu is drawn.
+ */
+ mode: SettingsMenuMode;
+ /** The persisted value. `restoreSettingsMenuMode` reverts `mode` to this. */
+ savedMode: SettingsMenuMode;
+ /**
+ * Branch keys open in `persistent` mode. Accordion mode derives its single
+ * open path from the route instead, so it never reads or writes this.
+ */
+ expandedKeys: string[];
+};
+
/** Unified AppSidebar collapse + per-section expand state (localStorage). */
export type AppSidebarState = {
collapsed: boolean;
@@ -244,6 +261,8 @@ export type UISliceState = {
sidebarTaskPrefs: SidebarTaskPrefsState;
/** Unified AppSidebar collapse + section expand state (localStorage). */
appSidebar: AppSidebarState;
+ /** Settings menu shape + open branches (localStorage). */
+ settingsMenu: SettingsMenuState;
/**
* Most recently dismissed `last_agent_error` stamp per sessionId. Shared by
* the chat banner and the sidebar error icon so dismissing the banner also
@@ -368,6 +387,13 @@ export type UISliceActions = {
* sidebar, since the trigger renders only in the expanded header.
*/
setWorkspacePickerOpen: (open: boolean) => void;
+ /** Render `mode` without persisting it — the Appearance page's live preview. */
+ previewSettingsMenuMode: (mode: SettingsMenuMode) => void;
+ /** Persist `mode` for this device. Called by the settings save coordinator. */
+ commitSettingsMenuMode: (mode: SettingsMenuMode) => void;
+ /** Drop an unsaved preview and render the persisted mode again. */
+ restoreSettingsMenuMode: () => void;
+ setSettingsMenuExpandedKeys: (keys: string[]) => void;
/** Record multiple sidebar badge acknowledgements with one localStorage merge. */
acknowledgeAgentErrors: (stamps: Record) => void;
/** Record that `stamp` has been dismissed for `sessionId`. */
diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts
index 678ab7e95b..fc30b7f67c 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.ts
@@ -12,6 +12,8 @@ import {
buildAppSidebarActions,
loadAppSidebarState,
} from "./app-sidebar-actions";
+import { buildSettingsMenuActions, loadSettingsMenuState } from "./settings-menu-actions";
+import { DEFAULT_SETTINGS_MENU_MODE } from "@/lib/settings/settings-menu-mode";
import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions";
import { buildSidebarViewActions } from "./sidebar-view-actions";
@@ -143,6 +145,11 @@ export const defaultUIState: UISliceState = {
improveDialogOpen: false,
workspacePickerOpen: false,
},
+ settingsMenu: {
+ mode: DEFAULT_SETTINGS_MENU_MODE,
+ savedMode: DEFAULT_SETTINGS_MENU_MODE,
+ expandedKeys: [],
+ },
acknowledgedAgentErrors: {},
dismissedAgentErrors: {},
};
@@ -495,7 +502,9 @@ export const createUISlice: StateCreator {
* the "exact duplicates only" test is no longer pinning a real decision.
*/
it("still carries the deliberate storage redundancy the check must tolerate", () => {
- expect(i18nGuardFiles).toContain("app/settings/system/**/*.{ts,tsx}");
- expect(i18nGuardFiles).toContain("app/settings/system/storage/**/*.{ts,tsx}");
+ expect(i18nGuardFiles).toContain("components/settings/system/**/*.{ts,tsx}");
+ expect(i18nGuardFiles).toContain("components/settings/system/storage/**/*.{ts,tsx}");
});
});
diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json
index 294f24ecdd..5982f65095 100644
--- a/apps/web/src/locales/en/agents.json
+++ b/apps/web/src/locales/en/agents.json
@@ -10,8 +10,6 @@
"agentInstallationInProgress": "Agent installation is in progress",
"agentNotFound": "Agent not found.",
"agentProfileSettings": "{{name}} profile settings",
- "agentProfiles": "Agent Profiles",
- "agentProfilesDescription": "Manage existing profiles by agent.",
"agentProfilesTitle": "{{name}} Profiles",
"approveUpdate": "Approve update",
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
@@ -22,10 +20,10 @@
"availableCommandsCount_other": "Available commands ({{count}})",
"availableSlashCommands": "Available slash commands",
"availableSlashCommandsHelp": "Type these during a session chat to invoke them - e.g. <0>{{example}}0>.",
- "availableToInstall": "Available to Install",
"availableToInstallDescription": "Click Install to run the agent's install script on the kandev host. Progress streams live; you can start multiple installs in parallel.",
"backToAgents": "Back to Agents",
"backToModelSettings": "Back to model settings from {{name}}",
+ "browseAvailableAgents": "Browse available agents",
"builtIn": "Built-in",
"cannotDeleteAgentProfile": "Cannot delete agent profile",
"capabilityAuthRequired": "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh.",
@@ -33,6 +31,7 @@
"capabilityProbeFailed": "Probe failed. Check agent logs for details.",
"capabilityProbing": "Checking agent capabilities…",
"changeTierMappingsFirst": "Change these workspace tier mappings before deleting this profile.",
+ "everythingInstalled": "Every available agent and tool is already installed.",
"checkingAuthentication": "Checking authentication",
"checkingCapabilitiesAndAuth": "Checking agent capabilities and authentication...",
"checkingLatestRuntimeVersion": "Checking the latest runtime version…",
@@ -62,7 +61,9 @@
"copyToClipboard": "Copy to clipboard",
"create": "Create",
"createAgentProfileTitle": "Create {{name}} Profile",
- "createNewProfile": "Create new profile",
+ "installAgents": "Install agents",
+ "profileCount_one": "{{count}} profile",
+ "profileCount_other": "{{count}} profiles",
"createProfileIntro": "Create a new profile for this agent.",
"creating": "Creating...",
"defaultProfile": "Default profile",
@@ -79,7 +80,6 @@
"details": "details",
"detectedAt": "Detected at {{path}}",
"disableProfile": "Disable profile",
- "disableProfileNamed": "Disable {{name}}",
"disableSetting": "Disable {{name}}",
"displayName": "Display Name",
"displayNameRequired": "Display name is required",
@@ -87,7 +87,6 @@
"enableMcp": "Enable MCP",
"enableMcpDescription": "Allow this profile to use MCP servers during sessions.",
"enableProfile": "Enable profile",
- "enableProfileNamed": "Enable {{name}}",
"enableSetting": "Enable {{name}}",
"enabled": "Enabled",
"enabledProfileInfo": "More information about profile availability",
@@ -113,7 +112,6 @@
"fixInvalidMcpConfig": "Fix invalid MCP configuration before saving.",
"fixInvalidMcpJson": "Fix invalid MCP JSON before saving.",
"flag": "Flag",
- "goToAgents": "Go to Agents",
"hostTerminal": "Host terminal",
"hostTerminalDescription": "Runs a shell on the kandev host. Use it to install or configure agents manually.",
"install": "Install",
@@ -127,7 +125,6 @@
"invalidMcpConfig": "Invalid MCP config",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "Tools: {{tools}}",
- "manageAgentsFromMainPage": "Manage agents from the main Agents page.",
"mcpConfigMustBeJsonObject": "MCP servers config must be a JSON object",
"mcpConfiguration": "MCP Configuration",
"mcpDraftHint": "MCP config will be applied after the profile is saved.",
@@ -143,17 +140,20 @@
"noAuthHintNotInstalled": "Install the agent CLI, then click Refresh.",
"noAuthHintShell": "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh.",
"noAuthLoginRequired": "No auth - login required",
+ "newProfile": "New profile",
"noCliFlagsConfigured": "No CLI flags configured. Add one below.",
"noCommandPreview": "No command preview available.",
"noInstalledAgentsDetected": "No installed agents were detected. Install one below, then click Rescan.",
"noModeFound": "No mode found.",
"noModelFound": "No model found.",
"noModelsFound": "No models found.",
+ "noProfilesYet": "No profiles yet. Create one from an installed agent.",
"notInstalled": "Not installed",
"openTerminal": "Open terminal",
"pageDescription": "Discover installed agents, install new ones, and manage their profiles.",
"popularServers": "Popular servers",
"probingCapabilities": "Probing agent capabilities… this usually takes a few seconds.",
+ "profileActions": "Profile actions",
"profileInUseIntro": "This profile is currently in use. Deleting it will affect the following:",
"profileName": "Profile name",
"profileNameRequired": "Profile name is required.",
diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json
index 147c2a38ea..bcc580eb7b 100644
--- a/apps/web/src/locales/en/automations.json
+++ b/apps/web/src/locales/en/automations.json
@@ -37,7 +37,6 @@
"couldNotRefreshRuns": "Could not refresh runs; restored from local cache",
"couldNotTrigger": "Could not trigger the automation",
"createAnAutomation": "Create an automation",
- "createWorkspace": "Create workspace",
"cronEveryDay": "Every day",
"cronEveryHour": "Every hour",
"cronEveryWeek": "Every week",
@@ -91,14 +90,11 @@
"noTimezoneFound": "No timezone found.",
"noTriggers": "None",
"noWorkflow": "No workflow",
- "noWorkspacesDescription": "Create a workspace first, then schedule automations inside it.",
- "noWorkspacesYet": "No workspaces yet",
"nothingHasRunYet": "Nothing has run yet.",
"openAutomations": "Open automations",
"orUseWebhookInstead": "Or use a webhook instead",
"outcome": "Outcome",
"pickAWorkflowFirst": "Pick a workflow first",
- "pickWorkspace": "Pick a workspace to manage its automations.",
"prMergedDeadConfigWarning": "No repositories selected; this trigger will not fire. Enable 'All repositories' or add at least one repository.",
"prEventOpened": "Opened",
"prEventOpenedTooltip": "Fires once when a new open PR is detected. Each PR number is deduped; the same PR won't trigger again.",
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index 82ae5d44a8..76c06c8b93 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -19,6 +19,7 @@
"anUnknownErrorOccurred": "An unknown error occurred",
"apply": "Apply",
"autoApproveAllPermissions": "Auto-approve all permissions",
+ "automation": "Automation",
"automations": "Automations",
"availableTemplates": "Available templates",
"back": "Back",
@@ -155,6 +156,7 @@
"enterAValue": "Enter a value...",
"enterValue": "Enter value...",
"error": "Error",
+ "executor": "Executor",
"executors": "Executors",
"externalMcp": "External MCP",
"failedToCreateFolder": "Failed to create folder",
@@ -199,6 +201,7 @@
"goToNextPage": "Go to next page",
"goToPreviousPage": "Go to previous page",
"hAgo": "{{diffHr}}h ago",
+ "healthIssues": "Health issues",
"hideAdvancedOptions": "Hide advanced options",
"howLogContextWorks": "How log context works",
"improveKandev": "Improve Kandev",
@@ -230,6 +233,7 @@
"loadingWorkflowTemplates": "Loading workflow templates...",
"mAgo": "{{diffMin}}m ago",
"matchCase": "Match case",
+ "menu": "Menu",
"mode": "Mode",
"model": "Model",
"multiRepoUnavailableLocal": "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.",
@@ -272,6 +276,8 @@
"onboardingStepWorkflowsTitle": "Agentic Workflows",
"open": "Open",
"openFullPage": "Open full page",
+ "openNavigationMenu": "Open navigation menu",
+ "openNavigationMenuWithConnectionIssue": "{{description}} Open navigation menu",
"openingLastTask": "Opening last task…",
"openIssue": "Open issue",
"optionalTheRepositoryTheAgentWorks": "Optional; the repository the agent works in.",
@@ -299,6 +305,7 @@
"pressEnterToConfirm": "Press Enter to confirm",
"previousShiftEnter": "Previous (Shift+Enter)",
"probing": "Probing",
+ "profile": "Profile",
"profileName": "Profile name",
"prompts": "Prompts",
"reference": "Reference",
@@ -368,9 +375,11 @@
"showDiagram": "Show diagram",
"showingRange_one": "Showing {{from}} to {{to}} of {{count}} result",
"showingRange_other": "Showing {{from}} to {{to}} of {{count}} results",
+ "showMoreBreadcrumbs": "Show more breadcrumbs",
"skip": "Skip",
"stagesAndCommitsChangesUsingConventional": "Stages and commits changes using Conventional Commits.",
"status": "Status",
+ "statusWithConnectionIssue": "{{description}} Status",
"step": "Step",
"subtaskTitle": "Subtask title",
"switchMode": "Switch mode",
@@ -414,6 +423,7 @@
"usefulCommandsAgentSkills": "Useful commands & agent skills",
"useScratchInstead": "Use scratch instead",
"useThisFolder": "Use this folder",
+ "utilities": "Utilities",
"viewFullReleaseOnGithub": "View full release on GitHub",
"waitsForCiAndAutomatedReviews": "Waits for CI and automated reviews, then fixes failures and addresses comments.",
"walkthrough": "Walkthrough",
diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json
index 3d48e42808..0e9cf4a15a 100644
--- a/apps/web/src/locales/en/settings.json
+++ b/apps/web/src/locales/en/settings.json
@@ -2,6 +2,7 @@
"aSecretNameAndValueAreRequired": "A secret name and value are required.",
"aSecretNameIsRequired": "A secret name is required.",
"aboutAffectedKandevMcpTools": "About affected Kandev MCP tools",
+ "accessControl": "Access Control",
"actionsFor": "Actions for {{target}}",
"activeKandevSpritesSpritesAreDestroyed": "Active Kandev sprites. Sprites are destroyed when agents stop.",
"activeSessions": "Active sessions",
@@ -62,8 +63,8 @@
"changesPanelLayout": "Changes Panel Layout",
"changesTheLanguageOfTheKandev": "Changes the language of the Kandev interface. Applies immediately and is remembered on this device. Task titles, chat, and other content you create are not translated.",
"chatInput": "Chat Input",
- "chatInputAndCommandShortcuts": "Chat input and command shortcuts",
"chooseAMonospaceFontForThe": "Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.",
+ "chooseHowDeepTheSettingsMenuGoes": "Choose whether the settings menu lists only pages, or expands Workspaces, Agents and Executors into what they contain.",
"chooseTheLanguageUsedAcrossThe": "Choose the language used across the Kandev interface",
"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",
@@ -91,10 +92,8 @@
"connectKandevToThirdPartyServices": "Connect Kandev to third-party services. Connection scope and available settings are shown on each integration page.",
"connected": "Connected",
"connection": "Connection",
- "connectionLostDescription": "Connection lost for at least 10 seconds. Live updates may be stale.",
"connectionTestFailed": "Connection test failed",
"connectionTestPassed": "Connection test passed",
- "connectionUnstableDescription": "Connection unstable. Reconnecting to Kandev.",
"continueEditing": "Continue editing",
"couldnTSave": "<0>0> Couldn't save",
"couldnTSave2": "Couldn't save",
@@ -191,6 +190,7 @@
"discoveryAliasesNotificationSound": "notification audio,chime,alert sound",
"discoveryAliasesPreferredShell": "shell,zsh,bash,terminal shell",
"discoveryAliasesResourceMetrics": "metrics,CPU,memory,disk,status bar",
+ "discoveryAliasesSettingsMenu": "sidebar,navigation,tree,accordion,menu",
"discoveryAliasesStartupPage": "home page,start page,launch page",
"discoveryAliasesSubmitShortcut": "send key,enter to send,command enter",
"discoveryAliasesTerminalFont": "font family,typeface,monospace",
@@ -214,7 +214,6 @@
"edit": "Edit",
"editEditor": "Edit {{name}}",
"editSecret": "Edit secret",
- "editorIntegrationsAndDefaults": "Editor integrations and defaults",
"editorName": "Editor name",
"editorType": "Editor type",
"editors": "Editors",
@@ -298,7 +297,6 @@
"fontFamily": "Font Family",
"fontSize": "Font Size",
"frequencySeconds": "Frequency (seconds)",
- "general": "General",
"fallbackModelUnavailable": "Fallback model no longer available; select a different one",
"fallbackSettings": "Fallback settings",
"fallbackSettingsSummaryAutomatic": "Automatic fallback enabled",
@@ -306,8 +304,8 @@
"fallbackSettingsSummaryStrict": "No fallback configured",
"fallbackNoteLive": "This session started on a fallback model because the configured model was unavailable. The (fallback) marker reflects the live session and is not shown after reload.",
"modelFallbackSuffix": "(fallback)",
+ "globalSecrets": "Global Secrets",
"health": "Health",
- "home": "Home",
"hostedUrl": "Hosted URL",
"icons": "Icons",
"increaseHeight": "Increase height",
@@ -366,7 +364,6 @@
"loading": "Loading...",
"loadingSecrets": "Loading secrets...",
"logsPageDescription": "Create a diagnostic ZIP with frontend and backend logs.",
- "logsPageTitle": "Logs",
"lspConfigCustomBadge": "custom",
"lspInstallHintGo": "Runs \"{{command}}\". Requires Go to be installed.",
"lspInstallHintKotlin": "Install {{binary}} manually on the task host's {{path}}. For Local Docker tasks, install it inside the task container.",
@@ -383,9 +380,19 @@
"manageApiKeysAndCredentialsSecrets": "Manage API keys and credentials. Secrets are encrypted at rest and injected into agent environments via executor profile env vars.",
"globalScope": "Global",
"modelWithValue": "Model: {{model}}",
+ "settingsMenu": "Settings Menu",
+ "settingsMenuAccordion": "Accordion tree",
+ "settingsMenuAccordionDescription": "Workspaces, Agents and Executors expand into what they hold: a workspace down to a single integration. Opening one branch closes the others, so the menu only ever shows the way to the page you are on.",
+ "settingsMenuFlat": "Flat list",
+ "settingsMenuFlatDescription": "One row per settings page, never more. Individual workspaces, agents and executors are reached from their page. The default, and the shortest menu.",
+ "settingsMenuPersistent": "Persistent tree",
+ "settingsMenuPersistentDescription": "The same branches, but several stay open at once and are still open after a reload. Choose this to keep a few records side by side; with many workspaces or agents the menu gets long.",
+ "settingsMenuShape": "Menu Shape",
+ "settingsMenuShapePerDevice": "Applies to this device only. Your other devices and browsers keep their own choice.",
"temperatureWithValue": "Temperature: {{temperature}}",
"workspaceScope": "Workspace",
"workspaceSecretsDescription": "Manage secrets available to repositories in this workspace. Shared agent and executor profiles can use Global secrets only.",
+ "workspacesAndAccess": "Workspaces & Access",
"revealSecretNamed": "Reveal secret {{name}}",
"hideSecretNamed": "Hide secret {{name}}",
"editSecretNamed": "Edit secret {{name}}",
@@ -396,7 +403,6 @@
"mcpAffectedToolsHelp": "<0>create_task_kandev0> creates a separate task. <3>spawn_session_kandev3> adds a session to the current task, so it does not use this preference.",
"mcpCreateTaskScope": "<0>create_task_kandev0> creates new tasks and subtasks. This setting applies only when the call omits <4>agent_profile_id4>.",
"mcpSpawnSessionScope": "<0>spawn_session_kandev0> and tasks you create yourself are not affected. An explicitly selected profile always wins.",
- "mcpTaskDefaultsAndArchiveSafeguards": "MCP task defaults, archive safeguards, and transcript preferences",
"mcpTaskProfileCurrentTask": "Current task profile",
"mcpTaskProfileCurrentTaskDescription": "The new task uses the same profile as the task that created it. Choose this when follow-up work needs the same model and agent setup. This may reuse a more expensive profile.",
"mcpTaskProfileWorkspaceDefault": "Workspace default profile",
@@ -465,8 +471,6 @@
"notifyWhenThisEventOccurs": "Notify when this event occurs.",
"openKandevTo": "Open Kandev to",
"openLinksIn": "Open links in",
- "openSettingsMenu": "Open settings menu",
- "openSettingsMenuWithConnectionIssue": "{{description}} Open settings menu",
"opensAVsCodeRemoteSsh": "Opens a VS Code Remote SSH URL using the scheme (example: {{example}}).",
"optionalScheme": "optional scheme ({{schemes}})",
"optionalUsername": "optional username",
@@ -476,6 +480,7 @@
"pinTheAgentsLiveTodoChecklistAs": "Pin the agent's live todo checklist as a tab in the right panel.",
"pluginShortcuts": "Plugin Shortcuts",
"preferredShell": "Preferred Shell",
+ "preferences": "Preferences",
"pressAKeyCombo": "Press a key combo...",
"pressCmdCtrlEnterToSend": "Press Cmd/Ctrl+Enter to send messages. Press Enter for newlines.",
"pressEnterToSendMessagesPress": "Press Enter to send messages. Press Shift+Enter for newlines.",
@@ -500,7 +505,6 @@
"promptsLoading": "Loading prompts…",
"promptsPageDescription": "Create reusable prompt snippets for the chat input.",
"providerName": "Provider name",
- "providersAndNotificationEvents": "Providers and notification events",
"pxRange": "px (8-24)",
"refreshNotificationPermission": "Refresh notification permission",
"refreshPermissionStatus": "Refresh permission status",
@@ -542,7 +546,6 @@
"settingsSearchResults_one": "{{count}} matching setting",
"settingsSearchResults_other": "{{count}} matching settings",
"shellOptionsUnavailable": "Shell options unavailable",
- "shellTerminalFontsAndLinkBehavior": "Shell, terminal fonts, and link behavior",
"showAgentTodoListPanel": "Show agent todo list panel",
"showAnchoredPromptBar": "Show anchored prompt bar",
"showCleanupDetailsAndSubtaskOptions": "Show cleanup details and subtask options before an archive starts.",
@@ -593,23 +596,22 @@
"spriteHealthStopping": "Stopping",
"spriteHealthUnhealthy": "Unhealthy",
"spriteHealthUnknown": "Unknown",
- "sprites": "Sprites",
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev provides ephemeral cloud sandboxes for running agents remotely.",
"startOnYourSavedKanbanPipelineOrList": "Start on your saved Kanban, Pipeline, or List view.",
"startupPage": "Startup Page",
- "statusWithConnectionIssue": "Status. {{description}}",
"submitShortcut": "Submit Shortcut",
"supportedEditors": "Supported Editors",
"supportsPlaceholders": "Supports placeholders: {{placeholders}}",
"systemDefault": "System default",
"systemLoad1Min": "System load (1 min)",
"taskActions": "Task Actions",
+ "taskBehavior": "Task Behavior",
"taskOverview": "Task overview",
- "taskWorkbenchLayoutProfilesAndDefaults": "Task workbench layout profiles and defaults",
"startModelUnavailable": "No longer available; select a different model",
"startModelAria": "Profile start model settings",
"terminal": "Terminal",
"terminalFont": "Terminal Font",
+ "terminalAndEditors": "Terminal & Editors",
"terminalFontSize": "Terminal Font Size",
"terminalFontSizeMustBeA": "Terminal font size must be a number between 8 and 24.",
"terminalLinks": "Terminal Links",
@@ -617,7 +619,6 @@
"testNotificationBody": "If you can read this, browser notifications are working.",
"testNotificationTitle": "Test notification",
"theBuiltInDefaultLayoutWill": "The built-in Default layout will become the default.",
- "themeMetricsAndChangesPanelPreferences": "Theme, startup page, metrics, and changes panel preferences",
"thisAppliesWhenKandevStarts": "This applies when Kandev starts or you open its bare home page. Home, Back, and workflow navigation always open the task overview.",
"thisLayoutIsUsedAsThe": "This layout is used as the starting layout for new tasks.",
"thisPageHasUnsavedChangesSave": "This page has unsaved changes. Save them, discard them, or continue editing.",
diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json
index 3d78401ed5..da83b392f3 100644
--- a/apps/web/src/locales/en/sidebar.json
+++ b/apps/web/src/locales/en/sidebar.json
@@ -1,6 +1,7 @@
{
- "account": "Account",
"archivedLoadFailed": "Archived tasks could not be loaded",
+ "collapseAllBranches": "Collapse all",
+ "orgChart": "Org Chart",
"retry": "Retry",
"activeWorkspaceBadge": "Active",
"activity": "Activity",
diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json
index 173506edc2..d5621c8007 100644
--- a/apps/web/src/locales/en/system.json
+++ b/apps/web/src/locales/en/system.json
@@ -57,6 +57,7 @@
"databaseOptimize": "Optimize",
"databaseOptimizeHelp": "Asks the database to refresh its internal indexes so common queries stay fast. Run it after deleting a lot of data or if the app feels sluggish. Quick and safe - no data is changed.",
"databasePageDescription": "Database driver, size, and available maintenance controls.",
+ "dataStoragePageDescription": "Database statistics, backups, disk cleanup, and server logs.",
"databasePath": "Path",
"databaseRunOptimize": "Run optimize",
"databaseRunVacuum": "Run vacuum",
@@ -195,10 +196,10 @@
"navAbout": "About",
"navBackups": "Backups",
"navDatabase": "Database",
+ "navDataStorage": "Data & Logs",
"navFeatureToggles": "Feature Toggles",
"navLicenses": "Licenses",
"navLogs": "Logs",
- "navStorage": "Storage",
"navUpdates": "Updates",
"navUsers": "Users",
"reloadPage": "Reload page",
diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json
index 1c163d677f..14fefe18e4 100644
--- a/apps/web/src/locales/en/workflows.json
+++ b/apps/web/src/locales/en/workflows.json
@@ -99,7 +99,6 @@
"lastAttempt": "last attempt {{when}}",
"lastSynced": "last synced {{when}}",
"loadingWorkflowSteps": "Loading workflow steps...",
- "manageWorkflowsForThisWorkspace": "Manage workflows for this workspace.",
"migrateAndDelete": "Migrate & Delete",
"migrateAndDeleteStep": "Migrate & Delete Step",
"migrating": "Migrating...",
@@ -255,6 +254,5 @@
"workflows": "Workflows",
"workflowsSectionDescription": "Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself",
"workflowSteps": "Workflow Steps",
- "workflowSyncCompleted": "Workflow sync completed",
- "workspaceSettings": "Workspace settings"
+ "workflowSyncCompleted": "Workflow sync completed"
}
diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json
index 87995bae09..4c6be9c746 100644
--- a/apps/web/src/locales/en/workspaces.json
+++ b/apps/web/src/locales/en/workspaces.json
@@ -76,13 +76,13 @@
"invalidRepositoryPath": "Invalid repository path",
"localPath": "Local Path",
"localPathNotSet": "Local path not set",
- "manageRepositoriesConnected": "Manage repositories connected to this workspace.",
"repositoriesReadOnlyImprove": "Repositories in this workspace are managed by Improve Kandev and cannot be changed.",
"manageWorkspaceDetails": "Manage workspace details and jump into workflows or repositories.",
"manageYourWorkspacesAndWorkflows": "Manage your workspaces and workflows",
"manualPath": "Manual path",
"name": "Name",
"noAgentProfilesAvailable": "No agent profiles available",
+ "overview": "Overview",
"noCustomScripts": "No custom scripts",
"noDefault": "No default",
"noExecutorsAvailable": "No executors available",
@@ -92,6 +92,8 @@
"patternSyntax": "Pattern syntax",
"remoteRepository": "Remote repository",
"repositories": "Repositories",
+ "resourceCount_one": "{{count}} resource",
+ "resourceCount_other": "{{count}} resources",
"repositoriesInThisWorkspace": "Repositories in this workspace",
"repository": "Repository",
"repositoryName": "Repository Name",
@@ -110,15 +112,11 @@
"useRepository": "Use Repository",
"validGitRepository": "Valid git repository",
"validate": "Validate",
- "workflows": "Workflows",
- "workflowsManagedInThisWorkspace": "Workflows managed in this workspace",
- "workspaceLinks": "Workspace Links",
"workspaceName": "Workspace Name",
"workspaceNameIsRequired": "Workspace name is required.",
"workspaceNamePlaceholder": "My Workspace",
"workspaceNotFound": "Workspace not found",
"workspaceSettings": "Workspace Settings",
- "workspaceSettingsLink": "Workspace settings",
"worktreeBranchTemplate": "Worktree Branch Template",
"worktreeSync": "Worktree Sync"
}
diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json
index 9493045d29..2d66dbc77f 100644
--- a/apps/web/src/locales/pseudo/agents.json
+++ b/apps/web/src/locales/pseudo/agents.json
@@ -10,8 +10,6 @@
"agentInstallationInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś ĩń ƥŕōĝŕēśś",
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
- "agentProfiles": "Àĝēńţ Ƥŕōƒĩĺēś",
- "agentProfilesDescription": "Ḿàńàĝē ēxĩśţĩńĝ ƥŕōƒĩĺēś ƀŷ àĝēńţ.",
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
"approveUpdate": "Àƥƥŕōvē ũƥďàţē",
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
@@ -22,10 +20,10 @@
"availableCommandsCount_other": "Àvàĩĺàƀĺē ćōḿḿàńďś ({{count}})",
"availableSlashCommands": "Àvàĩĺàƀĺē śĺàśĥ ćōḿḿàńďś",
"availableSlashCommandsHelp": "Ţŷƥē ţĥēśē ďũŕĩńĝ à śēśśĩōń ćĥàţ ţō ĩńvōķē ţĥēḿ - ē.ĝ. <0>{{example}}0>.",
- "availableToInstall": "Àvàĩĺàƀĺē ţō Ĩńśţàĺĺ",
"availableToInstallDescription": "Ćĺĩćķ Ĩńśţàĺĺ ţō ŕũń ţĥē àĝēńţ'ś ĩńśţàĺĺ śćŕĩƥţ ōń ţĥē ķàńďēv ĥōśţ. Ƥŕōĝŕēśś śţŕēàḿś ĺĩvē; ŷōũ ćàń śţàŕţ ḿũĺţĩƥĺē ĩńśţàĺĺś ĩń ƥàŕàĺĺēĺ.",
"backToAgents": "Ɓàćķ ţō Àĝēńţś",
"backToModelSettings": "Ɓàćķ ţō ḿōďēĺ śēţţĩńĝś ƒŕōḿ {{name}}",
+ "browseAvailableAgents": "Ɓŕōŵśē àvàĩĺàƀĺē àĝēńţś",
"builtIn": "Ɓũĩĺţ-ĩń",
"cannotDeleteAgentProfile": "Ćàńńōţ ďēĺēţē àĝēńţ ƥŕōƒĩĺē",
"capabilityAuthRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď. Ŕũń ţĥē àĝēńţ ĆĹĨ ĩń ŷōũŕ ţēŕḿĩńàĺ ţō àũţĥēńţĩćàţē, ţĥēń ŕēƒŕēśĥ.",
@@ -33,6 +31,7 @@
"capabilityProbeFailed": "Ƥŕōƀē ƒàĩĺēď. Ćĥēćķ àĝēńţ ĺōĝś ƒōŕ ďēţàĩĺś.",
"capabilityProbing": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś…",
"changeTierMappingsFirst": "Ćĥàńĝē ţĥēśē ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś ƀēƒōŕē ďēĺēţĩńĝ ţĥĩś ƥŕōƒĩĺē.",
+ "everythingInstalled": "Ēvēŕŷ àvàĩĺàƀĺē àĝēńţ àńď ţōōĺ ĩś àĺŕēàďŷ ĩńśţàĺĺēď.",
"checkingAuthentication": "Ćĥēćķĩńĝ àũţĥēńţĩćàţĩōń",
"checkingCapabilitiesAndAuth": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś àńď àũţĥēńţĩćàţĩōń...",
"checkingLatestRuntimeVersion": "Ćĥēćķĩńĝ ţĥē ĺàţēśţ ŕũńţĩḿē vēŕśĩōń…",
@@ -62,7 +61,9 @@
"copyToClipboard": "Ćōƥŷ ţō ćĺĩƥƀōàŕď",
"create": "Ćŕēàţē",
"createAgentProfileTitle": "Ćŕēàţē {{name}} Ƥŕōƒĩĺē",
- "createNewProfile": "Ćŕēàţē ńēŵ ƥŕōƒĩĺē",
+ "installAgents": "Ĩńśţàĺĺ àĝēńţś",
+ "profileCount_one": "{{count}} ƥŕōƒĩĺē",
+ "profileCount_other": "{{count}} ƥŕōƒĩĺēś",
"createProfileIntro": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś àĝēńţ.",
"creating": "Ćŕēàţĩńĝ...",
"defaultProfile": "Ďēƒàũĺţ ƥŕōƒĩĺē",
@@ -79,7 +80,6 @@
"details": "ďēţàĩĺś",
"detectedAt": "Ďēţēćţēď àţ {{path}}",
"disableProfile": "Ďĩśàƀĺē ƥŕōƒĩĺē",
- "disableProfileNamed": "Ďĩśàƀĺē {{name}}",
"disableSetting": "Ďĩśàƀĺē {{name}}",
"displayName": "Ďĩśƥĺàŷ Ńàḿē",
"displayNameRequired": "Ďĩśƥĺàŷ ńàḿē ĩś ŕēqũĩŕēď",
@@ -87,7 +87,6 @@
"enableMcp": "Ēńàƀĺē ḾĆƤ",
"enableMcpDescription": "Àĺĺōŵ ţĥĩś ƥŕōƒĩĺē ţō ũśē ḾĆƤ śēŕvēŕś ďũŕĩńĝ śēśśĩōńś.",
"enableProfile": "Ēńàƀĺē ƥŕōƒĩĺē",
- "enableProfileNamed": "Ēńàƀĺē {{name}}",
"enableSetting": "Ēńàƀĺē {{name}}",
"enabled": "Ēńàƀĺēď",
"enabledProfileInfo": "Ḿōŕē ĩńƒōŕḿàţĩōń àƀōũţ ƥŕōƒĩĺē àvàĩĺàƀĩĺĩţŷ",
@@ -113,7 +112,6 @@
"fixInvalidMcpConfig": "Ƒĩx ĩńvàĺĩď ḾĆƤ ćōńƒĩĝũŕàţĩōń ƀēƒōŕē śàvĩńĝ.",
"fixInvalidMcpJson": "Ƒĩx ĩńvàĺĩď ḾĆƤ ĴŚŌŃ ƀēƒōŕē śàvĩńĝ.",
"flag": "Ƒĺàĝ",
- "goToAgents": "Ĝō ţō Àĝēńţś",
"hostTerminal": "Ĥōśţ ţēŕḿĩńàĺ",
"hostTerminalDescription": "Ŕũńś à śĥēĺĺ ōń ţĥē ķàńďēv ĥōśţ. Ũśē ĩţ ţō ĩńśţàĺĺ ōŕ ćōńƒĩĝũŕē àĝēńţś ḿàńũàĺĺŷ.",
"install": "Ĩńśţàĺĺ",
@@ -127,7 +125,6 @@
"invalidMcpConfig": "Ĩńvàĺĩď ḾĆƤ ćōńƒĩĝ",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "Ţōōĺś: {{tools}}",
- "manageAgentsFromMainPage": "Ḿàńàĝē àĝēńţś ƒŕōḿ ţĥē ḿàĩń Àĝēńţś ƥàĝē.",
"mcpConfigMustBeJsonObject": "ḾĆƤ śēŕvēŕś ćōńƒĩĝ ḿũśţ ƀē à ĴŚŌŃ ōƀĵēćţ",
"mcpConfiguration": "ḾĆƤ Ćōńƒĩĝũŕàţĩōń",
"mcpDraftHint": "ḾĆƤ ćōńƒĩĝ ŵĩĺĺ ƀē àƥƥĺĩēď àƒţēŕ ţĥē ƥŕōƒĩĺē ĩś śàvēď.",
@@ -143,17 +140,20 @@
"noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
"noAuthHintShell": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō ĺàũńćĥ à śĥēĺĺ, ŕũń ţĥē àĝēńţ'ś śĩĝń-ĩń ćōḿḿàńď, ţĥēń ŕēƒŕēśĥ.",
"noAuthLoginRequired": "Ńō àũţĥ - ĺōĝĩń ŕēqũĩŕēď",
+ "newProfile": "Ńēŵ ƥŕōƒĩĺē",
"noCliFlagsConfigured": "Ńō ĆĹĨ ƒĺàĝś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.",
"noCommandPreview": "Ńō ćōḿḿàńď ƥŕēvĩēŵ àvàĩĺàƀĺē.",
"noInstalledAgentsDetected": "Ńō ĩńśţàĺĺēď àĝēńţś ŵēŕē ďēţēćţēď. Ĩńśţàĺĺ ōńē ƀēĺōŵ, ţĥēń ćĺĩćķ Ŕēśćàń.",
"noModeFound": "Ńō ḿōďē ƒōũńď.",
"noModelFound": "Ńō ḿōďēĺ ƒōũńď.",
"noModelsFound": "Ńō ḿōďēĺś ƒōũńď.",
+ "noProfilesYet": "Ńō ƥŕōƒĩĺēś ŷēţ. Ćŕēàţē ōńē ƒŕōḿ àń ĩńśţàĺĺēď àĝēńţ.",
"notInstalled": "Ńōţ ĩńśţàĺĺēď",
"openTerminal": "Ōƥēń ţēŕḿĩńàĺ",
"pageDescription": "Ďĩśćōvēŕ ĩńśţàĺĺēď àĝēńţś, ĩńśţàĺĺ ńēŵ ōńēś, àńď ḿàńàĝē ţĥēĩŕ ƥŕōƒĩĺēś.",
"popularServers": "Ƥōƥũĺàŕ śēŕvēŕś",
"probingCapabilities": "Ƥŕōƀĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś… ţĥĩś ũśũàĺĺŷ ţàķēś à ƒēŵ śēćōńďś.",
+ "profileActions": "Ƥŕōƒĩĺē àćţĩōńś",
"profileInUseIntro": "Ţĥĩś ƥŕōƒĩĺē ĩś ćũŕŕēńţĺŷ ĩń ũśē. Ďēĺēţĩńĝ ĩţ ŵĩĺĺ àƒƒēćţ ţĥē ƒōĺĺōŵĩńĝ:",
"profileName": "Ƥŕōƒĩĺē ńàḿē",
"profileNameRequired": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď.",
diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json
index eff021d0f0..4057d78e8d 100644
--- a/apps/web/src/locales/pseudo/automations.json
+++ b/apps/web/src/locales/pseudo/automations.json
@@ -37,7 +37,6 @@
"couldNotRefreshRuns": "Ćōũĺď ńōţ ŕēƒŕēśĥ ŕũńś; ŕēśţōŕēď ƒŕōḿ ĺōćàĺ ćàćĥē",
"couldNotTrigger": "Ćōũĺď ńōţ ţŕĩĝĝēŕ ţĥē àũţōḿàţĩōń",
"createAnAutomation": "Ćŕēàţē àń àũţōḿàţĩōń",
- "createWorkspace": "Ćŕēàţē ŵōŕķśƥàćē",
"cronEveryDay": "Ēvēŕŷ ďàŷ",
"cronEveryHour": "Ēvēŕŷ ĥōũŕ",
"cronEveryWeek": "Ēvēŕŷ ŵēēķ",
@@ -91,14 +90,11 @@
"noTimezoneFound": "Ńō ţĩḿēźōńē ƒōũńď.",
"noTriggers": "Ńōńē",
"noWorkflow": "Ńō ŵōŕķƒĺōŵ",
- "noWorkspacesDescription": "Ćŕēàţē à ŵōŕķśƥàćē ƒĩŕśţ, ţĥēń śćĥēďũĺē àũţōḿàţĩōńś ĩńśĩďē ĩţ.",
- "noWorkspacesYet": "Ńō ŵōŕķśƥàćēś ŷēţ",
"nothingHasRunYet": "Ńōţĥĩńĝ ĥàś ŕũń ŷēţ.",
"openAutomations": "Ōƥēń àũţōḿàţĩōńś",
"orUseWebhookInstead": "Ōŕ ũśē à ŵēƀĥōōķ ĩńśţēàď",
"outcome": "Ōũţćōḿē",
"pickAWorkflowFirst": "Ƥĩćķ à ŵōŕķƒĺōŵ ƒĩŕśţ",
- "pickWorkspace": "Ƥĩćķ à ŵōŕķśƥàćē ţō ḿàńàĝē ĩţś àũţōḿàţĩōńś.",
"prMergedDeadConfigWarning": "Ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď; ţĥĩś ţŕĩĝĝēŕ ŵĩĺĺ ńōţ ƒĩŕē. Ēńàƀĺē 'Àĺĺ ŕēƥōśĩţōŕĩēś' ōŕ àďď àţ ĺēàśţ ōńē ŕēƥōśĩţōŕŷ.",
"prEventOpened": "Ōƥēńēď",
"prEventOpenedTooltip": "Ƒĩŕēś ōńćē ŵĥēń à ńēŵ ōƥēń ƤŔ ĩś ďēţēćţēď. Ēàćĥ ƤŔ ńũḿƀēŕ ĩś ďēďũƥēď; ţĥē śàḿē ƤŔ ŵōń'ţ ţŕĩĝĝēŕ àĝàĩń.",
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 921221968c..f2537dfc5b 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -19,6 +19,7 @@
"anUnknownErrorOccurred": "Àń ũńķńōŵń ēŕŕōŕ ōććũŕŕēď",
"apply": "Àƥƥĺŷ",
"autoApproveAllPermissions": "Àũţō-àƥƥŕōvē àĺĺ ƥēŕḿĩśśĩōńś",
+ "automation": "Àũţōḿàţĩōń",
"automations": "Àũţōḿàţĩōńś",
"availableTemplates": "Àvàĩĺàƀĺē ţēḿƥĺàţēś",
"back": "Ɓàćķ",
@@ -155,6 +156,7 @@
"enterAValue": "Ēńţēŕ à vàĺũē...",
"enterValue": "Ēńţēŕ vàĺũē...",
"error": "Ēŕŕōŕ",
+ "executor": "Ēxēćũţōŕ",
"executors": "Ēxēćũţōŕś",
"externalMcp": "Ēxţēŕńàĺ ḾĆƤ",
"failedToCreateFolder": "Ƒàĩĺēď ţō ćŕēàţē ƒōĺďēŕ",
@@ -199,6 +201,7 @@
"goToNextPage": "Ĝō ţō ńēxţ ƥàĝē",
"goToPreviousPage": "Ĝō ţō ƥŕēvĩōũś ƥàĝē",
"hAgo": "{{diffHr}}ĥ àĝō",
+ "healthIssues": "Ĥēàĺţĥ ĩśśũēś",
"hideAdvancedOptions": "Ĥĩďē àďvàńćēď ōƥţĩōńś",
"howLogContextWorks": "Ĥōŵ ĺōĝ ćōńţēxţ ŵōŕķś",
"improveKandev": "Ĩḿƥŕōvē Ķàńďēv",
@@ -230,6 +233,7 @@
"loadingWorkflowTemplates": "Ĺōàďĩńĝ ŵōŕķƒĺōŵ ţēḿƥĺàţēś...",
"mAgo": "{{diffMin}}ḿ àĝō",
"matchCase": "Ḿàţćĥ ćàśē",
+ "menu": "Ḿēńũ",
"mode": "Ḿōďē",
"model": "Ḿōďēĺ",
"multiRepoUnavailableLocal": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ĺōćàĺ ũńţĩĺ ĩţś ĩńĩţĩàĺ ĺàũńćĥ ƥàţĥ ćàń ƥŕōĵēćţ śĩƀĺĩńĝ ŕēƥōśĩţōŕĩēś.",
@@ -272,6 +276,8 @@
"onboardingStepWorkflowsTitle": "Àĝēńţĩć Ŵōŕķƒĺōŵś",
"open": "Ōƥēń",
"openFullPage": "Ōƥēń ƒũĺĺ ƥàĝē",
+ "openNavigationMenu": "Ōƥēń ńàvĩĝàţĩōń ḿēńũ",
+ "openNavigationMenuWithConnectionIssue": "{{description}} Ōƥēń ńàvĩĝàţĩōń ḿēńũ",
"openingLastTask": "Ōƥēńĩńĝ ĺàśţ ţàśķ…",
"openIssue": "Ōƥēń ĩśśũē",
"optionalTheRepositoryTheAgentWorks": "Ōƥţĩōńàĺ; ţĥē ŕēƥōśĩţōŕŷ ţĥē àĝēńţ ŵōŕķś ĩń.",
@@ -299,6 +305,7 @@
"pressEnterToConfirm": "Ƥŕēśś Ēńţēŕ ţō ćōńƒĩŕḿ",
"previousShiftEnter": "Ƥŕēvĩōũś (Śĥĩƒţ+Ēńţēŕ)",
"probing": "Ƥŕōƀĩńĝ",
+ "profile": "Ƥŕōƒĩĺē",
"profileName": "Ƥŕōƒĩĺē ńàḿē",
"prompts": "Ƥŕōḿƥţś",
"reference": "Ŕēƒēŕēńćē",
@@ -368,9 +375,11 @@
"showDiagram": "Śĥōŵ ďĩàĝŕàḿ",
"showingRange_one": "Śĥōŵĩńĝ {{from}} ţō {{to}} ōƒ {{count}} ŕēśũĺţ",
"showingRange_other": "Śĥōŵĩńĝ {{from}} ţō {{to}} ōƒ {{count}} ŕēśũĺţś",
+ "showMoreBreadcrumbs": "Śĥōŵ ḿōŕē ƀŕēàďćŕũḿƀś",
"skip": "Śķĩƥ",
"stagesAndCommitsChangesUsingConventional": "Śţàĝēś àńď ćōḿḿĩţś ćĥàńĝēś ũśĩńĝ Ćōńvēńţĩōńàĺ Ćōḿḿĩţś.",
"status": "Śţàţũś",
+ "statusWithConnectionIssue": "{{description}} Śţàţũś",
"step": "Śţēƥ",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"switchMode": "Śŵĩţćĥ ḿōďē",
@@ -414,6 +423,7 @@
"usefulCommandsAgentSkills": "Ũśēƒũĺ ćōḿḿàńďś & àĝēńţ śķĩĺĺś",
"useScratchInstead": "Ũśē śćŕàţćĥ ĩńśţēàď",
"useThisFolder": "Ũśē ţĥĩś ƒōĺďēŕ",
+ "utilities": "Ũţĩĺĩţĩēś",
"viewFullReleaseOnGithub": "Vĩēŵ ƒũĺĺ ŕēĺēàśē ōń ĜĩţĤũƀ",
"waitsForCiAndAutomatedReviews": "Ŵàĩţś ƒōŕ ĆĨ àńď àũţōḿàţēď ŕēvĩēŵś, ţĥēń ƒĩxēś ƒàĩĺũŕēś àńď àďďŕēśśēś ćōḿḿēńţś.",
"walkthrough": "Ŵàĺķţĥŕōũĝĥ",
diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json
index 26cca5bdf9..3b0e13ceed 100644
--- a/apps/web/src/locales/pseudo/settings.json
+++ b/apps/web/src/locales/pseudo/settings.json
@@ -2,6 +2,7 @@
"aSecretNameAndValueAreRequired": "À śēćŕēţ ńàḿē àńď vàĺũē àŕē ŕēqũĩŕēď.",
"aSecretNameIsRequired": "À śēćŕēţ ńàḿē ĩś ŕēqũĩŕēď.",
"aboutAffectedKandevMcpTools": "Àƀōũţ àƒƒēćţēď Ķàńďēv ḾĆƤ ţōōĺś",
+ "accessControl": "Àććēśś Ćōńţŕōĺ",
"actionsFor": "Àćţĩōńś ƒōŕ {{target}}",
"activeKandevSpritesSpritesAreDestroyed": "Àćţĩvē Ķàńďēv śƥŕĩţēś. Śƥŕĩţēś àŕē ďēśţŕōŷēď ŵĥēń àĝēńţś śţōƥ.",
"activeSessions": "Àćţĩvē śēśśĩōńś",
@@ -62,8 +63,8 @@
"changesPanelLayout": "Ćĥàńĝēś Ƥàńēĺ Ĺàŷōũţ",
"changesTheLanguageOfTheKandev": "Ćĥàńĝēś ţĥē ĺàńĝũàĝē ōƒ ţĥē Ķàńďēv ĩńţēŕƒàćē. Àƥƥĺĩēś ĩḿḿēďĩàţēĺŷ àńď ĩś ŕēḿēḿƀēŕēď ōń ţĥĩś ďēvĩćē. Ţàśķ ţĩţĺēś, ćĥàţ, àńď ōţĥēŕ ćōńţēńţ ŷōũ ćŕēàţē àŕē ńōţ ţŕàńśĺàţēď.",
"chatInput": "Ćĥàţ Ĩńƥũţ",
- "chatInputAndCommandShortcuts": "Ćĥàţ ĩńƥũţ àńď ćōḿḿàńď śĥōŕţćũţś",
"chooseAMonospaceFontForThe": "Ćĥōōśē à ḿōńōśƥàćē ƒōńţ ƒōŕ ţĥē ţēŕḿĩńàĺ. Ńēŕď Ƒōńţś ĩńćĺũďē ĩćōńś ƒōŕ ĆĹĨ ţōōĺś.",
+ "chooseHowDeepTheSettingsMenuGoes": "Ćĥōōśē ŵĥēţĥēŕ ţĥē śēţţĩńĝś ḿēńũ ĺĩśţś ōńĺŷ ƥàĝēś, ōŕ ēxƥàńďś Ŵōŕķśƥàćēś, Àĝēńţś àńď Ēxēćũţōŕś ĩńţō ŵĥàţ ţĥēŷ ćōńţàĩń.",
"chooseTheLanguageUsedAcrossThe": "Ćĥōōśē ţĥē ĺàńĝũàĝē ũśēď àćŕōśś ţĥē Ķàńďēv ĩńţēŕƒàćē",
"chooseWhatOpensWhenKandevStarts": "Ćĥōōśē ŵĥàţ ōƥēńś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ vĩśĩţ ĩţś ƀàŕē ĥōḿē ƥàĝē",
"chooseWhichTranscriptNavigationControls": "Ćĥōōśē ŵĥĩćĥ ţŕàńśćŕĩƥţ ńàvĩĝàţĩōń ćōńţŕōĺś àƥƥēàŕ, ĩńćĺũďĩńĝ ţĥē ďēśķţōƥ-ōńĺŷ śţĩćķŷ ƥŕōḿƥţ ƀàŕ",
@@ -91,10 +92,8 @@
"connectKandevToThirdPartyServices": "Ćōńńēćţ Ķàńďēv ţō ţĥĩŕď-ƥàŕţŷ śēŕvĩćēś. Ćōńńēćţĩōń śćōƥē àńď àvàĩĺàƀĺē śēţţĩńĝś àŕē śĥōŵń ōń ēàćĥ ĩńţēĝŕàţĩōń ƥàĝē.",
"connected": "Ćōńńēćţēď",
"connection": "Ćōńńēćţĩōń",
- "connectionLostDescription": "Ćōńńēćţĩōń ĺōśţ ƒōŕ àţ ĺēàśţ 10 śēćōńďś. Ĺĩvē ũƥďàţēś ḿàŷ ƀē śţàĺē.",
"connectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď",
"connectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď",
- "connectionUnstableDescription": "Ćōńńēćţĩōń ũńśţàƀĺē. Ŕēćōńńēćţĩńĝ ţō Ķàńďēv.",
"continueEditing": "Ćōńţĩńũē ēďĩţĩńĝ",
"couldnTSave": "<0>0> Ćōũĺďń'ţ śàvē",
"couldnTSave2": "Ćōũĺďń'ţ śàvē",
@@ -191,6 +190,7 @@
"discoveryAliasesNotificationSound": "ńōţĩƒĩćàţĩōń àũďĩō,ćĥĩḿē,àĺēŕţ śōũńď",
"discoveryAliasesPreferredShell": "śĥēĺĺ,źśĥ,ƀàśĥ,ţēŕḿĩńàĺ śĥēĺĺ",
"discoveryAliasesResourceMetrics": "ḿēţŕĩćś,ĆƤŨ,ḿēḿōŕŷ,ďĩśķ,śţàţũś ƀàŕ",
+ "discoveryAliasesSettingsMenu": "śĩďēƀàŕ,ńàvĩĝàţĩōń,ţŕēē,àććōŕďĩōń,ḿēńũ",
"discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē",
"discoveryAliasesSubmitShortcut": "śēńď ķēŷ,ēńţēŕ ţō śēńď,ćōḿḿàńď ēńţēŕ",
"discoveryAliasesTerminalFont": "ƒōńţ ƒàḿĩĺŷ,ţŷƥēƒàćē,ḿōńōśƥàćē",
@@ -214,7 +214,6 @@
"edit": "Ēďĩţ",
"editEditor": "Ēďĩţ {{name}}",
"editSecret": "Ēďĩţ śēćŕēţ",
- "editorIntegrationsAndDefaults": "Ēďĩţōŕ ĩńţēĝŕàţĩōńś àńď ďēƒàũĺţś",
"editorName": "Ēďĩţōŕ ńàḿē",
"editorType": "Ēďĩţōŕ ţŷƥē",
"editors": "Ēďĩţōŕś",
@@ -298,7 +297,6 @@
"fontFamily": "Ƒōńţ Ƒàḿĩĺŷ",
"fontSize": "Ƒōńţ Śĩźē",
"frequencySeconds": "Ƒŕēqũēńćŷ (śēćōńďś)",
- "general": "Ĝēńēŕàĺ",
"fallbackModelUnavailable": "Ƒàĺĺƀàćķ ḿōďēĺ ńō ĺōńĝēŕ àvàĩĺàƀĺē; śēĺēćţ à ďĩƒƒēŕēńţ ōńē",
"fallbackSettings": "Ƒàĺĺƀàćķ śēţţĩńĝś",
"fallbackSettingsSummaryAutomatic": "Àũţōḿàţĩć ƒàĺĺƀàćķ ēńàƀĺēď",
@@ -306,8 +304,8 @@
"fallbackSettingsSummaryStrict": "Ńō ƒàĺĺƀàćķ ćōńƒĩĝũŕēď",
"fallbackNoteLive": "Ţĥĩś śēśśĩōń śţàŕţēď ōń à ƒàĺĺƀàćķ ḿōďēĺ ƀēćàũśē ţĥē ćōńƒĩĝũŕēď ḿōďēĺ ŵàś ũńàvàĩĺàƀĺē. Ţĥē (ƒàĺĺƀàćķ) ḿàŕķēŕ ŕēƒĺēćţś ţĥē ĺĩvē śēśśĩōń àńď ĩś ńōţ śĥōŵń àƒţēŕ ŕēĺōàď.",
"modelFallbackSuffix": "(ƒàĺĺƀàćķ)",
+ "globalSecrets": "Ĝĺōƀàĺ Śēćŕēţś",
"health": "Ĥēàĺţĥ",
- "home": "Ĥōḿē",
"hostedUrl": "Ĥōśţēď ŨŔĹ",
"icons": "Ĩćōńś",
"increaseHeight": "Ĩńćŕēàśē ĥēĩĝĥţ",
@@ -366,7 +364,6 @@
"loading": "Ĺōàďĩńĝ...",
"loadingSecrets": "Ĺōàďĩńĝ śēćŕēţś...",
"logsPageDescription": "Ćŕēàţē à ďĩàĝńōśţĩć ŹĨƤ ŵĩţĥ ƒŕōńţēńď àńď ƀàćķēńď ĺōĝś.",
- "logsPageTitle": "Ĺōĝś",
"lspConfigCustomBadge": "ćũśţōḿ",
"lspInstallHintGo": "Ŕũńś \"{{command}}\". Ŕēqũĩŕēś Ĝō ţō ƀē ĩńśţàĺĺēď.",
"lspInstallHintKotlin": "Ĩńśţàĺĺ {{binary}} ḿàńũàĺĺŷ ōń ţĥē ţàśķ ĥōśţ'ś {{path}}. Ƒōŕ Ĺōćàĺ Ďōćķēŕ ţàśķś, ĩńśţàĺĺ ĩţ ĩńśĩďē ţĥē ţàśķ ćōńţàĩńēŕ.",
@@ -383,9 +380,19 @@
"manageApiKeysAndCredentialsSecrets": "Ḿàńàĝē ÀƤĨ ķēŷś àńď ćŕēďēńţĩàĺś. Śēćŕēţś àŕē ēńćŕŷƥţēď àţ ŕēśţ àńď ĩńĵēćţēď ĩńţō àĝēńţ ēńvĩŕōńḿēńţś vĩà ēxēćũţōŕ ƥŕōƒĩĺē ēńv vàŕś.",
"globalScope": "Ĝĺōƀàĺ",
"modelWithValue": "Ḿōďēĺ: {{model}}",
+ "settingsMenu": "Śēţţĩńĝś Ḿēńũ",
+ "settingsMenuAccordion": "Àććōŕďĩōń ţŕēē",
+ "settingsMenuAccordionDescription": "Ŵōŕķśƥàćēś, Àĝēńţś àńď Ēxēćũţōŕś ēxƥàńď ĩńţō ŵĥàţ ţĥēŷ ĥōĺď: à ŵōŕķśƥàćē ďōŵń ţō à śĩńĝĺē ĩńţēĝŕàţĩōń. Ōƥēńĩńĝ ōńē ƀŕàńćĥ ćĺōśēś ţĥē ōţĥēŕś, śō ţĥē ḿēńũ ōńĺŷ ēvēŕ śĥōŵś ţĥē ŵàŷ ţō ţĥē ƥàĝē ŷōũ àŕē ōń.",
+ "settingsMenuFlat": "Ƒĺàţ ĺĩśţ",
+ "settingsMenuFlatDescription": "Ōńē ŕōŵ ƥēŕ śēţţĩńĝś ƥàĝē, ńēvēŕ ḿōŕē. Ĩńďĩvĩďũàĺ ŵōŕķśƥàćēś, àĝēńţś àńď ēxēćũţōŕś àŕē ŕēàćĥēď ƒŕōḿ ţĥēĩŕ ƥàĝē. Ţĥē ďēƒàũĺţ, àńď ţĥē śĥōŕţēśţ ḿēńũ.",
+ "settingsMenuPersistent": "Ƥēŕśĩśţēńţ ţŕēē",
+ "settingsMenuPersistentDescription": "Ţĥē śàḿē ƀŕàńćĥēś, ƀũţ śēvēŕàĺ śţàŷ ōƥēń àţ ōńćē àńď àŕē śţĩĺĺ ōƥēń àƒţēŕ à ŕēĺōàď. Ćĥōōśē ţĥĩś ţō ķēēƥ à ƒēŵ ŕēćōŕďś śĩďē ƀŷ śĩďē; ŵĩţĥ ḿàńŷ ŵōŕķśƥàćēś ōŕ àĝēńţś ţĥē ḿēńũ ĝēţś ĺōńĝ.",
+ "settingsMenuShape": "Ḿēńũ Śĥàƥē",
+ "settingsMenuShapePerDevice": "Àƥƥĺĩēś ţō ţĥĩś ďēvĩćē ōńĺŷ. Ŷōũŕ ōţĥēŕ ďēvĩćēś àńď ƀŕōŵśēŕś ķēēƥ ţĥēĩŕ ōŵń ćĥōĩćē.",
"temperatureWithValue": "Ţēḿƥēŕàţũŕē: {{temperature}}",
"workspaceScope": "Ŵōŕķśƥàćē",
"workspaceSecretsDescription": "Ḿàńàĝē śēćŕēţś àvàĩĺàƀĺē ţō ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē. Śĥàŕēď àĝēńţ àńď ēxēćũţōŕ ƥŕōƒĩĺēś ćàń ũśē Ĝĺōƀàĺ śēćŕēţś ōńĺŷ.",
+ "workspacesAndAccess": "Ŵōŕķśƥàćēś & Àććēśś",
"revealSecretNamed": "Ŕēvēàĺ śēćŕēţ {{name}}",
"hideSecretNamed": "Ĥĩďē śēćŕēţ {{name}}",
"editSecretNamed": "Ēďĩţ śēćŕēţ {{name}}",
@@ -396,7 +403,6 @@
"mcpAffectedToolsHelp": "<0>ćŕēàţē_ţàśķ_ķàńďēv0> ćŕēàţēś à śēƥàŕàţē ţàśķ. <3>śƥàŵń_śēśśĩōń_ķàńďēv3> àďďś à śēśśĩōń ţō ţĥē ćũŕŕēńţ ţàśķ, śō ĩţ ďōēś ńōţ ũśē ţĥĩś ƥŕēƒēŕēńćē.",
"mcpCreateTaskScope": "<0>ćŕēàţē_ţàśķ_ķàńďēv0> ćŕēàţēś ńēŵ ţàśķś àńď śũƀţàśķś. Ţĥĩś śēţţĩńĝ àƥƥĺĩēś ōńĺŷ ŵĥēń ţĥē ćàĺĺ ōḿĩţś <4>àĝēńţ_ƥŕōƒĩĺē_ĩď4>.",
"mcpSpawnSessionScope": "<0>śƥàŵń_śēśśĩōń_ķàńďēv0> àńď ţàśķś ŷōũ ćŕēàţē ŷōũŕśēĺƒ àŕē ńōţ àƒƒēćţēď. Àń ēxƥĺĩćĩţĺŷ śēĺēćţēď ƥŕōƒĩĺē àĺŵàŷś ŵĩńś.",
- "mcpTaskDefaultsAndArchiveSafeguards": "ḾĆƤ ţàśķ ďēƒàũĺţś, àŕćĥĩvē śàƒēĝũàŕďś, àńď ţŕàńśćŕĩƥţ ƥŕēƒēŕēńćēś",
"mcpTaskProfileCurrentTask": "Ćũŕŕēńţ ţàśķ ƥŕōƒĩĺē",
"mcpTaskProfileCurrentTaskDescription": "Ţĥē ńēŵ ţàśķ ũśēś ţĥē śàḿē ƥŕōƒĩĺē àś ţĥē ţàśķ ţĥàţ ćŕēàţēď ĩţ. Ćĥōōśē ţĥĩś ŵĥēń ƒōĺĺōŵ-ũƥ ŵōŕķ ńēēďś ţĥē śàḿē ḿōďēĺ àńď àĝēńţ śēţũƥ. Ţĥĩś ḿàŷ ŕēũśē à ḿōŕē ēxƥēńśĩvē ƥŕōƒĩĺē.",
"mcpTaskProfileWorkspaceDefault": "Ŵōŕķśƥàćē ďēƒàũĺţ ƥŕōƒĩĺē",
@@ -465,8 +471,6 @@
"notifyWhenThisEventOccurs": "Ńōţĩƒŷ ŵĥēń ţĥĩś ēvēńţ ōććũŕś.",
"openKandevTo": "Ōƥēń Ķàńďēv ţō",
"openLinksIn": "Ōƥēń ĺĩńķś ĩń",
- "openSettingsMenu": "Ōƥēń śēţţĩńĝś ḿēńũ",
- "openSettingsMenuWithConnectionIssue": "{{description}} Ōƥēń śēţţĩńĝś ḿēńũ",
"opensAVsCodeRemoteSsh": "Ōƥēńś à VŚ Ćōďē Ŕēḿōţē ŚŚĤ ŨŔĹ ũśĩńĝ ţĥē śćĥēḿē (ēxàḿƥĺē: {{example}}).",
"optionalScheme": "ōƥţĩōńàĺ śćĥēḿē ({{schemes}})",
"optionalUsername": "ōƥţĩōńàĺ ũśēŕńàḿē",
@@ -476,6 +480,7 @@
"pinTheAgentsLiveTodoChecklistAs": "Ƥĩń ţĥē àĝēńţ'ś ĺĩvē ţōďō ćĥēćķĺĩśţ àś à ţàƀ ĩń ţĥē ŕĩĝĥţ ƥàńēĺ.",
"pluginShortcuts": "Ƥĺũĝĩń Śĥōŕţćũţś",
"preferredShell": "Ƥŕēƒēŕŕēď Śĥēĺĺ",
+ "preferences": "Ƥŕēƒēŕēńćēś",
"pressAKeyCombo": "Ƥŕēśś à ķēŷ ćōḿƀō...",
"pressCmdCtrlEnterToSend": "Ƥŕēśś Ćḿď/Ćţŕĺ+Ēńţēŕ ţō śēńď ḿēśśàĝēś. Ƥŕēśś Ēńţēŕ ƒōŕ ńēŵĺĩńēś.",
"pressEnterToSendMessagesPress": "Ƥŕēśś Ēńţēŕ ţō śēńď ḿēśśàĝēś. Ƥŕēśś Śĥĩƒţ+Ēńţēŕ ƒōŕ ńēŵĺĩńēś.",
@@ -500,7 +505,6 @@
"promptsLoading": "Ĺōàďĩńĝ ƥŕōḿƥţś…",
"promptsPageDescription": "Ćŕēàţē ŕēũśàƀĺē ƥŕōḿƥţ śńĩƥƥēţś ƒōŕ ţĥē ćĥàţ ĩńƥũţ.",
"providerName": "Ƥŕōvĩďēŕ ńàḿē",
- "providersAndNotificationEvents": "Ƥŕōvĩďēŕś àńď ńōţĩƒĩćàţĩōń ēvēńţś",
"pxRange": "ƥx (8-24)",
"refreshNotificationPermission": "Ŕēƒŕēśĥ ńōţĩƒĩćàţĩōń ƥēŕḿĩśśĩōń",
"refreshPermissionStatus": "Ŕēƒŕēśĥ ƥēŕḿĩśśĩōń śţàţũś",
@@ -542,7 +546,6 @@
"settingsSearchResults_one": "{{count}} ḿàţćĥĩńĝ śēţţĩńĝ",
"settingsSearchResults_other": "{{count}} ḿàţćĥĩńĝ śēţţĩńĝś",
"shellOptionsUnavailable": "Śĥēĺĺ ōƥţĩōńś ũńàvàĩĺàƀĺē",
- "shellTerminalFontsAndLinkBehavior": "Śĥēĺĺ, ţēŕḿĩńàĺ ƒōńţś, àńď ĺĩńķ ƀēĥàvĩōŕ",
"showAgentTodoListPanel": "Śĥōŵ àĝēńţ ţōďō ĺĩśţ ƥàńēĺ",
"showAnchoredPromptBar": "Śĥōŵ àńćĥōŕēď ƥŕōḿƥţ ƀàŕ",
"showCleanupDetailsAndSubtaskOptions": "Śĥōŵ ćĺēàńũƥ ďēţàĩĺś àńď śũƀţàśķ ōƥţĩōńś ƀēƒōŕē àń àŕćĥĩvē śţàŕţś.",
@@ -593,23 +596,22 @@
"spriteHealthStopping": "Śţōƥƥĩńĝ",
"spriteHealthUnhealthy": "Ũńĥēàĺţĥŷ",
"spriteHealthUnknown": "Ũńķńōŵń",
- "sprites": "Śƥŕĩţēś",
"spritesDevProvidesEphemeralCloudSandboxes": "Śƥŕĩţēś.ďēv ƥŕōvĩďēś ēƥĥēḿēŕàĺ ćĺōũď śàńďƀōxēś ƒōŕ ŕũńńĩńĝ àĝēńţś ŕēḿōţēĺŷ.",
"startOnYourSavedKanbanPipelineOrList": "Śţàŕţ ōń ŷōũŕ śàvēď Ķàńƀàń, Ƥĩƥēĺĩńē, ōŕ Ĺĩśţ vĩēŵ.",
"startupPage": "Śţàŕţũƥ Ƥàĝē",
- "statusWithConnectionIssue": "Śţàţũś. {{description}}",
"submitShortcut": "Śũƀḿĩţ Śĥōŕţćũţ",
"supportedEditors": "Śũƥƥōŕţēď Ēďĩţōŕś",
"supportsPlaceholders": "Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś: {{placeholders}}",
"systemDefault": "Śŷśţēḿ ďēƒàũĺţ",
"systemLoad1Min": "Śŷśţēḿ ĺōàď (1 ḿĩń)",
"taskActions": "Ţàśķ Àćţĩōńś",
+ "taskBehavior": "Ţàśķ Ɓēĥàvĩōŕ",
"taskOverview": "Ţàśķ ōvēŕvĩēŵ",
- "taskWorkbenchLayoutProfilesAndDefaults": "Ţàśķ ŵōŕķƀēńćĥ ĺàŷōũţ ƥŕōƒĩĺēś àńď ďēƒàũĺţś",
"startModelUnavailable": "Ńō ĺōńĝēŕ àvàĩĺàƀĺē; śēĺēćţ à ďĩƒƒēŕēńţ ḿōďēĺ",
"startModelAria": "Ƥŕōƒĩĺē śţàŕţ ḿōďēĺ śēţţĩńĝś",
"terminal": "Ţēŕḿĩńàĺ",
"terminalFont": "Ţēŕḿĩńàĺ Ƒōńţ",
+ "terminalAndEditors": "Ţēŕḿĩńàĺ & Ēďĩţōŕś",
"terminalFontSize": "Ţēŕḿĩńàĺ Ƒōńţ Śĩźē",
"terminalFontSizeMustBeA": "Ţēŕḿĩńàĺ ƒōńţ śĩźē ḿũśţ ƀē à ńũḿƀēŕ ƀēţŵēēń 8 àńď 24.",
"terminalLinks": "Ţēŕḿĩńàĺ Ĺĩńķś",
@@ -617,7 +619,6 @@
"testNotificationBody": "Ĩƒ ŷōũ ćàń ŕēàď ţĥĩś, ƀŕōŵśēŕ ńōţĩƒĩćàţĩōńś àŕē ŵōŕķĩńĝ.",
"testNotificationTitle": "Ţēśţ ńōţĩƒĩćàţĩōń",
"theBuiltInDefaultLayoutWill": "Ţĥē ƀũĩĺţ-ĩń Ďēƒàũĺţ ĺàŷōũţ ŵĩĺĺ ƀēćōḿē ţĥē ďēƒàũĺţ.",
- "themeMetricsAndChangesPanelPreferences": "Ţĥēḿē, śţàŕţũƥ ƥàĝē, ḿēţŕĩćś, àńď ćĥàńĝēś ƥàńēĺ ƥŕēƒēŕēńćēś",
"thisAppliesWhenKandevStarts": "Ţĥĩś àƥƥĺĩēś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ ōƥēń ĩţś ƀàŕē ĥōḿē ƥàĝē. Ĥōḿē, Ɓàćķ, àńď ŵōŕķƒĺōŵ ńàvĩĝàţĩōń àĺŵàŷś ōƥēń ţĥē ţàśķ ōvēŕvĩēŵ.",
"thisLayoutIsUsedAsThe": "Ţĥĩś ĺàŷōũţ ĩś ũśēď àś ţĥē śţàŕţĩńĝ ĺàŷōũţ ƒōŕ ńēŵ ţàśķś.",
"thisPageHasUnsavedChangesSave": "Ţĥĩś ƥàĝē ĥàś ũńśàvēď ćĥàńĝēś. Śàvē ţĥēḿ, ďĩśćàŕď ţĥēḿ, ōŕ ćōńţĩńũē ēďĩţĩńĝ.",
diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json
index 9a6d8cd477..6a92990188 100644
--- a/apps/web/src/locales/pseudo/sidebar.json
+++ b/apps/web/src/locales/pseudo/sidebar.json
@@ -1,6 +1,7 @@
{
- "account": "Àććōũńţ",
"archivedLoadFailed": "Àŕćĥĩvēď ţàśķś ćōũĺď ńōţ ƀē ĺōàďēď",
+ "collapseAllBranches": "Ćōĺĺàƥśē àĺĺ",
+ "orgChart": "Ōŕĝ Ćĥàŕţ",
"retry": "Ŕēţŕŷ",
"activeWorkspaceBadge": "Àćţĩvē",
"activity": "Àćţĩvĩţŷ",
diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json
index a167252fcf..2b99ff36b0 100644
--- a/apps/web/src/locales/pseudo/system.json
+++ b/apps/web/src/locales/pseudo/system.json
@@ -57,6 +57,7 @@
"databaseOptimize": "Ōƥţĩḿĩźē",
"databaseOptimizeHelp": "Àśķś ţĥē ďàţàƀàśē ţō ŕēƒŕēśĥ ĩţś ĩńţēŕńàĺ ĩńďēxēś śō ćōḿḿōń qũēŕĩēś śţàŷ ƒàśţ. Ŕũń ĩţ àƒţēŕ ďēĺēţĩńĝ à ĺōţ ōƒ ďàţà ōŕ ĩƒ ţĥē àƥƥ ƒēēĺś śĺũĝĝĩśĥ. Qũĩćķ àńď śàƒē - ńō ďàţà ĩś ćĥàńĝēď.",
"databasePageDescription": "Ďàţàƀàśē ďŕĩvēŕ, śĩźē, àńď àvàĩĺàƀĺē ḿàĩńţēńàńćē ćōńţŕōĺś.",
+ "dataStoragePageDescription": "Ďàţàƀàśē śţàţĩśţĩćś, ƀàćķũƥś, ďĩśķ ćĺēàńũƥ, àńď śēŕvēŕ ĺōĝś.",
"databasePath": "Ƥàţĥ",
"databaseRunOptimize": "Ŕũń ōƥţĩḿĩźē",
"databaseRunVacuum": "Ŕũń vàćũũḿ",
@@ -195,10 +196,10 @@
"navAbout": "Àƀōũţ",
"navBackups": "Ɓàćķũƥś",
"navDatabase": "Ďàţàƀàśē",
+ "navDataStorage": "Ďàţà & Ĺōĝś",
"navFeatureToggles": "Ƒēàţũŕē Ţōĝĝĺēś",
"navLicenses": "Ĺĩćēńśēś",
"navLogs": "Ĺōĝś",
- "navStorage": "Śţōŕàĝē",
"navUpdates": "Ũƥďàţēś",
"navUsers": "Ũśēŕś",
"reloadPage": "Ŕēĺōàď ƥàĝē",
diff --git a/apps/web/src/locales/pseudo/tasks.json b/apps/web/src/locales/pseudo/tasks.json
index dd26f5cba3..e693ee8078 100644
--- a/apps/web/src/locales/pseudo/tasks.json
+++ b/apps/web/src/locales/pseudo/tasks.json
@@ -47,8 +47,8 @@
"taskArchivedDescription": "Ţĥē ţàśķ ĥàś ƀēēń àŕćĥĩvēď śũććēśśƒũĺĺŷ.",
"taskUnarchived": "Ţàśķ ũńàŕćĥĩvēď",
"unarchive": "Ũńàŕćĥĩvē",
- "unarchivedBranchesMissing_one": "Ɓŕàńćĥ {{branches}} ńō ĺōńĝēŕ ēxĩśţś ĺōćàĺĺŷ ōŕ ōń ţĥē ŕēḿōţē. ţĥē ńēxţ śēśśĩōń śţàŕţś ƒŕēśĥ ƒŕōḿ ţĥē ƀàśē ƀŕàńćĥ.",
- "unarchivedBranchesMissing_other": "Ɓŕàńćĥēś {{branches}} ńō ĺōńĝēŕ ēxĩśţ ĺōćàĺĺŷ ōŕ ōń ţĥē ŕēḿōţē. ţĥē ńēxţ śēśśĩōń śţàŕţś ƒŕēśĥ ƒŕōḿ ţĥē ƀàśē ƀŕàńćĥ.",
+ "unarchivedBranchesMissing_one": "Ɓŕàńćĥ {{branches}} ńō ĺōńĝēŕ ēxĩśţś ĺōćàĺĺŷ ōŕ ōń ţĥē ŕēḿōţē. Ţĥē ńēxţ śēśśĩōń śţàŕţś ƒŕēśĥ ƒŕōḿ ţĥē ƀàśē ƀŕàńćĥ.",
+ "unarchivedBranchesMissing_other": "Ɓŕàńćĥēś {{branches}} ńō ĺōńĝēŕ ēxĩśţ ĺōćàĺĺŷ ōŕ ōń ţĥē ŕēḿōţē. Ţĥē ńēxţ śēśśĩōń śţàŕţś ƒŕēśĥ ƒŕōḿ ţĥē ƀàśē ƀŕàńćĥ.",
"unarchivedTaskRestored": "Ţĥē ţàśķ ĥàś ƀēēń ŕēśţōŕēď.",
"unarchiveTask": "Ũńàŕćĥĩvē ţàśķ"
}
diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json
index e886a52978..0e3ee7ed43 100644
--- a/apps/web/src/locales/pseudo/workflows.json
+++ b/apps/web/src/locales/pseudo/workflows.json
@@ -99,7 +99,6 @@
"lastAttempt": "ĺàśţ àţţēḿƥţ {{when}}",
"lastSynced": "ĺàśţ śŷńćēď {{when}}",
"loadingWorkflowSteps": "Ĺōàďĩńĝ ŵōŕķƒĺōŵ śţēƥś...",
- "manageWorkflowsForThisWorkspace": "Ḿàńàĝē ŵōŕķƒĺōŵś ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
"migrateAndDelete": "Ḿĩĝŕàţē & Ďēĺēţē",
"migrateAndDeleteStep": "Ḿĩĝŕàţē & Ďēĺēţē Śţēƥ",
"migrating": "Ḿĩĝŕàţĩńĝ...",
@@ -255,6 +254,5 @@
"workflows": "Ŵōŕķƒĺōŵś",
"workflowsSectionDescription": "Ćŕēàţē àũţōńōḿōũś ƥĩƥēĺĩńēś ŵĩţĥ àũţōḿàţēď ţŕàńśĩţĩōńś ōŕ ḿàńũàĺ ŵōŕķƒĺōŵś ŵĥēŕē ŷōũ ḿōvē ţàśķś ŷōũŕśēĺƒ",
"workflowSteps": "Ŵōŕķƒĺōŵ Śţēƥś",
- "workflowSyncCompleted": "Ŵōŕķƒĺōŵ śŷńć ćōḿƥĺēţēď",
- "workspaceSettings": "Ŵōŕķśƥàćē śēţţĩńĝś"
+ "workflowSyncCompleted": "Ŵōŕķƒĺōŵ śŷńć ćōḿƥĺēţēď"
}
diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json
index db2dfe86e7..fdf97a1047 100644
--- a/apps/web/src/locales/pseudo/workspaces.json
+++ b/apps/web/src/locales/pseudo/workspaces.json
@@ -76,13 +76,13 @@
"invalidRepositoryPath": "Ĩńvàĺĩď ŕēƥōśĩţōŕŷ ƥàţĥ",
"localPath": "Ĺōćàĺ Ƥàţĥ",
"localPathNotSet": "Ĺōćàĺ ƥàţĥ ńōţ śēţ",
- "manageRepositoriesConnected": "Ḿàńàĝē ŕēƥōśĩţōŕĩēś ćōńńēćţēď ţō ţĥĩś ŵōŕķśƥàćē.",
"repositoriesReadOnlyImprove": "Ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē àŕē ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv àńď ćàńńōţ ƀē ćĥàńĝēď.",
"manageWorkspaceDetails": "Ḿàńàĝē ŵōŕķśƥàćē ďēţàĩĺś àńď ĵũḿƥ ĩńţō ŵōŕķƒĺōŵś ōŕ ŕēƥōśĩţōŕĩēś.",
"manageYourWorkspacesAndWorkflows": "Ḿàńàĝē ŷōũŕ ŵōŕķśƥàćēś àńď ŵōŕķƒĺōŵś",
"manualPath": "Ḿàńũàĺ ƥàţĥ",
"name": "Ńàḿē",
"noAgentProfilesAvailable": "Ńō àĝēńţ ƥŕōƒĩĺēś àvàĩĺàƀĺē",
+ "overview": "Ōvēŕvĩēŵ",
"noCustomScripts": "Ńō ćũśţōḿ śćŕĩƥţś",
"noDefault": "Ńō ďēƒàũĺţ",
"noExecutorsAvailable": "Ńō ēxēćũţōŕś àvàĩĺàƀĺē",
@@ -92,6 +92,8 @@
"patternSyntax": "Ƥàţţēŕń śŷńţàx",
"remoteRepository": "Ŕēḿōţē ŕēƥōśĩţōŕŷ",
"repositories": "Ŕēƥōśĩţōŕĩēś",
+ "resourceCount_one": "{{count}} ŕēśōũŕćē",
+ "resourceCount_other": "{{count}} ŕēśōũŕćēś",
"repositoriesInThisWorkspace": "Ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē",
"repository": "Ŕēƥōśĩţōŕŷ",
"repositoryName": "Ŕēƥōśĩţōŕŷ Ńàḿē",
@@ -110,15 +112,11 @@
"useRepository": "Ũśē Ŕēƥōśĩţōŕŷ",
"validGitRepository": "Vàĺĩď ĝĩţ ŕēƥōśĩţōŕŷ",
"validate": "Vàĺĩďàţē",
- "workflows": "Ŵōŕķƒĺōŵś",
- "workflowsManagedInThisWorkspace": "Ŵōŕķƒĺōŵś ḿàńàĝēď ĩń ţĥĩś ŵōŕķśƥàćē",
- "workspaceLinks": "Ŵōŕķśƥàćē Ĺĩńķś",
"workspaceName": "Ŵōŕķśƥàćē Ńàḿē",
"workspaceNameIsRequired": "Ŵōŕķśƥàćē ńàḿē ĩś ŕēqũĩŕēď.",
"workspaceNamePlaceholder": "Ḿŷ Ŵōŕķśƥàćē",
"workspaceNotFound": "Ŵōŕķśƥàćē ńōţ ƒōũńď",
"workspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś",
- "workspaceSettingsLink": "Ŵōŕķśƥàćē śēţţĩńĝś",
"worktreeBranchTemplate": "Ŵōŕķţŕēē Ɓŕàńćĥ Ţēḿƥĺàţē",
"worktreeSync": "Ŵōŕķţŕēē Śŷńć"
}
diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json
index 2c9395204a..100d0d1a30 100644
--- a/apps/web/src/locales/pt-pt/agents.json
+++ b/apps/web/src/locales/pt-pt/agents.json
@@ -10,8 +10,6 @@
"agentInstallationInProgress": "Está em curso a instalação do agente",
"agentNotFound": "Agente não encontrado.",
"agentProfileSettings": "Definições do perfil {{name}}",
- "agentProfiles": "Perfis de agente",
- "agentProfilesDescription": "Gerir os perfis existentes por agente.",
"agentProfilesTitle": "Perfis de {{name}}",
"approveUpdate": "Aprovar a atualização",
"authRequiredOpenLoginTerminal": "É necessária autenticação - clique para abrir o terminal de início de sessão",
@@ -22,7 +20,6 @@
"availableCommandsCount_other": "Comandos disponíveis ({{count}})",
"availableSlashCommands": "Comandos slash disponíveis",
"availableSlashCommandsHelp": "Escreva-os durante o chat de uma sessão para os invocar - ex.: <0>{{example}}0>.",
- "availableToInstall": "Disponíveis para instalar",
"availableToInstallDescription": "Clique em Instalar para executar o script de instalação do agente no anfitrião do kandev. O progresso é transmitido em direto; pode iniciar várias instalações em paralelo.",
"backToAgents": "Voltar a Agentes",
"backToModelSettings": "Voltar às definições de modelo a partir de {{name}}",
@@ -62,7 +59,6 @@
"copyToClipboard": "Copiar para a área de transferência",
"create": "Criar",
"createAgentProfileTitle": "Criar perfil de {{name}}",
- "createNewProfile": "Criar um novo perfil",
"createProfileIntro": "Crie um novo perfil para este agente.",
"creating": "A criar...",
"defaultProfile": "Perfil pré-definido",
@@ -79,7 +75,6 @@
"details": "detalhes",
"detectedAt": "Detetado em {{path}}",
"disableProfile": "Desativar perfil",
- "disableProfileNamed": "Desativar {{name}}",
"disableSetting": "Desativar {{name}}",
"displayName": "Nome a apresentar",
"displayNameRequired": "O nome a apresentar é obrigatório",
@@ -87,7 +82,6 @@
"enableMcp": "Ativar o MCP",
"enableMcpDescription": "Permitir que este perfil use servidores MCP durante as sessões.",
"enableProfile": "Ativar perfil",
- "enableProfileNamed": "Ativar {{name}}",
"enableSetting": "Ativar {{name}}",
"enabled": "Ativado",
"enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil",
@@ -113,7 +107,6 @@
"fixInvalidMcpConfig": "Corrija a configuração MCP inválida antes de guardar.",
"fixInvalidMcpJson": "Corrija o JSON de MCP inválido antes de guardar.",
"flag": "Flag",
- "goToAgents": "Ir para Agentes",
"hostTerminal": "Terminal do anfitrião",
"hostTerminalDescription": "Executa uma shell no anfitrião do kandev. Use-a para instalar ou configurar agentes manualmente.",
"install": "Instalar",
@@ -127,7 +120,6 @@
"invalidMcpConfig": "Configuração MCP inválida",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "Ferramentas: {{tools}}",
- "manageAgentsFromMainPage": "Faça a gestão dos agentes a partir da página principal de Agentes.",
"mcpConfigMustBeJsonObject": "A configuração dos servidores MCP tem de ser um objeto JSON",
"mcpConfiguration": "Configuração MCP",
"mcpDraftHint": "A configuração MCP será aplicada depois de o perfil ser guardado.",
diff --git a/apps/web/src/locales/pt-pt/automations.json b/apps/web/src/locales/pt-pt/automations.json
index fe853c5087..42c4e62474 100644
--- a/apps/web/src/locales/pt-pt/automations.json
+++ b/apps/web/src/locales/pt-pt/automations.json
@@ -37,7 +37,6 @@
"couldNotRefreshRuns": "Não foi possível atualizar as execuções; repostas a partir da cache local",
"couldNotTrigger": "Não foi possível acionar a automatização",
"createAnAutomation": "Criar uma automatização",
- "createWorkspace": "Criar área de trabalho",
"cronEveryDay": "Todos os dias",
"cronEveryHour": "Todas as horas",
"cronEveryWeek": "Todas as semanas",
@@ -91,14 +90,11 @@
"noTimezoneFound": "Nenhum fuso horário encontrado.",
"noTriggers": "Nenhum",
"noWorkflow": "Sem fluxo de trabalho",
- "noWorkspacesDescription": "Crie primeiro uma área de trabalho e depois agende automatizações dentro dela.",
- "noWorkspacesYet": "Ainda não há áreas de trabalho",
"nothingHasRunYet": "Ainda não foi executado nada.",
"openAutomations": "Abrir automatizações",
"orUseWebhookInstead": "Ou use antes um webhook",
"outcome": "Resultado",
"pickAWorkflowFirst": "Escolha primeiro um fluxo de trabalho",
- "pickWorkspace": "Escolha uma área de trabalho para gerir as suas automatizações.",
"prEventOpened": "Aberto",
"prEventOpenedTooltip": "Dispara uma vez quando é detetado um novo PR aberto. Cada número de PR é desduplicado; o mesmo PR não volta a acionar.",
"prTriggerRepositoryHelp": "Os acionadores de PR usam sempre o repositório do próprio PR.",
diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json
index c2f1bd3e57..c092c9deb8 100644
--- a/apps/web/src/locales/pt-pt/settings.json
+++ b/apps/web/src/locales/pt-pt/settings.json
@@ -62,7 +62,6 @@
"changesPanelLayout": "Esquema do painel de alterações",
"changesTheLanguageOfTheKandev": "Altera o idioma da interface do Kandev. Aplica-se de imediato e fica memorizado neste dispositivo. Os títulos das tarefas, o chat e outros conteúdos que criar não são traduzidos.",
"chatInput": "Campo de chat",
- "chatInputAndCommandShortcuts": "Campo de chat e atalhos de comandos",
"chooseAMonospaceFontForThe": "Escolha um tipo de letra monoespaçado para o terminal. As Nerd Fonts incluem ícones para ferramentas de CLI.",
"chooseTheLanguageUsedAcrossThe": "Escolha o idioma usado em toda a interface do Kandev",
"chooseWhatOpensWhenKandevStarts": "Escolha o que abre quando o Kandev arranca ou quando visita a sua página inicial simples",
@@ -214,7 +213,6 @@
"edit": "Editar",
"editEditor": "Editar {{name}}",
"editSecret": "Editar segredo",
- "editorIntegrationsAndDefaults": "Integrações e pré-definições do editor",
"editorName": "Nome do editor",
"editorType": "Tipo de editor",
"editors": "Editores",
@@ -305,7 +303,6 @@
"fontFamily": "Tipo de letra",
"fontSize": "Tamanho da letra",
"frequencySeconds": "Frequência (segundos)",
- "general": "Geral",
"health": "Saúde",
"home": "Início",
"hostedUrl": "URL alojado",
@@ -366,7 +363,6 @@
"loading": "A carregar...",
"loadingSecrets": "A carregar os segredos...",
"logsPageDescription": "Crie um ZIP de diagnóstico com os registos do frontend e do backend.",
- "logsPageTitle": "Registos",
"lspConfigCustomBadge": "personalizado",
"lspInstallHintGo": "Executa \"{{command}}\". Requer que o Go esteja instalado.",
"lspInstallHintKotlin": "Instale {{binary}} manualmente no {{path}} do anfitrião da tarefa. Nas tarefas com Docker local, instale-o dentro do contentor da tarefa.",
@@ -403,7 +399,6 @@
"mcpAffectedToolsHelp": "<0>create_task_kandev0> cria uma tarefa separada. <3>spawn_session_kandev3> adiciona uma sessão à tarefa atual, pelo que não usa esta preferência.",
"mcpCreateTaskScope": "<0>create_task_kandev0> cria novas tarefas e subtarefas. Esta definição aplica-se apenas quando a chamada omite <4>agent_profile_id4>.",
"mcpSpawnSessionScope": "<0>spawn_session_kandev0> e as tarefas que criar não são afetados. Um perfil explicitamente selecionado prevalece sempre.",
- "mcpTaskDefaultsAndArchiveSafeguards": "Pré-definições de tarefas MCP, salvaguardas de arquivo e preferências de transcrição",
"mcpTaskProfileCurrentTask": "Perfil da tarefa atual",
"mcpTaskProfileCurrentTaskDescription": "A nova tarefa usa o mesmo perfil da tarefa que a criou. Escolha esta opção quando o trabalho de seguimento precisar do mesmo modelo e da mesma configuração de agente. Isto pode reutilizar um perfil mais dispendioso.",
"mcpTaskProfileWorkspaceDefault": "Perfil pré-definido da área de trabalho",
@@ -504,7 +499,6 @@
"promptsLoading": "A carregar os prompts…",
"promptsPageDescription": "Crie excertos de prompt reutilizáveis para o campo de chat.",
"providerName": "Nome do fornecedor",
- "providersAndNotificationEvents": "Fornecedores e eventos de notificação",
"pxRange": "px (8-24)",
"refreshNotificationPermission": "Atualizar a permissão de notificações",
"refreshPermissionStatus": "Atualizar o estado da permissão",
@@ -545,7 +539,6 @@
"settingsSearchResults_one": "{{count}} definição correspondente",
"settingsSearchResults_other": "{{count}} definições correspondentes",
"shellOptionsUnavailable": "Opções de shell indisponíveis",
- "shellTerminalFontsAndLinkBehavior": "Shell, tipos de letra do terminal e comportamento das ligações",
"showAgentTodoListPanel": "Mostrar o painel da lista de tarefas por fazer do agente",
"showAnchoredPromptBar": "Mostrar a barra de prompt fixa",
"showCleanupDetailsAndSubtaskOptions": "Mostrar os detalhes da limpeza e as opções de subtarefas antes de iniciar um arquivo.",
@@ -596,7 +589,6 @@
"spriteHealthStopping": "A parar",
"spriteHealthUnhealthy": "Não saudável",
"spriteHealthUnknown": "Desconhecido",
- "sprites": "Sprites",
"spritesDevProvidesEphemeralCloudSandboxes": "O Sprites.dev disponibiliza sandboxes efémeras na nuvem para executar agentes remotamente.",
"startOnYourSavedKanbanPipelineOrList": "Começar na sua vista Kanban, Pipeline ou Lista guardada.",
"startupPage": "Página de arranque",
@@ -608,7 +600,6 @@
"systemLoad1Min": "Carga do sistema (1 min)",
"taskActions": "Ações da tarefa",
"taskOverview": "Visão geral da tarefa",
- "taskWorkbenchLayoutProfilesAndDefaults": "Perfis de esquema e pré-definições da bancada de tarefas",
"terminal": "Terminal",
"terminalFont": "Tipo de letra do terminal",
"terminalFontSize": "Tamanho da letra do terminal",
@@ -618,7 +609,6 @@
"testNotificationBody": "Se consegue ler isto, as notificações do navegador estão a funcionar.",
"testNotificationTitle": "Notificação de teste",
"theBuiltInDefaultLayoutWill": "O esquema Pré-definido incorporado passa a ser a pré-definição.",
- "themeMetricsAndChangesPanelPreferences": "Tema, página de arranque, métricas e preferências do painel de alterações",
"thisAppliesWhenKandevStarts": "Isto aplica-se quando o Kandev arranca ou quando abre a sua página inicial simples. O Início, o Voltar e a navegação pelos fluxos de trabalho abrem sempre a visão geral da tarefa.",
"thisLayoutIsUsedAsThe": "Este esquema é usado como esquema inicial das novas tarefas.",
"thisPageHasUnsavedChangesSave": "Esta página tem alterações por guardar. Guarde-as, descarte-as ou continue a editar.",
diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json
index 34ac2afc08..dc407021ca 100644
--- a/apps/web/src/locales/pt-pt/sidebar.json
+++ b/apps/web/src/locales/pt-pt/sidebar.json
@@ -1,5 +1,4 @@
{
- "account": "Conta",
"archivedLoadFailed": "Não foi possível carregar as tarefas arquivadas",
"retry": "Tentar novamente",
"activeWorkspaceBadge": "Ativa",
diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json
index f6be67ef10..bce967a89d 100644
--- a/apps/web/src/locales/pt-pt/system.json
+++ b/apps/web/src/locales/pt-pt/system.json
@@ -198,7 +198,6 @@
"navFeatureToggles": "Interruptores de funcionalidades",
"navLicenses": "Licenças",
"navLogs": "Registos",
- "navStorage": "Armazenamento",
"navUpdates": "Atualizações",
"navUsers": "Utilizadores",
"reloadPage": "Recarregar a página",
diff --git a/apps/web/src/locales/pt-pt/workspaces.json b/apps/web/src/locales/pt-pt/workspaces.json
index a1f82e987e..06afab05c4 100644
--- a/apps/web/src/locales/pt-pt/workspaces.json
+++ b/apps/web/src/locales/pt-pt/workspaces.json
@@ -76,7 +76,6 @@
"invalidRepositoryPath": "Caminho de repositório inválido",
"localPath": "Caminho local",
"localPathNotSet": "Caminho local por definir",
- "manageRepositoriesConnected": "Faça a gestão dos repositórios ligados a esta área de trabalho.",
"repositoriesReadOnlyImprove": "Os repositórios desta área de trabalho são geridos pelo Melhorar o Kandev e não podem ser alterados.",
"manageWorkspaceDetails": "Faça a gestão dos detalhes da área de trabalho e salte para fluxos de trabalho ou repositórios.",
"manageYourWorkspacesAndWorkflows": "Faça a gestão das suas áreas de trabalho e fluxos de trabalho",
@@ -110,9 +109,6 @@
"useRepository": "Usar repositório",
"validGitRepository": "Repositório Git válido",
"validate": "Validar",
- "workflows": "Fluxos de trabalho",
- "workflowsManagedInThisWorkspace": "Fluxos de trabalho geridos nesta área de trabalho",
- "workspaceLinks": "Ligações da área de trabalho",
"workspaceName": "Nome da área de trabalho",
"workspaceNameIsRequired": "O nome da área de trabalho é obrigatório.",
"workspaceNamePlaceholder": "A minha área de trabalho",
diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json
index 8fac4cd4a7..6be4f9c82d 100644
--- a/apps/web/src/locales/zh-cn/agents.json
+++ b/apps/web/src/locales/zh-cn/agents.json
@@ -10,8 +10,6 @@
"agentInstallationInProgress": "正在安装代理",
"agentNotFound": "未找到代理。",
"agentProfileSettings": "{{name}} 配置设置",
- "agentProfiles": "代理配置",
- "agentProfilesDescription": "按代理管理现有配置。",
"agentProfilesTitle": "{{name}} 配置",
"approveUpdate": "批准更新",
"authRequiredOpenLoginTerminal": "需要身份验证; 点击打开登录终端",
@@ -62,7 +60,6 @@
"copyToClipboard": "复制到剪贴板",
"create": "创建",
"createAgentProfileTitle": "创建 {{name}} 配置",
- "createNewProfile": "创建新配置",
"createProfileIntro": "为此代理创建新配置。",
"creating": "正在创建…",
"defaultProfile": "默认配置",
@@ -79,7 +76,6 @@
"details": "详情",
"detectedAt": "检测路径:{{path}}",
"disableProfile": "禁用配置文件",
- "disableProfileNamed": "禁用 {{name}}",
"disableSetting": "禁用 {{name}}",
"displayName": "显示名称",
"displayNameRequired": "显示名称为必填项",
@@ -87,7 +83,6 @@
"enableMcp": "启用 MCP",
"enableMcpDescription": "允许此配置在会话中使用 MCP 服务器。",
"enableProfile": "启用配置文件",
- "enableProfileNamed": "启用 {{name}}",
"enableSetting": "启用 {{name}}",
"enabled": "已启用",
"enabledProfileInfo": "关于配置文件可用性的更多信息",
@@ -113,7 +108,6 @@
"fixInvalidMcpConfig": "请先修复无效的 MCP 配置再保存。",
"fixInvalidMcpJson": "请先修复无效的 MCP JSON 再保存。",
"flag": "标志",
- "goToAgents": "前往代理",
"hostTerminal": "主机终端",
"hostTerminalDescription": "在 kandev 主机上运行 shell。可用于手动安装或配置代理。",
"install": "安装",
@@ -127,7 +121,6 @@
"invalidMcpConfig": "无效的 MCP 配置",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "工具:{{tools}}",
- "manageAgentsFromMainPage": "请从代理主页管理代理。",
"mcpConfigMustBeJsonObject": "MCP 服务器配置必须是 JSON 对象",
"mcpConfiguration": "MCP 配置",
"mcpDraftHint": "配置保存后将应用 MCP 设置。",
diff --git a/apps/web/src/locales/zh-cn/automations.json b/apps/web/src/locales/zh-cn/automations.json
index 769b1f6f91..829fe83634 100644
--- a/apps/web/src/locales/zh-cn/automations.json
+++ b/apps/web/src/locales/zh-cn/automations.json
@@ -37,7 +37,6 @@
"couldNotRefreshRuns": "无法刷新运行记录; 已从本地缓存恢复",
"couldNotTrigger": "无法触发此自动化",
"createAnAutomation": "创建自动化",
- "createWorkspace": "创建工作区",
"cronEveryDay": "每天",
"cronEveryHour": "每小时",
"cronEveryWeek": "每周",
@@ -91,14 +90,11 @@
"noTimezoneFound": "未找到时区。",
"noTriggers": "无",
"noWorkflow": "无工作流",
- "noWorkspacesDescription": "请先创建工作区,然后在其中安排自动化。",
- "noWorkspacesYet": "还没有工作区",
"nothingHasRunYet": "尚未运行任何内容。",
"openAutomations": "打开自动化",
"orUseWebhookInstead": "或者改用 webhook",
"outcome": "结果",
"pickAWorkflowFirst": "请先选择工作流",
- "pickWorkspace": "选择一个工作区来管理其中的自动化。",
"prEventOpened": "已打开",
"prEventOpenedTooltip": "检测到新的开放 PR 时触发一次。每个 PR 编号只会去重一次; 同一个 PR 不会再次触发。",
"prTriggerRepositoryHelp": "PR 触发器始终使用 PR 自己的仓库。",
diff --git a/apps/web/src/locales/zh-cn/common.json b/apps/web/src/locales/zh-cn/common.json
index c5edba37ea..3ff64512f7 100644
--- a/apps/web/src/locales/zh-cn/common.json
+++ b/apps/web/src/locales/zh-cn/common.json
@@ -48,6 +48,7 @@
"commandAddPlanPanelKeywords": "计划, 文档, 任务计划, 实现计划, 计划详情, plan",
"commandAddTerminalPanel": "添加终端面板",
"commandAddTerminalPanelKeywords": "终端, shell, 控制台, 新终端, 打开终端, 命令行, terminal",
+ "commandAgentsSettings": "代理设置",
"commandAgentsSettingsKeywords": "代理, 代理设置, 代理配置, 已安装代理, claude",
"commandArchiveTaskKeywords": "归档, 归档任务, 归档当前任务, 关闭任务, 完成任务, 收起",
"commandCancelTurn": "取消本轮",
@@ -64,6 +65,7 @@
"commandCreatePrKeywords": "拉取请求, pr, 打开拉取请求, 提交拉取请求, git",
"commandCreateSubtask": "创建子任务",
"commandCreateSubtaskKeywords": "子任务, 创建, 新建子任务, 子级任务, subtask",
+ "commandExecutorsSettings": "执行器设置",
"commandExecutorsSettingsKeywords": "执行器, 执行器配置, 执行环境, 环境变量, 运行时, 计算",
"commandGoToAllTasks": "前往全部任务",
"commandGoToAllTasksKeywords": "任务, 列表, 全部",
@@ -97,6 +99,7 @@
"commandPreviewGoToHome": "前往首页",
"commandPreviewPull": "拉取",
"commandPreviewSearchTasks": "搜索任务",
+ "commandPromptsSettings": "提示词设置",
"commandPromptsSettingsKeywords": "提示词, 提示词设置, 自定义提示词, 提示词片段, 提示词模板",
"commandPullKeywords": "拉取, git, 拉取更改, 下载更改, pull",
"commandPushKeywords": "推送, git, 推送更改, 推送到远程, 上传更改, push",
@@ -112,6 +115,7 @@
"commandSwitchToPipelineView": "切换到流水线视图",
"commandSwitchToPipelineViewKeywords": "流水线, 图, 视图, pipeline",
"commandThemeKeywords": "主题, 颜色主题, 外观",
+ "commandWorkspaceSettings": "工作区设置",
"commandWorkspaceSettingsKeywords": "工作区, 工作区",
"commitsAndPushesToTheCurrent": "提交并推送到当前分支。",
"commitsPushesAndOpensAPull": "提交、推送并创建拉取请求。",
@@ -199,6 +203,7 @@
"goToNextPage": "转到下一页",
"goToPreviousPage": "转到上一页",
"hAgo": "{{diffHr}}小时前",
+ "healthIssues": "健康问题",
"hideAdvancedOptions": "隐藏高级选项",
"howLogContextWorks": "日志上下文如何工作",
"improveKandev": "改进 Kandev",
@@ -230,6 +235,7 @@
"loadingWorkflowTemplates": "正在加载工作流模板...",
"mAgo": "{{diffMin}}分钟前",
"matchCase": "区分大小写",
+ "menu": "菜单",
"mode": "模式",
"model": "模型",
"multiRepoUnavailableLocal": "在 Local 支持其初始启动路径投影同级仓库之前,多仓库任务不可用。",
@@ -274,6 +280,8 @@
"openFullPage": "打开完整页面",
"openingLastTask": "正在打开上次任务…",
"openIssue": "打开议题",
+ "openNavigationMenu": "打开导航菜单",
+ "openNavigationMenuWithConnectionIssue": "{{description}} 打开导航菜单",
"optionalTheRepositoryTheAgentWorks": "可选; 代理工作的仓库。",
"panels": "面板",
"parentDirectory": "上级目录",
@@ -414,6 +422,7 @@
"usefulCommandsAgentSkills": "实用命令与代理技能",
"useScratchInstead": "改用临时工作区",
"useThisFolder": "使用此文件夹",
+ "utilities": "实用工具",
"viewFullReleaseOnGithub": "在 GitHub 上查看完整发行说明",
"waitsForCiAndAutomatedReviews": "等待 CI 与自动审阅,然后修复失败并处理评论。",
"walkthrough": "导览",
diff --git a/apps/web/src/locales/zh-cn/jira.json b/apps/web/src/locales/zh-cn/jira.json
index 506d40594a..5b0a096dee 100644
--- a/apps/web/src/locales/zh-cn/jira.json
+++ b/apps/web/src/locales/zh-cn/jira.json
@@ -23,6 +23,7 @@
"checkFailed": "检查失败:{{error}}",
"checkingJiraStatus": "正在检查 Jira 状态…",
"checkNow": "立即检查",
+ "checkingJiraStatus": "正在检查 Jira 状态…",
"clear": "清除{{lowerCase}}",
"clearFilters": "清除筛选条件",
"connectedAs": "已连接为 {{name}}",
diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json
index 26afa0ff5d..eb605a7217 100644
--- a/apps/web/src/locales/zh-cn/settings.json
+++ b/apps/web/src/locales/zh-cn/settings.json
@@ -62,7 +62,6 @@
"changesPanelLayout": "变更面板布局",
"changesTheLanguageOfTheKandev": "更改 Kandev 界面的语言。更改会立即生效,并记住在此设备上。任务标题、聊天以及你创建的其他内容不会翻译。",
"chatInput": "聊天输入",
- "chatInputAndCommandShortcuts": "聊天输入和命令快捷键",
"chooseAMonospaceFontForThe": "为终端选择等宽字体。Nerd Fonts 包含 CLI 工具所需的图标。",
"chooseTheLanguageUsedAcrossThe": "选择 Kandev 界面使用的语言",
"chooseWhatOpensWhenKandevStarts": "选择 Kandev 启动时或访问其不带路径的主页时打开的内容",
@@ -214,7 +213,6 @@
"edit": "编辑",
"editEditor": "编辑 {{name}}",
"editSecret": "编辑密钥",
- "editorIntegrationsAndDefaults": "编辑器集成与默认设置",
"editorName": "编辑器名称",
"editorType": "编辑器类型",
"editors": "编辑器",
@@ -305,7 +303,6 @@
"fontFamily": "字体",
"fontSize": "字号",
"frequencySeconds": "频率(秒)",
- "general": "常规",
"health": "运行状况",
"home": "主页",
"hostedUrl": "托管 URL",
@@ -366,7 +363,6 @@
"loading": "加载中...",
"loadingSecrets": "正在加载密钥...",
"logsPageDescription": "下载一个大小受限的诊断 ZIP,其中包含前端和后端日志。",
- "logsPageTitle": "日志",
"lspConfigCustomBadge": "自定义",
"lspInstallHintGo": "运行“{{command}}”。需要安装 Go。",
"lspInstallHintKotlin": "请手动将 {{binary}} 安装到任务主机的 {{path}} 中。对于本地 Docker 任务,请将其安装在任务容器内。",
@@ -403,7 +399,6 @@
"mcpAffectedToolsHelp": "<0>create_task_kandev0> 会创建单独任务。<3>spawn_session_kandev3> 会向当前任务添加会话,因此不使用此偏好设置。",
"mcpCreateTaskScope": "<0>create_task_kandev0> 会创建新任务和子任务。该设置仅在调用时未提供 <4>agent_profile_id4> 时生效。",
"mcpSpawnSessionScope": "<0>spawn_session_kandev0> 和你自行创建的任务不受影响。明确选择的配置始终优先。",
- "mcpTaskDefaultsAndArchiveSafeguards": "MCP 任务默认设置、归档保护和对话记录偏好",
"mcpTaskProfileCurrentTask": "当前任务配置",
"mcpTaskProfileCurrentTaskDescription": "新任务使用创建它的任务所用的相同配置。若后续工作需要相同模型和代理设置,请选择此项。这可能会复用费用更高的配置。",
"mcpTaskProfileWorkspaceDefault": "工作区默认配置",
@@ -504,7 +499,6 @@
"promptsLoading": "正在加载提示词…",
"promptsPageDescription": "为聊天输入创建可复用的提示词片段。",
"providerName": "提供商名称",
- "providersAndNotificationEvents": "提供商和通知事件",
"pxRange": "px(8-24)",
"refreshNotificationPermission": "刷新通知权限",
"refreshPermissionStatus": "刷新权限状态",
@@ -545,7 +539,6 @@
"settingsSearchResults_one": "{{count}} 个匹配的设置",
"settingsSearchResults_other": "{{count}} 个匹配的设置",
"shellOptionsUnavailable": "命令解释器选项不可用",
- "shellTerminalFontsAndLinkBehavior": "命令解释器、终端字体和链接行为",
"showAgentTodoListPanel": "显示代理待办列表面板",
"showAnchoredPromptBar": "显示固定提示栏",
"showCleanupDetailsAndSubtaskOptions": "归档开始前显示清理详情和子任务选项。",
@@ -596,7 +589,6 @@
"spriteHealthStopping": "正在停止",
"spriteHealthUnhealthy": "异常",
"spriteHealthUnknown": "未知",
- "sprites": "Sprites.dev 云沙盒",
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev 提供临时云沙盒,用于远程运行代理。",
"startOnYourSavedKanbanPipelineOrList": "从保存的看板、流水线或列表视图开始。",
"startupPage": "启动页面",
@@ -608,7 +600,6 @@
"systemLoad1Min": "系统负载(1 分钟)",
"taskActions": "任务操作",
"taskOverview": "任务概览",
- "taskWorkbenchLayoutProfilesAndDefaults": "任务工作台布局配置和默认设置",
"terminal": "终端",
"terminalFont": "终端字体",
"terminalFontSize": "终端字号",
@@ -618,7 +609,6 @@
"testNotificationBody": "如果你能读到这条消息,说明浏览器通知正常工作。",
"testNotificationTitle": "测试通知",
"theBuiltInDefaultLayoutWill": "内置“默认”布局将成为默认布局。",
- "themeMetricsAndChangesPanelPreferences": "主题、启动页面、指标和变更面板偏好",
"thisAppliesWhenKandevStarts": "这适用于 Kandev 启动或打开不带路径的主页时。主页、返回和工作流导航始终打开任务概览。",
"thisLayoutIsUsedAsThe": "此布局将用作新任务的起始布局。",
"thisPageHasUnsavedChangesSave": "此页面有未保存的更改。请保存、放弃或继续编辑。",
diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json
index 23b9cccf24..4ba9b20353 100644
--- a/apps/web/src/locales/zh-cn/sidebar.json
+++ b/apps/web/src/locales/zh-cn/sidebar.json
@@ -1,9 +1,9 @@
{
- "account": "账户",
"archivedLoadFailed": "无法加载已归档任务",
"groupAll": "全部",
"groupMultiRepo": "多仓库",
"groupUnassigned": "未分配",
+ "orgChart": "组织架构",
"retry": "重试",
"activeWorkspaceBadge": "活动",
"activity": "活动",
diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json
index 2e5b0115c9..681553e0cf 100644
--- a/apps/web/src/locales/zh-cn/system.json
+++ b/apps/web/src/locales/zh-cn/system.json
@@ -198,7 +198,6 @@
"navFeatureToggles": "功能开关",
"navLicenses": "许可证",
"navLogs": "日志",
- "navStorage": "存储",
"navUpdates": "更新",
"navUsers": "用户",
"reloadPage": "重新加载页面",
diff --git a/apps/web/src/locales/zh-cn/workspaces.json b/apps/web/src/locales/zh-cn/workspaces.json
index 493bd41c60..c0b9f440b9 100644
--- a/apps/web/src/locales/zh-cn/workspaces.json
+++ b/apps/web/src/locales/zh-cn/workspaces.json
@@ -76,7 +76,6 @@
"invalidRepositoryPath": "无效的仓库路径",
"localPath": "本地路径",
"localPathNotSet": "未设置本地路径",
- "manageRepositoriesConnected": "管理连接到此工作区的仓库。",
"repositoriesReadOnlyImprove": "此工作区中的仓库由 Improve Kandev 管理,无法更改。",
"manageWorkspaceDetails": "管理工作区详情,并跳转到工作流或仓库。",
"manageYourWorkspacesAndWorkflows": "管理你的工作区与工作流",
@@ -110,9 +109,6 @@
"useRepository": "使用仓库",
"validGitRepository": "有效的 git 仓库",
"validate": "验证",
- "workflows": "工作流",
- "workflowsManagedInThisWorkspace": "此工作区中管理的工作流",
- "workspaceLinks": "工作区链接",
"workspaceName": "工作区名称",
"workspaceNameIsRequired": "工作区名称为必填项。",
"workspaceNamePlaceholder": "我的工作区",
diff --git a/apps/web/src/office-routes.tsx b/apps/web/src/office-routes.tsx
index d996d0a29f..8b04621e6c 100644
--- a/apps/web/src/office-routes.tsx
+++ b/apps/web/src/office-routes.tsx
@@ -8,7 +8,7 @@ import AgentMemoryPage from "@/app/office/agents/[id]/memory/page";
import AgentPermissionsPage from "@/app/office/agents/[id]/permissions/page";
import AgentSkillsPage from "@/app/office/agents/[id]/skills/page";
import { AgentsPageClient } from "@/app/office/agents/agents-page-client";
-import { OfficeTopbar } from "@/app/office/components/office-topbar";
+import { OfficeShell } from "@/app/office/components/office-shell";
import { InboxPageClient } from "@/app/office/inbox/inbox-page-client";
import { OfficePageClient } from "@/app/office/page-client";
import { ProjectsPageClient } from "@/app/office/projects/projects-page-client";
@@ -107,23 +107,9 @@ export function OfficeRoutes({ pathname }: { pathname: string }) {
return (
-
- {/* `data-office-route` stamps the RESOLVED route onto the outlet, and is
- the render anchor the pseudo-coverage oracle waits on for every
- `office — …` screen (e2e/tests/i18n/pseudo-coverage.spec.ts).
-
- It is an attribute on the outlet rather than a testid inside each
- page because these pages are mounted with empty collections
- (`initialItems={[]}`), so they legitimately render empty states in
- e2e — an anchor inside the populated branch would never match. This
- attribute is present whichever branch a page takes, and it is not
- shell-satisfiable: this element lives in the office chunk, is not
- rendered while `OfficeRouteLoading` holds, and its VALUE identifies
- the specific route, so a mis-pointed URL cannot match another
- screen's anchor. */}
-
+
{renderOfficeRoute(normalizedPathname)}
-
+
);
diff --git a/apps/web/src/settings-route-helpers.tsx b/apps/web/src/settings-route-helpers.tsx
new file mode 100644
index 0000000000..718208f086
--- /dev/null
+++ b/apps/web/src/settings-route-helpers.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "@/lib/routing/client-router";
+import { rememberSettingsPath } from "@/lib/settings/last-settings-page";
+
+/**
+ * A settings path that only forwards to another one — `/settings/general` to its
+ * first page, `/settings/general/shell` to Terminal, and the handful of others
+ * left behind when pages moved. `replace`, so Back skips the stub.
+ */
+export function SettingsRedirect({ to }: { to: string }) {
+ const router = useRouter();
+
+ useEffect(() => {
+ router.replace(to);
+ }, [router, to]);
+
+ return null;
+}
+
+/**
+ * Records the settings page bare `/settings` should return to.
+ *
+ * Takes the route table's own set of static paths: the settings shell renders —
+ * and would therefore record — any `/settings/*` path, including ones that fall
+ * through to the not-ported fallback, and the dynamic routes resolve against
+ * workspaces, agents and plugins that can be deleted.
+ */
+export function useRememberSettingsPath(pathname: string, knownPaths: ReadonlySet): void {
+ useEffect(() => {
+ rememberSettingsPath(pathname, knownPaths);
+ }, [pathname, knownPaths]);
+}
diff --git a/apps/web/src/settings-routes.plugin.test.ts b/apps/web/src/settings-routes.plugin.test.ts
index 88ffd0cdd4..ba666d92ff 100644
--- a/apps/web/src/settings-routes.plugin.test.ts
+++ b/apps/web/src/settings-routes.plugin.test.ts
@@ -65,7 +65,8 @@ describe("renderSettingsRoute — plugin fallthrough", () => {
const route = renderSettingsRoute("/settings/general");
- // "/settings/general" is a real static route (GeneralSettings), not the plugin's.
+ // "/settings/general" is a first-party route (a redirect to its first page),
+ // not the plugin's.
expect((route as ReactElement).type).not.toBe(ShouldNotMatch);
});
});
diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts
index 959c893185..e8c61d5159 100644
--- a/apps/web/src/settings-routes.test.ts
+++ b/apps/web/src/settings-routes.test.ts
@@ -8,25 +8,34 @@ import ExecutorCreatePage from "@/app/settings/executor/new/page";
import ProfileEditPage from "@/app/settings/executors/[profileId]/page";
import CreateProfilePage from "@/app/settings/executors/new/[type]/page";
import SSHExecutorPage from "@/app/settings/executors/ssh/[executorId]/page";
-import IntegrationsGitHubPage from "@/app/settings/integrations/github/page";
import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page";
import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page";
import NewAutomationPage from "@/app/settings/workspace/[id]/automations/new/page";
import WorkspaceEditPage from "@/app/settings/workspace/[id]/page";
-import { TaskActionsSettings } from "@/components/settings/general-settings";
-import { SecretsSettings } from "@/components/settings/secrets-settings";
+import {
+ resolveSettingsBreadcrumbs,
+ type CrumbValues,
+} from "@/components/settings/settings-breadcrumbs";
+import { TaskBehaviorSettings } from "@/components/settings/task-behavior-settings";
+import { WorkspaceSettingsShell } from "@/components/settings/workspaces/workspace-settings-shell";
+import { SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS } from "@/lib/settings-discovery/catalog";
import { workspaceId, workflowId } from "@/lib/types/ids";
import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http";
-import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes";
+import { DEFAULT_SETTINGS_PATH } from "@/lib/settings/last-settings-page";
+import {
+ buildSettingsInitialStateForRoute,
+ renderSettingsRoute,
+ SETTINGS_ROUTE_PATHS,
+} from "./settings-routes";
vi.mock("@/components/settings/system/updates-card", () => ({ UpdatesCard: () => null }));
const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace";
const OWNER_ID = "owner-1";
const TIMESTAMP = "2026-01-01T00:00:00Z";
-const ENCODED_WORKSPACE_ID = "workspace%20one";
-const DECODED_WORKSPACE_ID = "workspace one";
+// The merged page that Message Queue and the legacy task-actions URL both land on.
+const TASK_BEHAVIOR_PATH = "/settings/preferences/task-behavior";
describe("buildSettingsInitialStateForRoute", () => {
beforeEach(() => {
@@ -134,23 +143,31 @@ describe("buildSettingsInitialStateForRoute", () => {
});
describe("message queue settings route", () => {
- it("registers the Message Queue General settings leaf", () => {
- const route = renderSettingsRoute("/settings/general/message-queue");
+ it("renders the Message Queue inside the merged Task behavior page", () => {
+ const route = renderSettingsRoute(TASK_BEHAVIOR_PATH);
expect(isValidElement(route)).toBe(true);
- expect(((route as ReactElement).type as { name?: string }).name).toBe(
- "MessageQueueSettingsPage",
- );
+ expect((route as ReactElement).type).toBe(TaskBehaviorSettings);
});
- it("redirects the former System URL to the General settings leaf", () => {
+ it("redirects the former General URL to the Task behavior page", () => {
+ const route = renderSettingsRoute("/settings/general/message-queue") as ReactElement<{
+ to: string;
+ }>;
+
+ expect(isValidElement(route)).toBe(true);
+ expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
+ expect(route.props.to).toBe(TASK_BEHAVIOR_PATH);
+ });
+
+ it("redirects the former System URL to the Task behavior page", () => {
const route = renderSettingsRoute("/settings/system/message-queue") as ReactElement<{
to: string;
}>;
expect(isValidElement(route)).toBe(true);
expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
- expect(route.props.to).toBe("/settings/general/message-queue");
+ expect(route.props.to).toBe(TASK_BEHAVIOR_PATH);
});
});
@@ -160,34 +177,72 @@ describe("renderSettingsRoute", () => {
expect(screen.getByText(/notification preferences are managed/i)).toBeTruthy();
expect(screen.getByRole("link", { name: /notifications/i }).getAttribute("href")).toBe(
- "/settings/general/notifications",
+ "/settings/preferences/notifications",
);
});
- it("renders layout profile settings from General settings", () => {
- const route = renderSettingsRoute("/settings/general/layouts");
+ it("renders layout profile settings from Preferences", () => {
+ const route = renderSettingsRoute("/settings/preferences/layouts");
expect(isValidElement(route)).toBe(true);
expect(((route as ReactElement).type as { name?: string }).name).toBe("LayoutSettings");
});
- it("renders task action preferences from General settings", () => {
- const route = renderSettingsRoute("/settings/general/task-actions");
+ it("redirects the legacy task actions page into Task behavior", () => {
+ const route = renderSettingsRoute("/settings/general/task-actions") as ReactElement<{
+ to: string;
+ }>;
expect(isValidElement(route)).toBe(true);
- expect((route as ReactElement).type).toBe(TaskActionsSettings);
+ expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
+ expect(route.props.to).toBe(TASK_BEHAVIOR_PATH);
});
it("passes the route workspace id to the GitLab integration page", () => {
- expect(gitLabRouteWorkspaceId("/settings/workspace/ws-2/integrations/gitlab")).toBe("ws-2");
- expect(gitLabRouteWorkspaceId("/settings/workspace/ws%202/integrations/gitlab")).toBe("ws 2");
+ expect(gitLabRouteWorkspaceId("/settings/workspaces/ws-2/integrations/gitlab")).toBe("ws-2");
+ expect(gitLabRouteWorkspaceId("/settings/workspaces/ws%202/integrations/gitlab")).toBe("ws 2");
});
- it("routes GitHub settings through the integration page wrapper", () => {
- const route = renderSettingsRoute("/settings/integrations/github");
+ it("redirects legacy /settings/workspace/ paths into /settings/workspaces", () => {
+ const route = renderSettingsRoute(
+ "/settings/workspace/ws-1/integrations/github",
+ ) as ReactElement<{ to: string }>;
expect(isValidElement(route)).toBe(true);
- expect((route as ReactElement).type).toBe(IntegrationsGitHubPage);
+ expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
+ expect(route.props.to).toBe("/settings/workspaces/ws-1/integrations/github");
});
+ // Upstream asserted this route rendered IntegrationsGitHubPage directly. The
+ // restructure makes integrations workspace-scoped, so the unscoped path is a
+ // redirect into the active workspace's Integrations tab — the page component
+ // is still what renders, one hop later, at the scoped URL.
+ it("sends unscoped GitHub settings into the active workspace's integrations tab", () => {
+ const route = renderSettingsRoute("/settings/integrations/github") as ReactElement<{
+ section: string;
+ }>;
+
+ expect(isValidElement(route)).toBe(true);
+ expect((route.type as { name?: string }).name).toBe("ActiveWorkspaceSectionRedirect");
+ expect(route.props.section).toBe("integrations/github");
+ });
+
+ it("reserves /settings/executor/new for executor creation", () => {
+ const route = renderSettingsRoute("/settings/executor/new");
+
+ expect(isValidElement(route)).toBe(true);
+ expect((route as ReactElement).type).toBe(ExecutorCreatePage);
+ });
+
+ it("reserves /settings/agents/browse for the install catalogue, not an agent named browse", () => {
+ const route = renderSettingsRoute("/settings/agents/browse");
+
+ expect(isValidElement(route)).toBe(true);
+ expect(((route as ReactElement).type as { name?: string }).name).toBe("AgentsBrowsePage");
+ });
+});
+
+// Split from the block above to stay inside the per-function line limit: this
+// half is only about decoding `%20`-style segments into component props.
+describe("renderSettingsRoute identifier decoding", () => {
it.each([
{
pathname: "/settings/plugins/plugin%20one",
@@ -220,66 +275,46 @@ describe("renderSettingsRoute", () => {
identifiers: { executorId: "executor one" },
},
{
- pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}`,
+ pathname: "/settings/workspaces/workspace%20one",
component: WorkspaceEditPage,
- identifiers: { workspaceId: DECODED_WORKSPACE_ID },
+ identifiers: { workspaceId: "workspace one" },
},
{
- pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/automations/new`,
+ pathname: "/settings/workspaces/workspace%20one/automations/new",
component: NewAutomationPage,
- identifiers: { workspaceId: DECODED_WORKSPACE_ID },
+ identifiers: { workspaceId: "workspace one" },
},
{
- pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/automations/automation%20one`,
+ pathname: "/settings/workspaces/workspace%20one/automations/automation%20one",
component: AutomationEditorPage,
- identifiers: { workspaceId: DECODED_WORKSPACE_ID, automationId: "automation one" },
- },
- {
- pathname: `/settings/workspace/${ENCODED_WORKSPACE_ID}/secrets`,
- component: SecretsSettings,
- identifiers: { scope: "workspace", workspaceId: DECODED_WORKSPACE_ID },
+ identifiers: { workspaceId: "workspace one", automationId: "automation one" },
},
])(
"passes decoded synchronous identifiers for $pathname",
({ pathname, component, identifiers }) => {
- assertSynchronousRouteIdentifiers(pathname, component, identifiers);
+ const route = unwrapWorkspaceShell(renderSettingsRoute(pathname));
+ if (!isValidElement>(route)) {
+ throw new Error(`expected a route element for ${pathname}`);
+ }
+
+ expect({
+ component: route.type,
+ identifiers: pickProps(route.props, Object.keys(identifiers)),
+ thenableProps: Object.entries(route.props)
+ .filter(([, value]) => isThenable(value))
+ .map(([name]) => name),
+ asyncComponent:
+ typeof route.type === "function" && route.type.constructor.name === "AsyncFunction",
+ }).toEqual({
+ component,
+ identifiers,
+ thenableProps: [],
+ asyncComponent: false,
+ });
},
);
-
- it("reserves /settings/executor/new for executor creation", () => {
- const route = renderSettingsRoute("/settings/executor/new");
-
- expect(isValidElement(route)).toBe(true);
- expect((route as ReactElement).type).toBe(ExecutorCreatePage);
- });
});
-function assertSynchronousRouteIdentifiers(
- pathname: string,
- component: unknown,
- identifiers: Record,
-) {
- const route = renderSettingsRoute(pathname);
- if (!isValidElement>(route)) {
- throw new Error(`expected a route element for ${pathname}`);
- }
-
- expect({
- component: route.type,
- identifiers: pickProps(route.props, Object.keys(identifiers)),
- thenableProps: Object.entries(route.props)
- .filter(([, value]) => isThenable(value))
- .map(([name]) => name),
- asyncComponent:
- typeof route.type === "function" && route.type.constructor.name === "AsyncFunction",
- }).toEqual({
- component,
- identifiers,
- thenableProps: [],
- asyncComponent: false,
- });
-}
-
function buildState(
overrides: Partial[0]> = {},
) {
@@ -341,7 +376,7 @@ function userSettings(
}
function gitLabRouteWorkspaceId(pathname: string): string | undefined {
- const route = renderSettingsRoute(pathname);
+ const route = unwrapWorkspaceShell(renderSettingsRoute(pathname));
if (!isValidElement(route)) {
throw new Error("expected GitLab integration route element");
}
@@ -349,6 +384,15 @@ function gitLabRouteWorkspaceId(pathname: string): string | undefined {
return (route as ReactElement<{ workspaceId?: string }>).props.workspaceId;
}
+// Workspace routes render inside the tabbed WorkspaceSettingsShell; unwrap it
+// so identifier assertions land on the page component itself.
+function unwrapWorkspaceShell(route: unknown): unknown {
+ if (isValidElement(route) && route.type === WorkspaceSettingsShell) {
+ return (route as ReactElement<{ children?: unknown }>).props.children;
+ }
+ return route;
+}
+
function pickProps(props: Record, names: string[]): Record {
return Object.fromEntries(names.map((name) => [name, props[name]]));
}
@@ -361,3 +405,89 @@ function isThenable(value: unknown): value is PromiseLike {
typeof value.then === "function"
);
}
+
+describe("restorable settings paths", () => {
+ it("covers the default /settings target and excludes what cannot be restored", () => {
+ // `/settings` restores only into this set. It has to contain the fallback
+ // target, and must not contain the shapes that resolve against deletable
+ // records — those are matched dynamically, so membership is the guard.
+ expect(SETTINGS_ROUTE_PATHS.has(DEFAULT_SETTINGS_PATH)).toBe(true);
+ expect(DEFAULT_SETTINGS_PATH).toBe("/settings/preferences/appearance");
+
+ for (const path of SETTINGS_ROUTE_PATHS) {
+ expect(path.startsWith("/settings"), `${path} is not a settings path`).toBe(true);
+ expect(path, `${path} looks like a dynamic route`).not.toMatch(/[[\]:*]/);
+ }
+ expect(SETTINGS_ROUTE_PATHS.has("/settings/plugins/kandev-plugin-e2e")).toBe(false);
+ expect(SETTINGS_ROUTE_PATHS.has("/settings/does-not-exist")).toBe(false);
+ });
+});
+
+describe("settings breadcrumb coverage", () => {
+ // The dynamic route shapes, one per crumb row. Ids are deliberately absent
+ // from any store here: a route whose title only works once its record loads
+ // still has to name the page, and the fallback is what this asserts.
+ const DYNAMIC_SETTINGS_PATHS = [
+ "/settings/workspaces/ws-1",
+ "/settings/workspaces/ws-1/repositories",
+ "/settings/workspaces/ws-1/workflows",
+ "/settings/workspaces/ws-1/secrets",
+ "/settings/workspaces/ws-1/integrations",
+ "/settings/workspaces/ws-1/integrations/github",
+ "/settings/workspaces/ws-1/automations",
+ "/settings/workspaces/ws-1/automations/new",
+ "/settings/workspaces/ws-1/automations/auto-1",
+ "/settings/agents/claude",
+ "/settings/agents/claude/profiles/agent-profile-1",
+ "/settings/executors/exec-profile-1",
+ "/settings/executors/new/local_docker",
+ "/settings/executors/ssh/exec-1",
+ "/settings/executor/exec-1",
+ "/settings/executor/exec-1/profile/exec-profile-1",
+ ];
+
+ // Nothing resolves through the store, so every label must come from the
+ // catalog. `t` echoes its key, which is enough to prove one was used.
+ const echoKey = (key: string) => key;
+ const noRecords: CrumbValues = {
+ workspaceName: () => null,
+ agentDisplayName: () => null,
+ agentProfileName: () => null,
+ automationName: () => null,
+ executorName: () => null,
+ executorProfileName: () => null,
+ executorTypeTitle: () => null,
+ integrationTitle: () => null,
+ pluginName: () => null,
+ };
+
+ /**
+ * A title-cased URL segment reads as English in every locale and no lint rule
+ * can see it — there is no literal to flag. So every route the app ships must
+ * resolve its title through the catalog, a brand name, or a record name.
+ *
+ * Redirect-only paths are exempt via the discovery catalog's own exclusion
+ * list: they render for one frame on the way somewhere else, and that list is
+ * already the maintained record of which paths those are.
+ */
+ it("titles every shipped settings route from the catalog, not the URL", () => {
+ const untranslated: string[] = [];
+ const paths = [...SETTINGS_ROUTE_PATHS, ...DYNAMIC_SETTINGS_PATHS];
+ for (const path of paths) {
+ if (path in SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS) continue;
+ const { titleFromUrlSegment } = resolveSettingsBreadcrumbs(path, echoKey, noRecords);
+ if (titleFromUrlSegment) untranslated.push(path);
+ }
+ expect(untranslated, "add a SEGMENT_LABEL_KEYS entry or a crumb route row").toEqual([]);
+ });
+
+ it("orients every dynamic route under the settings page that owns it", () => {
+ for (const path of DYNAMIC_SETTINGS_PATHS) {
+ const { parents } = resolveSettingsBreadcrumbs(path, echoKey, noRecords);
+ // Settings, then the owning menu row: a record page is never a top-level
+ // settings page, so two crumbs is the floor.
+ expect(parents.length, `${path} has no owning page crumb`).toBeGreaterThanOrEqual(2);
+ expect(parents[0].label).toBe("common:settings");
+ }
+ });
+});
diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx
index 0c3d3e07f1..94889999c5 100644
--- a/apps/web/src/settings-routes.tsx
+++ b/apps/web/src/settings-routes.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useRef, useState, type ReactNode } from "react";
+import { useEffect, useRef, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import AgentsSettingsPage from "@/app/settings/agents/page";
+import AgentsBrowsePage from "@/app/settings/agents/browse/page";
import AgentSetupPage from "@/app/settings/agents/[agentId]/page";
import AgentProfileRoute from "@/app/settings/agents/[agentId]/profiles/[profileId]/page";
-import AutomationsTopLevelPage from "@/app/settings/automations/page";
import ExecutorEditPage from "@/app/settings/executor/[id]/page";
import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page";
import ExecutorCreatePage from "@/app/settings/executor/new/page";
@@ -23,61 +23,67 @@ import IntegrationsLinearPage from "@/app/settings/integrations/linear/page";
import IntegrationsSentryPage from "@/app/settings/integrations/sentry/page";
import PluginsSettingsPage from "@/app/settings/plugins/page";
import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page";
-import MessageQueueSettingsPage from "@/app/settings/general/message-queue/page";
-import StoragePage from "@/app/settings/system/storage/page";
import UtilityAgentsSettingsPage from "@/app/settings/utility-agents/page";
import AutomationsPage from "@/app/settings/workspace/[id]/automations/page";
import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page";
import NewAutomationPage from "@/app/settings/workspace/[id]/automations/new/page";
import WorkspaceEditPage from "@/app/settings/workspace/[id]/page";
-import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace-repositories-client";
-import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client";
import WorkspacesPage from "@/app/settings/workspace/page";
import Link from "@/components/routing/app-link";
-import { useAppStoreApi } from "@/components/state-provider";
-import { EditorsSettings } from "@/components/settings/editors-settings";
+import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import {
AppearanceSettings,
- GeneralSettings,
KeyboardShortcutsSettings,
- TaskActionsSettings,
} from "@/components/settings/general-settings";
+import { SettingsIndex } from "@/components/settings/settings-index";
+import { readLastSettingsPath } from "@/lib/settings/last-settings-page";
+import { SettingsRedirect, useRememberSettingsPath } from "./settings-route-helpers";
import { NotificationsSettings } from "@/components/settings/notifications-settings";
import { LayoutSettings } from "@/components/settings/layouts/layout-settings";
import { PromptsSettings } from "@/components/settings/prompts-settings";
import { SecretsSettings } from "@/components/settings/secrets-settings";
import { SettingsLayoutClient } from "@/components/settings/settings-layout-client";
-import { SpritesSettings } from "@/components/settings/sprites-settings";
-import { AboutCard } from "@/components/settings/system/about-card";
+import { TaskBehaviorSettings } from "@/components/settings/task-behavior-settings";
+import { TerminalEditorsSettings } from "@/components/settings/terminal-editors-settings";
+import { AboutSettings } from "@/components/settings/system/about-settings";
import { ApiTokens } from "@/components/settings/account/api-tokens";
import { SecuritySettings } from "@/components/settings/account/security-settings";
import { UsersTable } from "@/components/settings/system/users-table";
-import { BackupsTable } from "@/components/settings/system/backups-table";
-import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card";
+import { DataStorageSettings } from "@/components/settings/system/data-storage-settings";
import { DiskUsageCard } from "@/components/settings/system/disk-usage-card";
import { FeatureTogglesRoute } from "@/components/settings/system/feature-toggles-route";
import { HealthIssuesCard } from "@/components/settings/system/health-issues-card";
-import { LicensesList } from "@/components/settings/system/licenses-list";
-import { LogViewer } from "@/components/settings/system/log-viewer";
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
-import {
- BACKUP_DIR,
- BACKUP_SQL_COMMAND,
- SystemRouteShell,
-} from "@/components/settings/system/system-route-shell";
+import { SystemRouteShell } from "@/components/settings/system/system-route-shell";
import { UIStateCard } from "@/components/settings/system/ui-state-card";
import { UpdatesCard } from "@/components/settings/system/updates-card";
import { VersionSummaryCard } from "@/components/settings/system/version-summary-card";
-import { TerminalSettings } from "@/components/settings/terminal-settings";
import { VoiceModeSettings } from "@/components/settings/voice-mode-settings";
+import {
+ WorkspaceSettingsShell,
+ type WorkspaceSettingsTab,
+} from "@/components/settings/workspaces/workspace-settings-shell";
import licenses from "@/generated/licenses.json";
-import { fetchJson } from "@/lib/api/client";
+import {
+ APPEARANCE_SETTINGS_HREF,
+ KEYBOARD_SHORTCUTS_SETTINGS_HREF,
+ LAYOUTS_SETTINGS_HREF,
+ NOTIFICATIONS_SETTINGS_HREF,
+ TASK_BEHAVIOR_SETTINGS_HREF,
+ TERMINAL_EDITORS_SETTINGS_HREF,
+} from "@/lib/settings-discovery/catalog/preferences";
+import {
+ EXECUTORS_SETTINGS_HREF,
+ SECRETS_SETTINGS_HREF,
+ SYSTEM_ABOUT_SETTINGS_HREF,
+ SYSTEM_DATA_STORAGE_SETTINGS_HREF,
+ WORKSPACES_SETTINGS_HREF,
+} from "@/lib/settings-discovery/catalog";
import {
PluginErrorBoundary,
PluginRouteFallback,
} from "@/components/plugins/plugin-error-boundary";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
-import { listWorkflows } from "@/lib/api/domains/kanban-api";
import {
fetchUserSettings,
listAgentDiscovery,
@@ -85,16 +91,13 @@ import {
listAvailableAgents,
listExecutors,
} from "@/lib/api/domains/settings-api";
-import { listWorkflowTemplates } from "@/lib/api/domains/workflow-api";
-import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api";
-import { useRouter } from "@/lib/routing/client-router";
+import { listWorkspaces } from "@/lib/api/domains/workspace-api";
import {
matchSingle,
matchDouble,
normalizeSettingsPath,
safeDecodePathSegment,
} from "@/lib/routing/path";
-import { IMPROVE_KANDEV_WORKSPACE_NAME } from "@/components/improve-kandev-dialog-model";
import {
mapWorkspaceItem,
readActiveWorkspaceCookie,
@@ -103,28 +106,14 @@ import {
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { HydrationState } from "@/lib/state/store";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
-import type {
- ListWorkspacesResponse,
- Repository,
- RepositoryScript,
- UserSettingsResponse,
- Workflow,
- WorkflowTemplate,
- Workspace,
-} from "@/lib/types/http";
+import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http";
import type { LicenseEntry } from "@/lib/types/system";
+import {
+ WorkspaceRepositoriesRoute,
+ WorkspaceWorkflowsRoute,
+} from "./settings-routes.workspace-data";
type RouteRenderer = () => ReactNode;
-type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
-type WorkspaceRepositoriesRouteState = {
- workspace: Workspace | null;
- repositories: RepositoryWithScripts[];
-};
-type WorkspaceWorkflowsRouteState = {
- workspace: Workspace | null;
- workflows: Workflow[];
- workflowTemplates: WorkflowTemplate[];
-};
type SettingsInitialStateData = {
workspaces: ListWorkspacesResponse["workspaces"];
executors: Awaited>["executors"];
@@ -138,29 +127,41 @@ type SettingsInitialStateData = {
const licenseEntries = licenses as LicenseEntry[];
const SETTINGS_ROUTES: Record = {
- "/settings": () => ,
- "/settings/general": () => ,
- "/settings/general/appearance": () => ,
- "/settings/general/changes-panel": () => ,
- "/settings/general/chat-input": () => (
-
+ // The index resolves per surface: the tree as a page on a phone, a handoff to
+ // the last visited settings page on desktop. See `SettingsIndex`.
+ "/settings": () => ,
+ // Preferences pages (the former General group).
+ "/settings/preferences": () => ,
+ "/settings/preferences/appearance": () => ,
+ "/settings/preferences/keyboard-shortcuts": () => ,
+ "/settings/preferences/layouts": () => ,
+ "/settings/preferences/notifications": () => ,
+ "/settings/preferences/task-behavior": () => ,
+ "/settings/preferences/terminal-editors": () => ,
+ // Legacy /settings/general paths, one redirect per page that lived there.
+ "/settings/general": () => ,
+ "/settings/general/appearance": () => ,
+ "/settings/general/changes-panel": () => ,
+ "/settings/general/chat-input": () => ,
+ "/settings/general/editors": () => ,
+ "/settings/general/keyboard-shortcuts": () => (
+
),
- "/settings/general/editors": () => ,
- "/settings/general/keyboard-shortcuts": () => ,
- "/settings/general/layouts": () => ,
- "/settings/general/message-queue": () => ,
- "/settings/general/notifications": () => ,
- "/settings/general/resource-metrics": () => (
-
- ),
- "/settings/general/secrets": () => ,
- "/settings/general/shell": () => ,
- "/settings/general/sprites": () => ,
- "/settings/general/task-actions": () => ,
- "/settings/general/terminal": () => ,
- "/settings/workspace": () => ,
+ "/settings/general/layouts": () => ,
+ "/settings/general/message-queue": () => ,
+ "/settings/general/notifications": () => ,
+ "/settings/general/resource-metrics": () => ,
+ "/settings/general/secrets": () => ,
+ "/settings/general/shell": () => ,
+ "/settings/general/sprites": () => ,
+ "/settings/general/task-actions": () => ,
+ "/settings/general/terminal": () => ,
+ "/settings/workspaces": () => ,
+ "/settings/workspace": () => ,
+ "/settings/secrets": () => ,
"/settings/agents": () => ,
- "/settings/automations": () => ,
+ "/settings/agents/browse": () => ,
+ "/settings/automations": () => ,
"/settings/executors": () => ,
"/settings/executor/new": () => ,
"/settings/utility-agents": () => ,
@@ -168,13 +169,27 @@ const SETTINGS_ROUTES: Record = {
"/settings/prompts": () => ,
"/settings/voice-mode": () => ,
"/settings/plugins": () => ,
- "/settings/integrations": () => renderIntegrationSettingsRoute(null),
- "/settings/integrations/azure-devops": () => renderIntegrationSettingsRoute("azure-devops"),
- "/settings/integrations/github": () => renderIntegrationSettingsRoute("github"),
- "/settings/integrations/gitlab": () => renderIntegrationSettingsRoute("gitlab"),
- "/settings/integrations/jira": () => renderIntegrationSettingsRoute("jira"),
- "/settings/integrations/linear": () => renderIntegrationSettingsRoute("linear"),
- "/settings/integrations/sentry": () => renderIntegrationSettingsRoute("sentry"),
+ // Integrations are per-workspace pages now; the old install-level paths
+ // forward into the active workspace's Integrations tab.
+ "/settings/integrations": () => ,
+ "/settings/integrations/azure-devops": () => (
+
+ ),
+ "/settings/integrations/github": () => (
+
+ ),
+ "/settings/integrations/gitlab": () => (
+
+ ),
+ "/settings/integrations/jira": () => (
+
+ ),
+ "/settings/integrations/linear": () => (
+
+ ),
+ "/settings/integrations/sentry": () => (
+
+ ),
"/settings/system": () => ,
"/settings/system/users": () => (
@@ -185,23 +200,19 @@ const SETTINGS_ROUTES: Record = {
"/settings/account/tokens": () => ,
"/settings/system/about": () => (
-
+
),
- "/settings/system/backups": () => (
+ "/settings/system/data-storage": () => (
-
-
- ),
- "/settings/system/database": () => (
-
-
+
),
+ "/settings/system/backups": () => ,
+ "/settings/system/database": () => ,
"/settings/system/feature-toggles": () => (
= {
),
- "/settings/system/licenses": () => (
-
-
-
- ),
- "/settings/system/logs": () => (
-
-
-
- ),
- "/settings/system/message-queue": () => ,
+ "/settings/system/licenses": () => ,
+ "/settings/system/logs": () => ,
+ "/settings/system/message-queue": () => ,
"/settings/system/status": () => (
@@ -234,17 +234,28 @@ const SETTINGS_ROUTES: Record
= {
),
- "/settings/system/storage": () => ,
+ "/settings/system/storage": () => ,
"/settings/system/updates": renderUpdatesRoute,
"/settings/changelog": () => ,
};
+/**
+ * Every static settings path. Bare `/settings` restores only into this set: the
+ * shell renders — and would therefore record — any `/settings/*` path, including
+ * ones that fall through to `SettingsRouteFallback`, and the dynamic routes
+ * resolve against workspaces, agents and plugins that can be deleted. Derived
+ * from the table above so a removed route leaves the set in the same commit.
+ */
+export const SETTINGS_ROUTE_PATHS: ReadonlySet = new Set(Object.keys(SETTINGS_ROUTES));
+
export function SettingsRoutes({ pathname }: { pathname: string }) {
const normalizedPathname = normalizeSettingsPath(pathname);
// Subscribe so a plugin settings route registered after first paint
// (async bundle load) re-resolves without requiring a navigation.
usePluginRegistry();
+ useRememberSettingsPath(normalizedPathname, SETTINGS_ROUTE_PATHS);
+
return (
<>
@@ -304,7 +315,9 @@ function renderDynamicSettingsRoute(pathname: string) {
}
const agentId = matchSingle(pathname, /^\/settings\/agents\/([^/]+)$/);
- if (agentId) {
+ // "browse" is the static install-catalogue route, not an agent name — same
+ // guard shape as /settings/executor/new below.
+ if (agentId && agentId !== "browse") {
return ;
}
@@ -340,49 +353,124 @@ function renderDynamicSettingsRoute(pathname: string) {
return null;
}
-function renderWorkspaceSettingsRoute(pathname: string) {
+// One component per workspace sub-page tab. A lookup rather than a ternary
+// chain: the nested version pushed the enclosing matcher over both the
+// cyclomatic and cognitive complexity limits.
+// Keep in step with the alternation in the sub-page pattern below.
+type WorkspaceSubpageSection = "repositories" | "workflows" | "automations" | "secrets";
+
+const WORKSPACE_SUBPAGE_PAGES: Record ReactNode> = {
+ repositories: (id) => ,
+ workflows: (id) => ,
+ automations: (id) => ,
+ secrets: (id) => ,
+};
+
+function renderWorkspaceIntegrationRoute(match: RegExpMatchArray): ReactNode {
+ const workspaceId = safeDecodePathSegment(match[1]);
+ const section = match[2] ? safeDecodePathSegment(match[2]) : null;
+ if (!workspaceId || (match[2] && !section)) return null;
+ const integrationPage = renderIntegrationSettingsRoute(section, workspaceId);
+ if (!integrationPage) return null;
+ return (
+
+ {integrationPage}
+
+ );
+}
+
+function renderWorkspaceAutomationRoute(id: string, automationId: string): ReactNode {
+ const editor =
+ automationId === "new" ? (
+
+ ) : (
+
+ );
+ return (
+
+ {editor}
+
+ );
+}
+
+function renderWorkspaceSettingsRoute(pathname: string): ReactNode {
+ // Legacy /settings/workspace/... paths forward to /settings/workspaces/...
+ if (pathname.startsWith("/settings/workspace/")) {
+ return (
+
+ );
+ }
+
const workspaceIntegration = pathname.match(
- /^\/settings\/workspace\/([^/]+)\/integrations(?:\/([^/]+))?$/,
+ /^\/settings\/workspaces\/([^/]+)\/integrations(?:\/([^/]+))?$/,
);
if (workspaceIntegration?.[1]) {
- const workspaceId = safeDecodePathSegment(workspaceIntegration[1]);
- const section = workspaceIntegration[2] ? safeDecodePathSegment(workspaceIntegration[2]) : null;
- if (!workspaceId || (workspaceIntegration[2] && !section)) return null;
- return renderIntegrationSettingsRoute(section, workspaceId);
+ return renderWorkspaceIntegrationRoute(workspaceIntegration);
}
const workspaceAutomation = matchDouble(
pathname,
- /^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/,
+ /^\/settings\/workspaces\/([^/]+)\/automations\/([^/]+)$/,
);
if (workspaceAutomation) {
- const [id, automationId] = workspaceAutomation;
- if (automationId === "new") {
- return ;
- }
- return ;
+ return renderWorkspaceAutomationRoute(workspaceAutomation[0], workspaceAutomation[1]);
}
const workspaceSubpage = matchDouble(
pathname,
- /^\/settings\/workspace\/([^/]+)\/(repositories|secrets|workflows|automations)$/,
+ /^\/settings\/workspaces\/([^/]+)\/(repositories|workflows|automations|secrets)$/,
);
if (workspaceSubpage) {
const [id, section] = workspaceSubpage;
- if (section === "repositories") return ;
- if (section === "secrets") return ;
- if (section === "workflows") return ;
- return ;
+ const tab = section as WorkspaceSubpageSection;
+ return (
+
+ {WORKSPACE_SUBPAGE_PAGES[tab](id)}
+
+ );
}
- const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/);
+ const workspaceId = matchSingle(pathname, /^\/settings\/workspaces\/([^/]+)$/);
if (workspaceId) {
- return ;
+ return (
+
+
+
+ );
}
return null;
}
+/**
+ * The install-level automations/integrations paths are gone from the menu;
+ * anything still linking to them lands on the active workspace's tab. Falls
+ * back to the Workspaces list when no workspace exists (or none is active yet).
+ */
+function ActiveWorkspaceSectionRedirect({ section }: { section: string }) {
+ const workspaces = useAppStore((s) => s.workspaces.items);
+ const activeId = useAppStore((s) => s.workspaces.activeId);
+ // Workspaces hydrate asynchronously (SettingsRouteBootstrap); this flag flips
+ // in the same hydrate call, so it separates "still loading" from "none exist".
+ const hydrated = useAppStore((s) => s.settingsData.executorsLoaded);
+ const workspaceId =
+ (activeId && workspaces.some((workspace) => workspace.id === activeId) ? activeId : null) ??
+ workspaces[0]?.id ??
+ null;
+ if (workspaceId === null) {
+ if (!hydrated) return null;
+ // No workspace exists: the list page owns the "create one" flow.
+ return ;
+ }
+ return (
+
+ );
+}
+
function renderIntegrationSettingsRoute(section: string | null, workspaceId?: string) {
switch (section) {
case null:
@@ -444,7 +532,7 @@ function UpdatesRoute() {
Notification preferences are managed in{" "}
-
+
Notifications
.
@@ -455,16 +543,6 @@ function UpdatesRoute() {
);
}
-function SettingsRedirect({ to }: { to: string }) {
- const router = useRouter();
-
- useEffect(() => {
- router.replace(to);
- }, [router, to]);
-
- return null;
-}
-
function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
const store = useAppStoreApi();
const bootstrappedRef = useRef(false);
@@ -559,104 +637,6 @@ export function buildSettingsInitialStateForRoute({
};
}
-function WorkspaceRepositoriesRoute({ workspaceId }: { workspaceId: string }) {
- const [state, setState] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
- setState(null);
-
- loadWorkspaceRepositoriesRoute(workspaceId)
- .catch(() => ({ workspace: null, repositories: [] }))
- .then((nextState) => {
- if (!cancelled) setState(nextState);
- });
-
- return () => {
- cancelled = true;
- };
- }, [workspaceId]);
-
- if (!state) return null;
- return (
-
- );
-}
-
-function WorkspaceWorkflowsRoute({ workspaceId }: { workspaceId: string }) {
- const [state, setState] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
- setState(null);
-
- loadWorkspaceWorkflowsRoute(workspaceId)
- .catch(() => ({ workspace: null, workflows: [], workflowTemplates: [] }))
- .then((nextState) => {
- if (!cancelled) setState(nextState);
- });
-
- return () => {
- cancelled = true;
- };
- }, [workspaceId]);
-
- if (!state) return null;
- return (
-
- );
-}
-
-async function loadWorkspaceRepositoriesRoute(
- workspaceId: string,
-): Promise {
- const [workspace, repoResponse] = await Promise.all([
- fetchJson(`/api/v1/workspaces/${workspaceId}`, { cache: "no-store" }),
- listRepositories(workspaceId, { includeScripts: true }, { cache: "no-store" }),
- ]);
-
- return {
- workspace,
- repositories: repoResponse.repositories.map((repository) => ({
- ...repository,
- scripts: repository.scripts ?? [],
- })),
- };
-}
-
-async function loadWorkspaceWorkflowsRoute(
- workspaceId: string,
-): Promise {
- const workspace = await fetchJson(`/api/v1/workspaces/${workspaceId}`, {
- cache: "no-store",
- });
- // The dedicated Improve Kandev workspace lists its hidden workflows
- // (improve-kandev, report-kandev-issue) read-only; other workspaces keep
- // them hidden.
- const [workflowResponse, templateResponse] = await Promise.all([
- listWorkflows(workspaceId, {
- includeHidden: workspace.name === IMPROVE_KANDEV_WORKSPACE_NAME,
- cache: "no-store",
- }),
- listWorkflowTemplates({ cache: "no-store" }),
- ]);
-
- return {
- workspace,
- workflows: workflowResponse.workflows ?? [],
- workflowTemplates: templateResponse.templates ?? [],
- };
-}
-
function SettingsRouteFallback({ pathname }: { pathname: string }) {
return (
diff --git a/apps/web/src/settings-routes.workspace-data.test.tsx b/apps/web/src/settings-routes.workspace-data.test.tsx
new file mode 100644
index 0000000000..dc432e9d79
--- /dev/null
+++ b/apps/web/src/settings-routes.workspace-data.test.tsx
@@ -0,0 +1,103 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { IMPROVE_KANDEV_WORKSPACE_NAME } from "@/components/improve-kandev-dialog-model";
+
+/**
+ * The Improve Kandev workspace is configuration-immutable, and the only thing
+ * that switches its Repositories/Workflows tabs into read-only mode is the
+ * `isImproveWorkspace` prop these route wrappers pass down. It was dropped once
+ * already when the routes moved out of `settings-routes.tsx`, silently
+ * re-enabling editing; these tests exist so that cannot happen again.
+ */
+
+const { repositoriesProps, workflowsProps } = vi.hoisted(() => ({
+ repositoriesProps: [] as { isImproveWorkspace?: boolean }[],
+ workflowsProps: [] as { isImproveWorkspace?: boolean }[],
+}));
+
+vi.mock("@/app/settings/workspace/workspace-repositories-client", () => ({
+ WorkspaceRepositoriesClient: (props: { isImproveWorkspace?: boolean }) => {
+ repositoriesProps.push(props);
+ return
{String(props.isImproveWorkspace)}
;
+ },
+}));
+
+vi.mock("@/app/settings/workspace/workspace-workflows-client", () => ({
+ WorkspaceWorkflowsClient: (props: { isImproveWorkspace?: boolean }) => {
+ workflowsProps.push(props);
+ return
{String(props.isImproveWorkspace)}
;
+ },
+}));
+
+const fetchJson = vi.fn();
+const listRepositories = vi.fn();
+const listWorkflows = vi.fn();
+const listWorkflowTemplates = vi.fn();
+
+vi.mock("@/lib/api/client", () => ({ fetchJson: (...args: unknown[]) => fetchJson(...args) }));
+vi.mock("@/lib/api/domains/workspace-api", () => ({
+ listRepositories: (...args: unknown[]) => listRepositories(...args),
+}));
+vi.mock("@/lib/api/domains/kanban-api", () => ({
+ listWorkflows: (...args: unknown[]) => listWorkflows(...args),
+}));
+vi.mock("@/lib/api/domains/workflow-api", () => ({
+ listWorkflowTemplates: (...args: unknown[]) => listWorkflowTemplates(...args),
+}));
+
+const { WorkspaceRepositoriesRoute, WorkspaceWorkflowsRoute } =
+ await import("./settings-routes.workspace-data");
+
+function mockWorkspace(name: string) {
+ fetchJson.mockResolvedValue({ id: "ws-1", name });
+ listRepositories.mockResolvedValue({ repositories: [] });
+ listWorkflows.mockResolvedValue({ workflows: [] });
+ listWorkflowTemplates.mockResolvedValue({ templates: [] });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ repositoriesProps.length = 0;
+ workflowsProps.length = 0;
+});
+
+describe("WorkspaceRepositoriesRoute", () => {
+ it("marks the Improve Kandev workspace as read-only", async () => {
+ mockWorkspace(IMPROVE_KANDEV_WORKSPACE_NAME);
+
+ render(
);
+
+ await screen.findByTestId("repositories-client");
+ expect(repositoriesProps.at(-1)?.isImproveWorkspace).toBe(true);
+ });
+
+ it("leaves a regular workspace editable", async () => {
+ mockWorkspace("My Workspace");
+
+ render(
);
+
+ await screen.findByTestId("repositories-client");
+ expect(repositoriesProps.at(-1)?.isImproveWorkspace).toBe(false);
+ });
+});
+
+describe("WorkspaceWorkflowsRoute", () => {
+ it("marks the Improve Kandev workspace as read-only", async () => {
+ mockWorkspace(IMPROVE_KANDEV_WORKSPACE_NAME);
+
+ render(
);
+
+ await screen.findByTestId("workflows-client");
+ expect(workflowsProps.at(-1)?.isImproveWorkspace).toBe(true);
+ });
+
+ it("leaves a regular workspace editable", async () => {
+ mockWorkspace("My Workspace");
+
+ render(
);
+
+ await screen.findByTestId("workflows-client");
+ expect(workflowsProps.at(-1)?.isImproveWorkspace).toBe(false);
+ });
+});
diff --git a/apps/web/src/settings-routes.workspace-data.tsx b/apps/web/src/settings-routes.workspace-data.tsx
new file mode 100644
index 0000000000..260e7202bd
--- /dev/null
+++ b/apps/web/src/settings-routes.workspace-data.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useState, type ReactNode } from "react";
+
+import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace-repositories-client";
+import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client";
+import { IMPROVE_KANDEV_WORKSPACE_NAME } from "@/components/improve-kandev-dialog-model";
+import { fetchJson } from "@/lib/api/client";
+import { listWorkflows } from "@/lib/api/domains/kanban-api";
+import { listWorkflowTemplates } from "@/lib/api/domains/workflow-api";
+import { listRepositories } from "@/lib/api/domains/workspace-api";
+import type {
+ Repository,
+ RepositoryScript,
+ Workflow,
+ WorkflowTemplate,
+ Workspace,
+} from "@/lib/types/http";
+
+/**
+ * The two workspace settings tabs that fetch their own data instead of reading
+ * the hydrated store: Repositories and Workflows. Both existed as page-level
+ * loaders under the Next.js runtime, so the SPA route table has to do the
+ * fetching the removed server components used to. They live here rather than in
+ * `settings-routes.tsx` to keep that file inside its line budget.
+ */
+
+type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
+
+type WorkspaceRepositoriesRouteState = {
+ workspace: Workspace | null;
+ repositories: RepositoryWithScripts[];
+};
+
+type WorkspaceWorkflowsRouteState = {
+ workspace: Workspace | null;
+ workflows: Workflow[];
+ workflowTemplates: WorkflowTemplate[];
+};
+
+export function WorkspaceRepositoriesRoute({ workspaceId }: { workspaceId: string }): ReactNode {
+ const [state, setState] = useState
(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setState(null);
+
+ loadWorkspaceRepositoriesRoute(workspaceId)
+ .catch(() => ({ workspace: null, repositories: [] }))
+ .then((nextState) => {
+ if (!cancelled) setState(nextState);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ if (!state) return null;
+ return (
+
+ );
+}
+
+export function WorkspaceWorkflowsRoute({ workspaceId }: { workspaceId: string }): ReactNode {
+ const [state, setState] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setState(null);
+
+ loadWorkspaceWorkflowsRoute(workspaceId)
+ .catch(() => ({ workspace: null, workflows: [], workflowTemplates: [] }))
+ .then((nextState) => {
+ if (!cancelled) setState(nextState);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ if (!state) return null;
+ return (
+
+ );
+}
+
+async function loadWorkspaceRepositoriesRoute(
+ workspaceId: string,
+): Promise {
+ const [workspace, repoResponse] = await Promise.all([
+ fetchJson(`/api/v1/workspaces/${workspaceId}`, { cache: "no-store" }),
+ listRepositories(workspaceId, { includeScripts: true }, { cache: "no-store" }),
+ ]);
+
+ return {
+ workspace,
+ repositories: repoResponse.repositories.map((repository) => ({
+ ...repository,
+ scripts: repository.scripts ?? [],
+ })),
+ };
+}
+
+async function loadWorkspaceWorkflowsRoute(
+ workspaceId: string,
+): Promise {
+ const [workspace, workflowResponse, templateResponse] = await Promise.all([
+ fetchJson(`/api/v1/workspaces/${workspaceId}`, { cache: "no-store" }),
+ listWorkflows(workspaceId, { cache: "no-store" }),
+ listWorkflowTemplates({ cache: "no-store" }),
+ ]);
+
+ return {
+ workspace,
+ workflows: workflowResponse.workflows ?? [],
+ workflowTemplates: templateResponse.templates ?? [],
+ };
+}
diff --git a/docs/specs/integrations/enable-disable-toggle.md b/docs/specs/integrations/enable-disable-toggle.md
index d493d95743..5d456fa985 100644
--- a/docs/specs/integrations/enable-disable-toggle.md
+++ b/docs/specs/integrations/enable-disable-toggle.md
@@ -43,10 +43,12 @@ this spec was written and is out of scope.)
integrations from left panel navigation"**, disabled (off) by default.
- When that setting is **off** (default), a disabled-but-configured
integration MUST still appear in the left panel navigation (the sidebar's
- Integrations section, its mobile-menu equivalent, and the Settings left
- panel's per-workspace Integrations list under Settings → Workspaces →
- ) exactly as it does today for an enabled integration — only
- credential/health status controls nav visibility.
+ Integrations section, its mobile-menu equivalent, and — whenever a tree
+ menu mode renders it; the default flat menu lists no per-workspace rows at
+ all — the Settings left panel's per-workspace Integrations list under
+ Settings → Workspaces → ) exactly as it does today for an
+ enabled integration — only credential/health status controls nav
+ visibility.
- When that setting is **on**, a disabled integration MUST be hidden from all
of those left-panel surfaces regardless of its credential/health status. An
enabled, healthy integration is unaffected. Only integrations whose
@@ -150,8 +152,10 @@ toggle).
- No integration ever hides from a *settings* page based on the new nav
setting — `hideDisabled` affects only left-panel navigation surfaces
(`useNavAvailability`'s output for the main sidebar and mobile menu, and
- `WorkspaceIntegrationItems`' filter for the Settings left panel's
- per-workspace Integrations tree), never a settings route's reachability.
+ the settings menu's visible-slug filter — `useVisibleIntegrationSlugs` in
+ `use-settings-menu-branches.ts` feeding `integrationNodes` — for the
+ Settings left panel's per-workspace Integrations branch), never a settings
+ route's reachability.
A user can always reach a disabled integration's own settings page
directly (by URL, from the index page, or from the sidebar's Settings
section) and re-enable it even while the left-panel nav entry is hidden.