diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx
index 01af5b9fb0..fba2c2d7de 100644
--- a/apps/web/app/settings/integrations/github/page.tsx
+++ b/apps/web/app/settings/integrations/github/page.tsx
@@ -2,7 +2,13 @@ import { GitHubIntegrationPage } from "@/components/github/github-settings";
import { StateHydrator } from "@/components/state-hydrator";
import { fetchGitHubStatus } from "@/lib/api/domains/github-api";
-export default async function IntegrationsGitHubPage() {
+type IntegrationsGitHubPageProps = {
+ workspaceId?: string;
+};
+
+export default async function IntegrationsGitHubPage({
+ workspaceId,
+}: IntegrationsGitHubPageProps = {}) {
const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null);
const initialState = status
? {
@@ -16,7 +22,7 @@ export default async function IntegrationsGitHubPage() {
return (
<>
-
+
>
);
}
diff --git a/apps/web/app/settings/integrations/gitlab/page.tsx b/apps/web/app/settings/integrations/gitlab/page.tsx
index b38aa35ce4..35f0ed87eb 100644
--- a/apps/web/app/settings/integrations/gitlab/page.tsx
+++ b/apps/web/app/settings/integrations/gitlab/page.tsx
@@ -1,5 +1,9 @@
import { GitLabIntegrationPage } from "@/components/gitlab/gitlab-settings";
-export default function IntegrationsGitLabPage() {
- return ;
+type IntegrationsGitLabPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsGitLabPage({ workspaceId }: IntegrationsGitLabPageProps = {}) {
+ return ;
}
diff --git a/apps/web/app/settings/integrations/jira/page.tsx b/apps/web/app/settings/integrations/jira/page.tsx
index b1b4e7a40f..978508e90a 100644
--- a/apps/web/app/settings/integrations/jira/page.tsx
+++ b/apps/web/app/settings/integrations/jira/page.tsx
@@ -1,5 +1,9 @@
import { JiraIntegrationPage } from "@/components/jira/jira-settings";
-export default function IntegrationsJiraPage() {
- return ;
+type IntegrationsJiraPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsJiraPage({ workspaceId }: IntegrationsJiraPageProps = {}) {
+ return ;
}
diff --git a/apps/web/app/settings/integrations/linear/page.tsx b/apps/web/app/settings/integrations/linear/page.tsx
index 41ff686e62..604140826e 100644
--- a/apps/web/app/settings/integrations/linear/page.tsx
+++ b/apps/web/app/settings/integrations/linear/page.tsx
@@ -1,5 +1,9 @@
import { LinearIntegrationPage } from "@/components/linear/linear-settings";
-export default function IntegrationsLinearPage() {
- return ;
+type IntegrationsLinearPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsLinearPage({ workspaceId }: IntegrationsLinearPageProps = {}) {
+ return ;
}
diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx
index 4eb2656bcf..8180ffaa62 100644
--- a/apps/web/app/settings/integrations/page.tsx
+++ b/apps/web/app/settings/integrations/page.tsx
@@ -11,44 +11,52 @@ import { Card, CardContent } from "@kandev/ui/card";
const INTEGRATIONS = [
{
- href: "/settings/integrations/github",
+ slug: "github",
label: "GitHub",
description: "PR review queues, issue watchers, and OAuth credentials.",
Icon: IconBrandGithub,
},
{
- href: "/settings/integrations/gitlab",
+ slug: "gitlab",
label: "GitLab",
description: "Merge request creation, discussion replies, and self-managed hosts.",
Icon: IconBrandGitlab,
},
{
- href: "/settings/integrations/jira",
+ slug: "jira",
label: "Jira",
description: "Atlassian Cloud credentials and JQL issue watchers.",
Icon: IconTicket,
},
{
- href: "/settings/integrations/linear",
+ slug: "linear",
label: "Linear",
description: "Personal API key and team defaults.",
Icon: IconHexagon,
},
{
- href: "/settings/integrations/sentry",
+ slug: "sentry",
label: "Sentry",
description: "Auth token, org/project defaults, and issue browsing.",
Icon: IconBrandSentry,
},
{
- href: "/settings/integrations/slack",
+ slug: "slack",
label: "Slack",
description: "Browser-session credentials and !kandev triage agent.",
Icon: IconBrandSlack,
},
];
-export default function IntegrationsIndexPage() {
+type IntegrationsIndexPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsIndexPage({ workspaceId }: IntegrationsIndexPageProps = {}) {
+ const rootHref = workspaceId
+ ? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations`
+ : "/settings/integrations";
+
return (
@@ -59,19 +67,22 @@ export default function IntegrationsIndexPage() {
- {INTEGRATIONS.map(({ href, label, description, Icon }) => (
-
-
-
-
-
- {label}
-
- {description}
-
-
-
- ))}
+ {INTEGRATIONS.map(({ slug, label, description, Icon }) => {
+ const href = `${rootHref}/${slug}`;
+ return (
+
+
+
+
+
+ {label}
+
+ {description}
+
+
+
+ );
+ })}
);
diff --git a/apps/web/app/settings/integrations/sentry/page.tsx b/apps/web/app/settings/integrations/sentry/page.tsx
index 1977cca074..53d05fd942 100644
--- a/apps/web/app/settings/integrations/sentry/page.tsx
+++ b/apps/web/app/settings/integrations/sentry/page.tsx
@@ -1,5 +1,9 @@
import { SentryIntegrationPage } from "@/components/sentry/sentry-settings";
-export default function IntegrationsSentryPage() {
- return ;
+type IntegrationsSentryPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsSentryPage({ workspaceId }: IntegrationsSentryPageProps = {}) {
+ return ;
}
diff --git a/apps/web/app/settings/integrations/slack/page.tsx b/apps/web/app/settings/integrations/slack/page.tsx
index b4f8a43924..d50bb4057f 100644
--- a/apps/web/app/settings/integrations/slack/page.tsx
+++ b/apps/web/app/settings/integrations/slack/page.tsx
@@ -1,5 +1,9 @@
import { SlackIntegrationPage } from "@/components/slack/slack-settings";
-export default function IntegrationsSlackPage() {
- return ;
+type IntegrationsSlackPageProps = {
+ workspaceId?: string;
+};
+
+export default function IntegrationsSlackPage({ workspaceId }: IntegrationsSlackPageProps = {}) {
+ return ;
}
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index b3f7e1c459..28aa3aeab7 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -45,7 +45,6 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode })
const workspaceItems = workspaces.workspaces.map(mapWorkspaceItem);
const activeWorkspaceId = resolveSettingsActiveWorkspaceId(
workspaceItems,
- null,
// `readCookies()` is client-only in this path; during SSR this is empty.
// Settings active workspace selection is completed on first client render by
// spa-routes via `readActiveWorkspaceCookie()`.
diff --git a/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx b/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx
deleted file mode 100644
index 3e34d66f2d..0000000000
--- a/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client";
-
-import {
- IconBrandGithub,
- IconBrandGitlab,
- IconBrandSentry,
- IconBrandSlack,
- IconHexagon,
- IconPlugConnected,
- IconTicket,
-} from "@tabler/icons-react";
-import type { Icon as TablerIcon } from "@tabler/icons-react";
-import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
-
-const ROOT_HREF = "/settings/integrations";
-
-const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
- { href: `${ROOT_HREF}/github`, label: "GitHub", icon: IconBrandGithub },
- { href: `${ROOT_HREF}/gitlab`, label: "GitLab", icon: IconBrandGitlab },
- { href: `${ROOT_HREF}/jira`, label: "Jira", icon: IconTicket },
- { href: `${ROOT_HREF}/linear`, label: "Linear", icon: IconHexagon },
- { href: `${ROOT_HREF}/sentry`, label: "Sentry", icon: IconBrandSentry },
- { href: `${ROOT_HREF}/slack`, label: "Slack", icon: IconBrandSlack },
-];
-
-type IntegrationsGroupProps = {
- pathname: string;
- expanded?: boolean;
- onToggle?: () => void;
-};
-
-export function IntegrationsGroup({ pathname, expanded, onToggle }: IntegrationsGroupProps) {
- return (
-
- {ITEMS.map(({ href, label, icon }) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx
index fa8619cceb..41e95b7e75 100644
--- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx
+++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx
@@ -3,7 +3,7 @@
import Link from "@/components/routing/app-link";
import { IconChevronRight } from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
-import { useState, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
import { cn } from "@/lib/utils";
import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants";
@@ -22,8 +22,8 @@ type SettingsLeafProps = {
depth?: number;
};
-const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5"] as const;
-const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1"] as const;
+const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5", "pl-[52px] pr-2.5"] as const;
+const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1", "pl-[52px] pr-1"] as const;
const NAV_FOCUS_CLASS =
"outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1";
@@ -59,6 +59,7 @@ export function SettingsLeaf({
type SettingsGroupProps = {
label: string;
+ labelSuffix?: ReactNode;
icon?: TablerIcon;
/** When the group itself has a destination, the label area is also a link. */
href?: string;
@@ -78,6 +79,7 @@ type SettingsGroupProps = {
export function SettingsGroup({
label,
+ labelSuffix,
icon: Icon,
href,
isActive,
@@ -91,6 +93,11 @@ export function SettingsGroup({
const isControlled = controlledExpanded !== undefined;
const expanded = isControlled ? controlledExpanded : internalExpanded;
const paddingClass = GROUP_DEPTH_PADDING[clampDepth(depth, GROUP_DEPTH_PADDING.length - 1)];
+
+ useEffect(() => {
+ if (!isControlled) setInternalExpanded(defaultExpanded);
+ }, [defaultExpanded, isControlled]);
+
const toggle = () => {
if (isControlled) onToggle?.();
else setInternalExpanded((v) => !v);
@@ -100,6 +107,7 @@ export function SettingsGroup({
<>
{Icon && }
{label}
+ {labelSuffix}
>
);
diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx
index f602abf367..29bab57cf6 100644
--- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx
+++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,8 +9,10 @@ const ARCHIVE_WORKSPACE_NAME = "Archive Workspace";
const state = {
workspaces: {
+ activeId: MAIN_WORKSPACE_ID,
items: [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }],
},
+ setActiveWorkspace: vi.fn(),
settingsAgents: {
items: [],
},
@@ -45,7 +47,9 @@ import { WorkspacesGroup } from "./workspaces-group";
describe("SettingsTree rendering", () => {
beforeEach(() => {
+ state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
+ state.setActiveWorkspace.mockClear();
state.settingsAgents.items = [];
state.executors.items = [];
});
@@ -61,9 +65,54 @@ describe("SettingsTree rendering", () => {
expect(screen.getByRole("link", { name: "Workflows" }).getAttribute("href")).toBe(
"/settings/workspace/ws-1/workflows",
);
+ expect(screen.getByRole("link", { name: "Automations" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-1/automations",
+ );
+ expect(screen.getByRole("link", { name: "Integrations" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-1/integrations",
+ );
});
- it("only opens the active workspace subsection on workspace detail routes", () => {
+ it("opens the active workspace by default when the settings tree opens", () => {
+ state.workspaces.items = [
+ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
+ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
+ ];
+
+ render();
+
+ expect(screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` })).toBeTruthy();
+ expect(screen.getByRole("link", { name: ARCHIVE_WORKSPACE_NAME })).toBeTruthy();
+ expect(screen.queryByText("[active]")).toBeNull();
+ expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-1/repositories",
+ );
+ expect(screen.getByRole("link", { name: "Automations" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-1/automations",
+ );
+ });
+
+ it("uses an accordion for workspace subsections", () => {
+ state.workspaces.items = [
+ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
+ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
+ ];
+
+ render();
+
+ expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-1/repositories",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Expand Archive Workspace" }));
+
+ expect(screen.getByRole("button", { name: "Expand Main Workspace" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-10/repositories",
+ );
+ });
+
+ it("only opens the routed workspace subsection on workspace detail routes", () => {
state.workspaces.items = [
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
@@ -71,13 +120,13 @@ describe("SettingsTree rendering", () => {
const { rerender } = render();
- expect(screen.getAllByRole("link", { name: "Repositories" })).toHaveLength(2);
+ expect(screen.getAllByRole("link", { name: "Repositories" })).toHaveLength(1);
rerender();
- expect(screen.getByRole("link", { name: MAIN_WORKSPACE_NAME }).getAttribute("href")).toBe(
- "/settings/workspace/ws-1",
- );
+ expect(
+ screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` }).getAttribute("href"),
+ ).toBe("/settings/workspace/ws-1");
const repositoryLinks = screen.getAllByRole("link", { name: "Repositories" });
const workflowLinks = screen.getAllByRole("link", { name: "Workflows" });
@@ -90,6 +139,20 @@ describe("SettingsTree rendering", () => {
);
});
+ it("opens workspace integrations when a workspace integration route is active", () => {
+ state.workspaces.items = [
+ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
+ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
+ ];
+
+ render();
+
+ expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe(
+ "/settings/workspace/ws-10/integrations/github",
+ );
+ expect(screen.getByRole("button", { name: "Expand Main Workspace" })).toBeTruthy();
+ });
+
it("keeps Voice Mode in the settings tree as a standalone active leaf", () => {
render();
@@ -107,3 +170,70 @@ describe("SettingsTree rendering", () => {
expect(screen.queryByRole("link", { name: "Appearance" })).toBeNull();
});
});
+
+describe("WorkspacesGroup active workspace presentation", () => {
+ beforeEach(() => {
+ state.workspaces.activeId = MAIN_WORKSPACE_ID;
+ state.workspaces.items = [
+ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
+ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
+ ];
+ state.setActiveWorkspace.mockClear();
+ });
+
+ afterEach(() => cleanup());
+
+ it("keeps the active workspace first even when the API returns it later", () => {
+ render();
+
+ const workspaceLinks = getWorkspaceRootLinks();
+
+ expect(workspaceLinks.map((link) => link.textContent)).toEqual([
+ `${MAIN_WORKSPACE_NAME}Active`,
+ ARCHIVE_WORKSPACE_NAME,
+ ]);
+ });
+
+ it("expands another workspace without changing the active workspace", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Expand Archive Workspace" }));
+
+ expect(state.setActiveWorkspace).not.toHaveBeenCalled();
+ expect(getWorkspaceRootLinks()[0].textContent).toBe(`${MAIN_WORKSPACE_NAME}Active`);
+ expect(screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` })).toBeTruthy();
+ });
+});
+
+describe("WorkspacesGroup integration route sync", () => {
+ beforeEach(() => {
+ state.workspaces.activeId = MAIN_WORKSPACE_ID;
+ state.workspaces.items = [
+ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
+ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
+ ];
+ });
+
+ afterEach(() => cleanup());
+
+ it("opens workspace integrations after navigating into an integration route", async () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
+
+ rerender();
+
+ expect((await screen.findByRole("link", { name: "GitHub" })).getAttribute("href")).toBe(
+ "/settings/workspace/ws-10/integrations/github",
+ );
+ });
+});
+
+function getWorkspaceRootLinks(): HTMLAnchorElement[] {
+ return screen.getAllByRole("link").filter((link): link is HTMLAnchorElement => {
+ const href = link.getAttribute("href");
+ return Boolean(href?.match(/^\/settings\/workspace\/[^/]+$/));
+ });
+}
diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts
index 1efcd65688..5792ff36fd 100644
--- a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts
+++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts
@@ -1,12 +1,11 @@
import { describe, it, expect } from "vitest";
-import { settingsGroupIdForPath } from "./settings-tree";
+import { settingsGroupIdForPath, settingsOpenGroupIdForPath } from "./settings-tree";
describe("settingsGroupIdForPath", () => {
it("maps a group root to its id", () => {
expect(settingsGroupIdForPath("/settings/executors")).toBe("executors");
expect(settingsGroupIdForPath("/settings/agents")).toBe("agents");
expect(settingsGroupIdForPath("/settings/general")).toBe("general");
- expect(settingsGroupIdForPath("/settings/integrations")).toBe("integrations");
expect(settingsGroupIdForPath("/settings/system")).toBe("system");
expect(settingsGroupIdForPath("/settings/workspace")).toBe("workspaces");
});
@@ -14,6 +13,9 @@ describe("settingsGroupIdForPath", () => {
it("maps a nested path to its owning group", () => {
expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors");
expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces");
+ expect(settingsGroupIdForPath("/settings/workspace/ws-1/integrations/github")).toBe(
+ "workspaces",
+ );
expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system");
expect(settingsGroupIdForPath("/settings/system/feature-toggles")).toBe("system");
// General subpages stay under /settings/general so they belong to General.
@@ -25,6 +27,8 @@ describe("settingsGroupIdForPath", () => {
it("returns null for standalone leaves with no owning group", () => {
expect(settingsGroupIdForPath("/settings/automations")).toBeNull();
+ expect(settingsGroupIdForPath("/settings/integrations")).toBeNull();
+ expect(settingsGroupIdForPath("/settings/integrations/github")).toBeNull();
expect(settingsGroupIdForPath("/settings/prompts")).toBeNull();
expect(settingsGroupIdForPath("/settings/voice-mode")).toBeNull();
expect(settingsGroupIdForPath("/settings/utility-agents")).toBeNull();
@@ -32,3 +36,16 @@ describe("settingsGroupIdForPath", () => {
expect(settingsGroupIdForPath("/settings")).toBeNull();
});
});
+
+describe("settingsOpenGroupIdForPath", () => {
+ it("defaults the settings tree to Workspaces when no other group owns the route", () => {
+ expect(settingsOpenGroupIdForPath("/")).toBe("workspaces");
+ expect(settingsOpenGroupIdForPath("/settings")).toBe("workspaces");
+ expect(settingsOpenGroupIdForPath("/settings/integrations/github")).toBe("workspaces");
+ });
+
+ it("keeps routed groups open when a route belongs to a different settings group", () => {
+ expect(settingsOpenGroupIdForPath("/settings/general")).toBe("general");
+ expect(settingsOpenGroupIdForPath("/settings/system/status")).toBe("system");
+ });
+});
diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx
index 13fa7b413f..328ad63344 100644
--- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx
+++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import {
- IconBolt,
IconKey,
IconMessageCircle,
IconMicrophone,
@@ -12,17 +11,16 @@ import {
import { AgentsGroup } from "./agents-group";
import { ExecutorsGroup } from "./executors-group";
import { GeneralGroup } from "./general-group";
-import { IntegrationsGroup } from "./integrations-group";
import { SettingsLeaf } from "./settings-nav-primitives";
import { SystemGroup } from "./system-group";
import { WorkspacesGroup } from "./workspaces-group";
-const AUTOMATIONS_HREF = "/settings/automations";
const PROMPTS_HREF = "/settings/prompts";
const VOICE_MODE_HREF = "/settings/voice-mode";
const UTILITY_HREF = "/settings/utility-agents";
const SECRETS_HREF = "/settings/general/secrets";
const EXT_MCP_HREF = "/settings/external-mcp";
+const DEFAULT_OPEN_GROUP = "workspaces";
// Single-open accordion: each top-level group owns a route prefix. The group
// whose prefix matches the current path is the open one. Prefixes are disjoint,
@@ -30,7 +28,6 @@ const EXT_MCP_HREF = "/settings/external-mcp";
const GROUP_ROUTES = [
{ id: "general", prefix: "/settings/general" },
{ id: "workspaces", prefix: "/settings/workspace" },
- { id: "integrations", prefix: "/settings/integrations" },
{ id: "agents", prefix: "/settings/agents" },
{ id: "executors", prefix: "/settings/executors" },
{ id: "system", prefix: "/settings/system" },
@@ -41,21 +38,27 @@ export function settingsGroupIdForPath(pathname: string): string | null {
return GROUP_ROUTES.find((g) => pathname.startsWith(g.prefix))?.id ?? null;
}
+export function settingsOpenGroupIdForPath(pathname: string): string {
+ return settingsGroupIdForPath(pathname) ?? DEFAULT_OPEN_GROUP;
+}
+
/**
* The settings nav tree. Top-level groups behave as a single-open accordion:
- * opening one closes the others and reveals its subsections. Navigating to a
- * standalone leaf (Prompts, Automations, …) closes every group.
+ * opening one closes the others and reveals its subsections. Routes without a
+ * top-level group open Workspaces so the active workspace settings stay visible.
*
* Rendered both inside the collapsible "Settings" sidebar section and, when the
* footer gear is active, as the full-height sidebar takeover.
*/
export function SettingsTree({ pathname }: { pathname: string }) {
- const [openGroup, setOpenGroup] = useState(() => settingsGroupIdForPath(pathname));
+ const [openGroup, setOpenGroup] = useState(() =>
+ settingsOpenGroupIdForPath(pathname),
+ );
// Re-sync when navigation lands on a different section so the open group
// always reflects the current page (a leaf with no owning group → all closed).
useEffect(() => {
- setOpenGroup(settingsGroupIdForPath(pathname));
+ setOpenGroup(settingsOpenGroupIdForPath(pathname));
}, [pathname]);
const groupProps = (id: string) => ({
@@ -67,13 +70,6 @@ export function SettingsTree({ pathname }: { pathname: string }) {
<>
-
-
= [
+ { slug: "github", label: "GitHub", icon: IconBrandGithub },
+ { slug: "gitlab", label: "GitLab", icon: IconBrandGitlab },
+ { slug: "jira", label: "Jira", icon: IconTicket },
+ { slug: "linear", label: "Linear", icon: IconHexagon },
+ { slug: "sentry", label: "Sentry", icon: IconBrandSentry },
+ { slug: "slack", label: "Slack", icon: IconBrandSlack },
+];
+
+const ACTIVE_WORKSPACE_LABEL = (
+
+ Active
+
+);
+
type WorkspacesGroupProps = {
pathname: string;
expanded?: boolean;
@@ -17,16 +46,42 @@ function isWorkspaceRoute(pathname: string, workspaceId: string): boolean {
return pathname === workspacePath || pathname.startsWith(`${workspacePath}/`);
}
+function activeWorkspaceIdFor(
+ workspaces: Array<{ id: string }>,
+ storeActiveWorkspaceId: string | null,
+): string | null {
+ return workspaces.some((workspace) => workspace.id === storeActiveWorkspaceId)
+ ? storeActiveWorkspaceId
+ : null;
+}
+
+function activeWorkspaceFirst(workspaces: T[], activeId: string | null) {
+ if (!activeId) return workspaces;
+ return [
+ ...workspaces.filter((workspace) => workspace.id === activeId),
+ ...workspaces.filter((workspace) => workspace.id !== activeId),
+ ];
+}
+
export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) {
const workspaces = useAppStore((s) => s.workspaces.items);
- const activeWorkspaceId =
+ const storeActiveWorkspaceId = useAppStore((s) => s.workspaces.activeId);
+ const routeWorkspaceId =
workspaces.find((workspace) => isWorkspaceRoute(pathname, workspace.id))?.id ?? null;
- const routeExpansionKey = activeWorkspaceId ?? "all";
- const hasActiveWorkspaceRoute = activeWorkspaceId !== null;
+ const activeWorkspaceId = activeWorkspaceIdFor(workspaces, storeActiveWorkspaceId);
+ const orderedWorkspaces = activeWorkspaceFirst(workspaces, activeWorkspaceId);
+ const defaultExpandedWorkspaceId = routeWorkspaceId ?? activeWorkspaceId ?? workspaces[0]?.id;
+ const [expandedWorkspaceId, setExpandedWorkspaceId] = useState(
+ defaultExpandedWorkspaceId ?? null,
+ );
- function shouldExpandWorkspace(workspaceId: string): boolean {
- return !hasActiveWorkspaceRoute || activeWorkspaceId === workspaceId;
- }
+ useEffect(() => {
+ setExpandedWorkspaceId(defaultExpandedWorkspaceId ?? null);
+ }, [defaultExpandedWorkspaceId]);
+
+ const toggleWorkspace = (workspaceId: string) => {
+ setExpandedWorkspaceId((current) => (current === workspaceId ? null : workspaceId));
+ };
return (
- {workspaces.map((workspace) => {
+ {orderedWorkspaces.map((workspace) => {
const workspacePath = `${ROOT_HREF}/${workspace.id}`;
const repositoriesPath = `${workspacePath}/repositories`;
const workflowsPath = `${workspacePath}/workflows`;
+ const automationsPath = `${workspacePath}/automations`;
+ const integrationsPath = `${workspacePath}/integrations`;
+ const integrationsActive =
+ pathname === integrationsPath || pathname.startsWith(`${integrationsPath}/`);
+ const workspaceIsActive = activeWorkspaceId === workspace.id;
return (
toggleWorkspace(workspace.id)}
depth={1}
>
+
+ {INTEGRATIONS.map(({ slug, label, icon }) => {
+ const href = `${integrationsPath}/${slug}`;
+ return (
+
+ );
+ })}
+
+
);
})}
diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx
index 466545703a..58581b49f7 100644
--- a/apps/web/components/github/github-settings.tsx
+++ b/apps/web/components/github/github-settings.tsx
@@ -307,12 +307,16 @@ function PerWorkspaceSection({ workspaceId }: { workspaceId: string }) {
);
}
-export function GitHubIntegrationPage() {
+type GitHubIntegrationPageProps = {
+ workspaceId?: string;
+};
+
+export function GitHubIntegrationPage({ workspaceId }: GitHubIntegrationPageProps = {}) {
return (
diff --git a/apps/web/components/gitlab/gitlab-settings.tsx b/apps/web/components/gitlab/gitlab-settings.tsx
index b07f6dabfa..51db73aab4 100644
--- a/apps/web/components/gitlab/gitlab-settings.tsx
+++ b/apps/web/components/gitlab/gitlab-settings.tsx
@@ -20,6 +20,7 @@ import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Spinner } from "@kandev/ui/spinner";
+import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import {
@@ -227,7 +228,19 @@ function ClearTokenButton({ onCleared }: { onCleared: () => void }) {
);
}
-export function GitLabIntegrationPage() {
+type GitLabIntegrationPageProps = {
+ workspaceId?: string;
+};
+
+export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProps = {}) {
+ return (
+
+ {(ws) => }
+
+ );
+}
+
+function GitLabConnectionSection() {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/apps/web/components/integrations/integration-copy-config.test.ts b/apps/web/components/integrations/integration-copy-config.test.ts
index 2632ed706c..917d925849 100644
--- a/apps/web/components/integrations/integration-copy-config.test.ts
+++ b/apps/web/components/integrations/integration-copy-config.test.ts
@@ -1,32 +1,20 @@
-import { describe, it, expect } from "vitest";
-import { integrationFromPathname, integrationLabel } from "./integration-copy-config";
+import { describe, expect, it } from "vitest";
+
+import { integrationFromPathname } from "./integration-copy-config";
describe("integrationFromPathname", () => {
- it("resolves each supported integration slug", () => {
- expect(integrationFromPathname("/settings/integrations/slack")).toBe("slack");
- expect(integrationFromPathname("/settings/integrations/jira")).toBe("jira");
- expect(integrationFromPathname("/settings/integrations/linear")).toBe("linear");
- expect(integrationFromPathname("/settings/integrations/sentry")).toBe("sentry");
+ it("recognizes global integration settings routes", () => {
expect(integrationFromPathname("/settings/integrations/github")).toBe("github");
+ expect(integrationFromPathname("/settings/integrations/linear")).toBe("linear");
});
- it("ignores trailing path segments and query-like suffixes", () => {
- expect(integrationFromPathname("/settings/integrations/slack/watchers")).toBe("slack");
+ it("recognizes workspace-scoped integration settings routes", () => {
+ expect(integrationFromPathname("/settings/workspace/ws-1/integrations/github")).toBe("github");
+ expect(integrationFromPathname("/settings/workspace/ws-1/integrations/linear")).toBe("linear");
});
- it("returns null for non-integration or unknown pages", () => {
- expect(integrationFromPathname("/settings/integrations")).toBeNull();
- expect(integrationFromPathname("/settings/agents")).toBeNull();
- expect(integrationFromPathname("/settings/integrations/unknown")).toBeNull();
- });
-});
-
-describe("integrationLabel", () => {
- it("uses the app's canonical spelling", () => {
- expect(integrationLabel("github")).toBe("GitHub");
- expect(integrationLabel("jira")).toBe("Jira");
- expect(integrationLabel("slack")).toBe("Slack");
- expect(integrationLabel("linear")).toBe("Linear");
- expect(integrationLabel("sentry")).toBe("Sentry");
+ it("ignores non-copyable integration routes", () => {
+ expect(integrationFromPathname("/settings/workspace/ws-1/integrations/gitlab")).toBeNull();
+ expect(integrationFromPathname("/settings/workspace/ws-1/integrations")).toBeNull();
});
});
diff --git a/apps/web/components/integrations/integration-copy-config.ts b/apps/web/components/integrations/integration-copy-config.ts
index 0dbe44f486..84e60ecad9 100644
--- a/apps/web/components/integrations/integration-copy-config.ts
+++ b/apps/web/components/integrations/integration-copy-config.ts
@@ -18,10 +18,11 @@ const integrationLabels: Record = {
};
// integrationFromPathname returns the integration slug for a settings pathname
-// like /settings/integrations/slack, or null when the current page is not a
-// copyable integration page.
+// like /settings/integrations/slack or
+// /settings/workspace//integrations/slack, or null when the current page is
+// not a copyable integration page.
export function integrationFromPathname(pathname: string): IntegrationSlug | null {
- const match = pathname.match(/^\/settings\/integrations\/([^/]+)/);
+ const match = pathname.match(/^\/settings(?:\/workspace\/[^/]+)?\/integrations\/([^/]+)/);
const slug = match?.[1];
if (slug && Object.prototype.hasOwnProperty.call(integrationLabels, slug)) {
return slug as IntegrationSlug;
diff --git a/apps/web/components/integrations/workspace-scoped-section.test.tsx b/apps/web/components/integrations/workspace-scoped-section.test.tsx
new file mode 100644
index 0000000000..fecb19cba5
--- /dev/null
+++ b/apps/web/components/integrations/workspace-scoped-section.test.tsx
@@ -0,0 +1,32 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const state = {
+ workspaces: {
+ activeId: "ws-active",
+ items: [
+ { id: "ws-active", name: "Active" },
+ { id: "ws-route", name: "Route" },
+ ],
+ },
+};
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
+}));
+
+import { WorkspaceScopedSection } from "./workspace-scoped-section";
+
+describe("WorkspaceScopedSection", () => {
+ afterEach(() => cleanup());
+
+ it("uses an explicit route workspace before the active workspace", () => {
+ render(
+
+ {(workspaceId) => {workspaceId}
}
+ ,
+ );
+
+ expect(screen.getByTestId("workspace-id").textContent).toBe("ws-route");
+ });
+});
diff --git a/apps/web/components/integrations/workspace-scoped-section.tsx b/apps/web/components/integrations/workspace-scoped-section.tsx
index 0d8223b06f..152fa3c776 100644
--- a/apps/web/components/integrations/workspace-scoped-section.tsx
+++ b/apps/web/components/integrations/workspace-scoped-section.tsx
@@ -6,20 +6,22 @@ import { useAppStore } from "@/components/state-provider";
type WorkspaceScopedSectionProps = {
emptyMessage?: string;
+ workspaceId?: string;
children: (workspaceId: string) => ReactNode;
};
// WorkspaceScopedSection renders per-workspace integration settings for the
-// workspace currently selected in the top-right settings switcher. The switcher
-// (settings-layout-client) is the single source of truth for which workspace is
-// being edited, so this component just reads the active workspace from the
-// store — it no longer renders its own selector.
-export function WorkspaceScopedSection({ emptyMessage, children }: WorkspaceScopedSectionProps) {
+// routed workspace when present, otherwise the active workspace in the store.
+export function WorkspaceScopedSection({
+ emptyMessage,
+ workspaceId,
+ children,
+}: WorkspaceScopedSectionProps) {
const workspaces = useAppStore((s) => s.workspaces.items);
const activeId = useAppStore((s) => s.workspaces.activeId);
- const selected = activeId ?? workspaces[0]?.id ?? null;
+ const selected = workspaceId ?? activeId ?? workspaces[0]?.id ?? null;
- if (workspaces.length === 0) {
+ if (!workspaceId && workspaces.length === 0) {
return (
diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx
index 498f944377..a8da3d377f 100644
--- a/apps/web/components/jira/jira-settings.tsx
+++ b/apps/web/components/jira/jira-settings.tsx
@@ -670,10 +670,10 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
);
}
-export function JiraIntegrationPage() {
+export function JiraIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) {
return (
-
+
{(workspaceId) => }
diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx
index da5c9ca6da..4b93f6efd9 100644
--- a/apps/web/components/linear/linear-settings.tsx
+++ b/apps/web/components/linear/linear-settings.tsx
@@ -445,10 +445,14 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
);
}
-export function LinearIntegrationPage() {
+type LinearIntegrationPageProps = {
+ workspaceId?: string;
+};
+
+export function LinearIntegrationPage({ workspaceId }: LinearIntegrationPageProps = {}) {
return (
-
+
{(workspaceId) => }
diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx
index e1a9639f07..f3003d0836 100644
--- a/apps/web/components/sentry/sentry-settings.tsx
+++ b/apps/web/components/sentry/sentry-settings.tsx
@@ -430,10 +430,14 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string }
);
}
-export function SentryIntegrationPage() {
+type SentryIntegrationPageProps = {
+ workspaceId?: string;
+};
+
+export function SentryIntegrationPage({ workspaceId }: SentryIntegrationPageProps = {}) {
return (
-
+
{(workspaceId) => }
diff --git a/apps/web/components/settings/settings-layout-client.test.tsx b/apps/web/components/settings/settings-layout-client.test.tsx
new file mode 100644
index 0000000000..d64cfb4e80
--- /dev/null
+++ b/apps/web/components/settings/settings-layout-client.test.tsx
@@ -0,0 +1,104 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let pathname = "/settings/integrations/github";
+const COPY_CONFIG_TEST_ID = "mock-copy-config";
+
+const state = {
+ workspaces: {
+ activeId: "ws-1",
+ items: [
+ { id: "ws-1", name: "Default" },
+ { id: "ws-2", name: "Archive" },
+ ],
+ },
+ setActiveWorkspace: vi.fn(),
+};
+
+vi.mock("@/lib/routing/client-router", () => ({
+ usePathname: () => pathname,
+ useRouter: () => ({ replace: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
+}));
+
+vi.mock("@/components/page-topbar", () => ({
+ PageTopbar: ({ actions }: { actions?: ReactNode }) => (
+ {actions}
+ ),
+}));
+
+vi.mock("@kandev/ui/tooltip", () => ({
+ TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+vi.mock("@/components/integrations/integration-copy-config-menu", () => ({
+ IntegrationCopyConfigMenu: ({ sourceWorkspaceId }: { sourceWorkspaceId: string }) => (
+
+ ),
+}));
+
+import { SettingsLayoutClient } from "./settings-layout-client";
+
+describe("SettingsLayoutClient integrations actions", () => {
+ beforeEach(() => {
+ pathname = "/settings/integrations/github";
+ state.workspaces.activeId = "ws-1";
+ state.setActiveWorkspace.mockClear();
+ });
+
+ afterEach(() => cleanup());
+
+ it("keeps copy config available without rendering the workspace switcher", () => {
+ render(
+
+ Settings page
+ ,
+ );
+
+ expect(screen.queryByTestId("integration-workspace-switcher")).toBeNull();
+ expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
+ });
+
+ it("shows copy config on workspace-scoped integration pages", () => {
+ pathname = "/settings/workspace/ws-1/integrations/github";
+
+ render(
+
+ Settings page
+ ,
+ );
+
+ 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";
+ state.workspaces.activeId = "ws-1";
+
+ render(
+
+ Settings page
+ ,
+ );
+
+ 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";
+ state.workspaces.activeId = "ws-1";
+
+ render(
+
+ Settings page
+ ,
+ );
+
+ expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
+ });
+});
diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx
index 8e2549cfe5..cbe35518de 100644
--- a/apps/web/components/settings/settings-layout-client.tsx
+++ b/apps/web/components/settings/settings-layout-client.tsx
@@ -1,16 +1,12 @@
"use client";
-import { useCallback, useEffect, useMemo } from "react";
-import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router";
+import { usePathname } from "@/lib/routing/client-router";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { PageTopbar } from "@/components/page-topbar";
import { useAppStore } from "@/components/state-provider";
-import { WorkspaceSwitcher } from "@/components/task/workspace-switcher";
-import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
import { IntegrationCopyConfigMenu } from "@/components/integrations/integration-copy-config-menu";
import { integrationFromPathname } from "@/components/integrations/integration-copy-config";
-
-const WORKSPACE_SYNC_FAILED_KEY = "kandev:settings:integration-workspace:sync-failed:v1";
+import { safeDecodePathSegment } from "@/lib/routing/path";
// Brand/initialism overrides so the derived label matches how the rest of the
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
@@ -79,7 +75,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }>
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents";
- const showWorkspaceSwitcher = pathname.startsWith("/settings/integrations");
+ const showIntegrationCopyAction = integrationFromPathname(pathname) !== null;
if (isAgentDetail) {
return (
@@ -88,7 +84,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
backHref="/settings/agents"
backLabel="Agents"
parents={[]}
- showWorkspaceSwitcher={showWorkspaceSwitcher}
+ showIntegrationCopyAction={showIntegrationCopyAction}
>
{children}
@@ -105,102 +101,55 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
backHref="/"
backLabel="Kandev"
parents={parents}
- showWorkspaceSwitcher={showWorkspaceSwitcher}
+ showIntegrationCopyAction={showIntegrationCopyAction}
>
{children}
);
}
-// useWorkspaceQueryParamSync keeps the top-right switcher and the `?workspace`
-// query param in agreement. On load (or when a shared deep link points at a
-// valid workspace), it adopts the query param as the active workspace without
-// persisting it as the user's global default. setWorkspaceParam writes the
-// param back when the user picks a workspace.
-function useWorkspaceQueryParamSync(
- workspaces: Array<{ id: string }>,
- activeId: string | null,
- setActiveWorkspace: (id: string) => void,
-) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const paramWorkspace = searchParams.get("workspace");
-
- useEffect(() => {
- if (!paramWorkspace || paramWorkspace === activeId) return;
- if (!workspaces.some((w) => w.id === paramWorkspace)) return;
- setActiveWorkspace(paramWorkspace);
- }, [paramWorkspace, activeId, workspaces, setActiveWorkspace]);
-
- const setWorkspaceParam = useCallback(
- (workspaceId: string) => {
- const next = new URLSearchParams(window.location.search);
- next.set("workspace", workspaceId);
- router.replace(`${window.location.pathname}?${next.toString()}`, { scroll: false });
- },
- [router],
- );
-
- return setWorkspaceParam;
-}
-
-function IntegrationWorkspaceSwitcher() {
+function IntegrationCopyConfigAction() {
const pathname = usePathname();
const workspaces = useAppStore((s) => s.workspaces.items);
const activeId = useAppStore((s) => s.workspaces.activeId);
- const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace);
- const selected = activeId ?? workspaces[0]?.id ?? null;
+ const routeWorkspaceId = workspaceIdFromPathname(pathname);
+ const selected =
+ routeWorkspaceId && workspaces.some((workspace) => workspace.id === routeWorkspaceId)
+ ? routeWorkspaceId
+ : (activeId ?? workspaces[0]?.id ?? null);
const integration = integrationFromPathname(pathname);
- const persistWorkspace = useMemo(
- () =>
- createQueuedUserSettingsSync(WORKSPACE_SYNC_FAILED_KEY, (workspaceId) => ({
- workspace_id: workspaceId,
- })),
- [],
- );
- const setWorkspaceParam = useWorkspaceQueryParamSync(workspaces, activeId, setActiveWorkspace);
- const onSelect = useCallback(
- (workspaceId: string) => {
- setActiveWorkspace(workspaceId);
- setWorkspaceParam(workspaceId);
- void persistWorkspace(workspaceId);
- },
- [persistWorkspace, setActiveWorkspace, setWorkspaceParam],
- );
-
- if (workspaces.length === 0) return null;
+ if (!integration || !selected || workspaces.length === 0) return null;
return (
-
-
- Editing workspace
-
-
- {integration && selected ? (
-
- ) : null}
+
+
);
}
+function workspaceIdFromPathname(pathname: string): string | null {
+ const match = pathname.match(/^\/settings\/workspace\/([^/]+)(?:\/|$)/);
+ return safeDecodePathSegment(match?.[1]);
+}
+
function SettingsShell({
title,
backHref,
backLabel,
parents,
- showWorkspaceSwitcher,
+ showIntegrationCopyAction,
children,
}: {
title: string;
backHref: string;
backLabel: string;
parents: Array<{ label: string; href: string }>;
- showWorkspaceSwitcher: boolean;
+ showIntegrationCopyAction: boolean;
children: React.ReactNode;
}) {
return (
@@ -212,7 +161,7 @@ function SettingsShell({
backLabel={backLabel}
parents={parents}
className="h-10"
- actions={showWorkspaceSwitcher ?
: undefined}
+ 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. */}
diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx
index eed54a1603..3df37fc3b5 100644
--- a/apps/web/components/slack/slack-settings.tsx
+++ b/apps/web/components/slack/slack-settings.tsx
@@ -603,10 +603,14 @@ export function SlackConnectionSection({ workspaceId }: { workspaceId: string })
);
}
-export function SlackIntegrationPage() {
+type SlackIntegrationPageProps = {
+ workspaceId?: string;
+};
+
+export function SlackIntegrationPage({ workspaceId }: SlackIntegrationPageProps = {}) {
return (
-
+
{(workspaceId) => }
diff --git a/apps/web/e2e/pages/linear-settings-page.ts b/apps/web/e2e/pages/linear-settings-page.ts
index 652861c0c2..1bcb759d2b 100644
--- a/apps/web/e2e/pages/linear-settings-page.ts
+++ b/apps/web/e2e/pages/linear-settings-page.ts
@@ -6,8 +6,6 @@ export class LinearSettingsPage {
readonly saveButton: Locator;
readonly deleteButton: Locator;
readonly statusBanner: Locator;
- readonly switcher: Locator;
- readonly workspaceTrigger: Locator;
readonly copyConfigTrigger: Locator;
readonly copyConfigTarget: Locator;
readonly copyConfigConfirm: Locator;
@@ -18,8 +16,6 @@ export class LinearSettingsPage {
this.saveButton = page.getByTestId("linear-save-button");
this.deleteButton = page.getByTestId("linear-delete-button");
this.statusBanner = page.getByTestId("integration-auth-status-banner");
- this.switcher = page.getByTestId("integration-workspace-switcher");
- this.workspaceTrigger = this.switcher.getByTitle("Switch Workspace");
this.copyConfigTrigger = page.getByTestId("integration-copy-config-trigger");
this.copyConfigTarget = page.getByTestId("integration-copy-config-target");
this.copyConfigConfirm = page.getByTestId("integration-copy-config-confirm");
@@ -29,4 +25,9 @@ export class LinearSettingsPage {
await this.page.goto(`/settings/integrations/linear${query}`);
await this.secretInput.waitFor({ state: "visible" });
}
+
+ async gotoWorkspace(workspaceId: string) {
+ await this.page.goto(`/settings/workspace/${workspaceId}/integrations/linear`);
+ await this.secretInput.waitFor({ state: "visible" });
+ }
}
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 2c2c949494..6cf372cf22 100644
--- a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts
@@ -35,8 +35,8 @@ test.describe("GitHub workspace settings", () => {
},
]);
- await testPage.goto("/settings/integrations/github");
- await expect(testPage.getByTestId("integration-workspace-switcher")).toBeVisible();
+ await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
+ await expect(testPage.getByTestId("integration-workspace-switcher")).toHaveCount(0);
await testPage.getByTestId("github-scope-mode").click();
await testPage.getByRole("option", { name: "Selected repositories" }).click();
diff --git a/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts b/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts
index 290d7267b9..96915a741b 100644
--- a/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts
+++ b/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts
@@ -40,9 +40,7 @@ async function integrationCardIconTopInsets(cards: Locator[]) {
}
async function integrationCards(page: Page): Promise
{
- const links = page
- .getByTestId("settings-scroll-container")
- .locator('a[href^="/settings/integrations/"]');
+ const links = page.getByTestId("settings-scroll-container").locator('a[href*="/integrations/"]');
await expect(links.first()).toBeVisible();
const count = await links.count();
expect(count).toBeGreaterThan(0);
diff --git a/apps/web/e2e/tests/integrations/linear-settings.spec.ts b/apps/web/e2e/tests/integrations/linear-settings.spec.ts
index 85a1a51bd8..ad8a8465db 100644
--- a/apps/web/e2e/tests/integrations/linear-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/linear-settings.spec.ts
@@ -45,7 +45,10 @@ test.describe("Linear settings", () => {
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
});
- test("workspace selector scopes the saved credentials form", async ({ testPage, apiClient }) => {
+ test("workspace-scoped route scopes the saved credentials form", async ({
+ testPage,
+ apiClient,
+ }) => {
const other = await apiClient.createWorkspace("Linear Secondary Workspace");
await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]);
@@ -57,8 +60,7 @@ test.describe("Linear settings", () => {
await expect(settings.deleteButton).toBeVisible();
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
- await settings.workspaceTrigger.click();
- await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
+ await settings.gotoWorkspace(other.id);
await expect(settings.secretInput).toHaveValue("");
await expect(settings.saveButton).toBeDisabled();
@@ -66,35 +68,36 @@ test.describe("Linear settings", () => {
await expect(testPage.getByText(/leave blank to keep the current value/i)).toHaveCount(0);
});
- test("a ?workspace deep link adopts that workspace on load", async ({ testPage, apiClient }) => {
+ test("a workspace-scoped deep link adopts that workspace on load", async ({
+ testPage,
+ apiClient,
+ }) => {
const other = await apiClient.createWorkspace("Linear Deep Link Workspace");
// Seed the secondary workspace so the deep link lands on a configured row,
- // proving the query param — not the user's global default — drove selection.
+ // proving the route path — not the user's global default — drove selection.
await apiClient.setLinearConfig({ secret: "lin_api_deeplink", workspaceId: other.id });
const settings = new LinearSettingsPage(testPage);
- await settings.goto(`?workspace=${other.id}`);
+ await settings.gotoWorkspace(other.id);
- await expect(settings.switcher).toContainText("Editing workspace");
- await expect(settings.switcher).toContainText(other.name);
// The seeded row loads, confirming the deep link scoped the form to `other`.
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
});
- test("selecting a workspace writes it to the ?workspace query param", async ({
+ test("workspace-scoped integration route keeps the workspace in the path", async ({
testPage,
apiClient,
}) => {
- const other = await apiClient.createWorkspace("Linear Query Param Workspace");
+ const other = await apiClient.createWorkspace("Linear Path Workspace");
const settings = new LinearSettingsPage(testPage);
- await settings.goto();
+ await settings.gotoWorkspace(other.id);
- await settings.workspaceTrigger.click();
- await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
-
- await expect(testPage).toHaveURL(new RegExp(`[?&]workspace=${other.id}(&|$)`));
- await expect(settings.switcher).toContainText(other.name);
+ await expect(testPage).toHaveURL(
+ new RegExp(`/settings/workspace/${other.id}/integrations/linear$`),
+ );
+ await expect(testPage).not.toHaveURL(/[?&]workspace=/);
+ await expect(settings.secretInput).toHaveValue("");
});
test("copy config duplicates the credentials to another workspace", async ({
@@ -125,9 +128,8 @@ test.describe("Linear settings", () => {
const cfg = (await res.json()) as { hasSecret?: boolean };
expect(cfg.hasSecret).toBe(true);
- // Switching to the target in the UI shows the copied credentials loaded.
- await settings.workspaceTrigger.click();
- await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
+ // Opening the target workspace route shows the copied credentials loaded.
+ await settings.gotoWorkspace(other.id);
await expect(settings.deleteButton).toBeVisible();
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
});
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 79da3a56a4..86940d1e8a 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
@@ -2,8 +2,11 @@ import { test } from "../../fixtures/test-base";
import { expectStableIntegrationCardLayout } from "./integrations-index-layout-helpers";
test.describe("integrations settings index layout on mobile", () => {
- test("renders equal-height integration cards", async ({ testPage }) => {
- await testPage.goto("/settings/integrations");
+ test("renders equal-height integration cards on a workspace-scoped route", async ({
+ testPage,
+ seedData,
+ }) => {
+ await testPage.goto(`/settings/workspace/${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 5e457b21fb..8057439acf 100644
--- a/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts
+++ b/apps/web/e2e/tests/integrations/mobile-linear-settings.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
import { LinearSettingsPage } from "../../pages/linear-settings-page";
test.describe("Linear settings on mobile", () => {
- test("workspace selector scopes the credentials form", async ({ testPage, apiClient }) => {
+ test("workspace-scoped route scopes the credentials form", async ({ testPage, apiClient }) => {
const other = await apiClient.createWorkspace("Mobile Linear Workspace");
const settings = new LinearSettingsPage(testPage);
@@ -12,8 +12,7 @@ test.describe("Linear settings on mobile", () => {
await settings.saveButton.click();
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
- await settings.workspaceTrigger.click();
- await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
+ await settings.gotoWorkspace(other.id);
await expect(settings.secretInput).toHaveValue("");
await expect(settings.saveButton).toBeDisabled();
diff --git a/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts
new file mode 100644
index 0000000000..82755be540
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts
@@ -0,0 +1,18 @@
+import { test, expect } from "../../fixtures/test-base";
+
+test.describe("Mobile settings navigation", () => {
+ test("keeps the desktop settings sidebar hidden without leaking the old active text", async ({
+ testPage,
+ }) => {
+ await testPage.goto("/settings");
+
+ await expect(testPage.getByRole("link", { name: /Appearance/ })).toBeVisible();
+ await expect(testPage.getByRole("link", { name: /Terminal/ })).toBeVisible();
+ await expect(testPage.getByText("[active]")).toHaveCount(0);
+ await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
+
+ const takeover = testPage.getByTestId("app-sidebar-settings-mode");
+
+ await expect(takeover).toBeHidden();
+ });
+});
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 1b7be68041..91c83605aa 100644
--- a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
@@ -20,6 +20,13 @@ test.describe("Settings sidebar takeover", () => {
// 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.getByRole("link", { name: "Integrations" })).toHaveAttribute(
+ "href",
+ /\/settings\/workspace\/[^/]+\/integrations$/,
+ );
+ await expect(takeover.locator('a[href="/settings/integrations"]')).toHaveCount(0);
// Enter a section: navigates to a settings sub-page; takeover stays open.
await takeover.locator('a[href="/settings/agents"]').first().click();
diff --git a/apps/web/lib/routing/path.ts b/apps/web/lib/routing/path.ts
new file mode 100644
index 0000000000..f8be616bd0
--- /dev/null
+++ b/apps/web/lib/routing/path.ts
@@ -0,0 +1,8 @@
+export function safeDecodePathSegment(segment: string | undefined): string | null {
+ if (!segment) return null;
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/web/lib/routing/route-bootstrap.test.ts b/apps/web/lib/routing/route-bootstrap.test.ts
index b746a2b15f..fc6fb05154 100644
--- a/apps/web/lib/routing/route-bootstrap.test.ts
+++ b/apps/web/lib/routing/route-bootstrap.test.ts
@@ -79,25 +79,10 @@ describe("resolveSettingsActiveWorkspaceId", () => {
],
null,
null,
- null,
),
).toBe(kanbanOne);
});
- it("prefers explicit route workspace ID", () => {
- expect(
- resolveSettingsActiveWorkspaceId(
- [
- { id: "office-1", office_workflow_id: officeWorkflow },
- { id: kanbanOne, office_workflow_id: null },
- ],
- "office-1",
- null,
- null,
- ),
- ).toBe("office-1");
- });
-
it("uses active cookie when it matches a kanban workspace", () => {
expect(
resolveSettingsActiveWorkspaceId(
@@ -106,7 +91,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
{ id: kanbanOne, office_workflow_id: null },
{ id: kanbanTwo, office_workflow_id: null },
],
- null,
kanbanTwo,
null,
),
@@ -121,7 +105,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
{ id: kanbanOne, office_workflow_id: null },
{ id: kanbanTwo, office_workflow_id: null },
],
- null,
"ws-missing",
kanbanOne,
),
@@ -129,7 +112,7 @@ describe("resolveSettingsActiveWorkspaceId", () => {
});
it("returns null when no workspaces exist", () => {
- expect(resolveSettingsActiveWorkspaceId([], null, "k-1", "k-2")).toBeNull();
+ expect(resolveSettingsActiveWorkspaceId([], "k-1", "k-2")).toBeNull();
});
it("falls back to office workspace only when no kanban workspaces exist", () => {
@@ -139,7 +122,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
{ id: "office-1", office_workflow_id: officeWorkflow1 },
{ id: "office-2", office_workflow_id: officeWorkflow2 },
],
- null,
"missing",
null,
),
@@ -155,7 +137,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
],
null,
null,
- null,
),
).toBe(kanbanOne);
});
diff --git a/apps/web/lib/routing/route-bootstrap.ts b/apps/web/lib/routing/route-bootstrap.ts
index cacb43b5df..741fbc03ac 100644
--- a/apps/web/lib/routing/route-bootstrap.ts
+++ b/apps/web/lib/routing/route-bootstrap.ts
@@ -44,14 +44,12 @@ type SettingsWorkspaceItem = {
export function resolveSettingsActiveWorkspaceId(
workspaceItems: SettingsWorkspaceItem[],
- requestedWorkspaceId: string | null,
activeCookieWorkspaceId: string | null,
settingsWorkspaceId: string | null,
): string | null {
const kanbanWorkspaceItems = workspaceItems.filter((workspace) => !workspace.office_workflow_id);
return (
- workspaceItems.find((workspace) => workspace.id === requestedWorkspaceId)?.id ??
kanbanWorkspaceItems.find((workspace) => workspace.id === activeCookieWorkspaceId)?.id ??
kanbanWorkspaceItems.find((workspace) => workspace.id === settingsWorkspaceId)?.id ??
kanbanWorkspaceItems[0]?.id ??
diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts
index 5743f00321..7197dec4b5 100644
--- a/apps/web/src/settings-routes.test.ts
+++ b/apps/web/src/settings-routes.test.ts
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it } from "vitest";
+import { isValidElement, type ReactElement } from "react";
+import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
import { workspaceId, workflowId } from "@/lib/types/ids";
import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http";
-import { buildSettingsInitialStateForRoute } from "./settings-routes";
+import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes";
const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace";
const OWNER_ID = "owner-1";
const TIMESTAMP = "2026-01-01T00:00:00Z";
-const SETTINGS_ROUTE = "/settings/integrations";
describe("buildSettingsInitialStateForRoute", () => {
beforeEach(() => {
@@ -15,22 +16,20 @@ describe("buildSettingsInitialStateForRoute", () => {
});
describe("workspace selection", () => {
- it("prefers the workspace matching the URL path param", () => {
+ it("keeps the saved active workspace for settings hydration", () => {
const state = buildState({
- pathname: "/settings/workspace/ws-2/repositories",
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
});
- expect(state.workspaces?.activeId).toBe("ws-2");
- expect(state.userSettings?.workspaceId).toBe("ws-2");
+ expect(state.workspaces?.activeId).toBe("ws-1");
+ expect(state.userSettings?.workspaceId).toBe("ws-1");
});
it("keeps the active workspace cookie on global settings pages", () => {
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-2; path=/`;
const state = buildState({
- pathname: SETTINGS_ROUTE,
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
});
@@ -43,7 +42,6 @@ describe("buildSettingsInitialStateForRoute", () => {
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-office; path=/`;
const state = buildState({
- pathname: SETTINGS_ROUTE,
workspaces: [
buildWorkspace({ id: "ws-office", office_workflow_id: workflowId("office") }),
buildWorkspace({ id: "ws-kanban", office_workflow_id: null }),
@@ -57,9 +55,8 @@ describe("buildSettingsInitialStateForRoute", () => {
});
describe("fallbacks", () => {
- it("falls back to the settings workspace_id when no URL param matches", () => {
+ it("falls back to the settings workspace_id when no cookie matches", () => {
const state = buildState({
- pathname: "/settings/workspace/missing/repositories",
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-2") }),
});
@@ -68,9 +65,8 @@ describe("buildSettingsInitialStateForRoute", () => {
expect(state.userSettings?.workspaceId).toBe("ws-2");
});
- it("falls back to the first workspace when neither URL param nor settings match", () => {
+ it("falls back to the first workspace when neither cookie nor settings match", () => {
const state = buildState({
- pathname: "/settings/utility-agents",
workspaces: workspaceRows(["ws-1", "ws-2"]),
userSettingsResponse: userSettings({ workspace_id: workspaceId("missing") }),
});
@@ -113,11 +109,17 @@ describe("buildSettingsInitialStateForRoute", () => {
});
});
+describe("renderSettingsRoute", () => {
+ 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");
+ });
+});
+
function buildState(
overrides: Partial[0]> = {},
) {
return buildSettingsInitialStateForRoute({
- pathname: "/settings",
workspaces: [],
executors: [],
agents: [],
@@ -173,3 +175,12 @@ function userSettings(
},
};
}
+
+function gitLabRouteWorkspaceId(pathname: string): string | undefined {
+ const route = renderSettingsRoute(pathname);
+ if (!isValidElement(route)) {
+ throw new Error("expected GitLab integration route element");
+ }
+ expect(route.type).toBe(IntegrationsGitLabPage);
+ return (route as ReactElement<{ workspaceId?: string }>).props.workspaceId;
+}
diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx
index 7cfbdb6cbc..babf2c0814 100644
--- a/apps/web/src/settings-routes.tsx
+++ b/apps/web/src/settings-routes.tsx
@@ -66,6 +66,7 @@ import {
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 { safeDecodePathSegment } from "@/lib/routing/path";
import {
mapWorkspaceItem,
readActiveWorkspaceCookie,
@@ -97,7 +98,6 @@ type WorkspaceWorkflowsRouteState = {
workflowTemplates: WorkflowTemplate[];
};
type SettingsInitialStateData = {
- pathname: string;
workspaces: ListWorkspacesResponse["workspaces"];
executors: Awaited>["executors"];
agents: Awaited>["agents"];
@@ -136,13 +136,13 @@ const SETTINGS_ROUTES: Record = {
"/settings/external-mcp": () => ,
"/settings/prompts": () => ,
"/settings/voice-mode": () => ,
- "/settings/integrations": () => ,
- "/settings/integrations/github": () => ,
- "/settings/integrations/gitlab": () => ,
- "/settings/integrations/jira": () => ,
- "/settings/integrations/linear": () => ,
- "/settings/integrations/sentry": () => ,
- "/settings/integrations/slack": () => ,
+ "/settings/integrations": () => renderIntegrationSettingsRoute(null),
+ "/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"),
+ "/settings/integrations/slack": () => renderIntegrationSettingsRoute("slack"),
"/settings/system": () => ,
"/settings/system/about": () => (
@@ -218,44 +218,15 @@ export function settingsRouteKey(pathname: string): string {
return normalizeSettingsPath(pathname);
}
-function renderSettingsRoute(pathname: string) {
+export function renderSettingsRoute(pathname: string) {
const dynamicRoute = renderDynamicSettingsRoute(pathname);
if (dynamicRoute) return dynamicRoute;
return SETTINGS_ROUTES[pathname]?.() ?? ;
}
function renderDynamicSettingsRoute(pathname: string) {
- const workspaceAutomation = matchDouble(
- pathname,
- /^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/,
- );
- if (workspaceAutomation) {
- const [id, automationId] = workspaceAutomation;
- if (automationId === "new") {
- return ;
- }
- return ;
- }
-
- const workspaceSubpage = matchDouble(
- pathname,
- /^\/settings\/workspace\/([^/]+)\/(repositories|workflows|automations)$/,
- );
- if (workspaceSubpage) {
- const [id, section] = workspaceSubpage;
- if (section === "repositories") {
- return ;
- }
- if (section === "workflows") {
- return ;
- }
- return ;
- }
-
- const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/);
- if (workspaceId) {
- return ;
- }
+ const workspaceRoute = renderWorkspaceSettingsRoute(pathname);
+ if (workspaceRoute) return workspaceRoute;
const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/);
if (agentProfile) {
@@ -299,6 +270,73 @@ function renderDynamicSettingsRoute(pathname: string) {
return null;
}
+function renderWorkspaceSettingsRoute(pathname: string) {
+ const workspaceIntegration = pathname.match(
+ /^\/settings\/workspace\/([^/]+)\/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);
+ }
+
+ const workspaceAutomation = matchDouble(
+ pathname,
+ /^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/,
+ );
+ if (workspaceAutomation) {
+ const [id, automationId] = workspaceAutomation;
+ if (automationId === "new") {
+ return ;
+ }
+ return ;
+ }
+
+ const workspaceSubpage = matchDouble(
+ pathname,
+ /^\/settings\/workspace\/([^/]+)\/(repositories|workflows|automations)$/,
+ );
+ if (workspaceSubpage) {
+ const [id, section] = workspaceSubpage;
+ if (section === "repositories") {
+ return ;
+ }
+ if (section === "workflows") {
+ return ;
+ }
+ return ;
+ }
+
+ const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/);
+ if (workspaceId) {
+ return ;
+ }
+
+ return null;
+}
+
+function renderIntegrationSettingsRoute(section: string | null, workspaceId?: string) {
+ switch (section) {
+ case null:
+ return ;
+ case "github":
+ return ;
+ case "gitlab":
+ return ;
+ case "jira":
+ return ;
+ case "linear":
+ return ;
+ case "sentry":
+ return ;
+ case "slack":
+ return ;
+ default:
+ return null;
+ }
+}
+
function renderUpdatesRoute() {
return (
0) {
store.getState().hydrate(initialState);
}
@@ -346,7 +384,7 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
return null;
}
-async function loadSettingsInitialState(pathname: string): Promise> {
+async function loadSettingsInitialState(): Promise> {
const [workspaces, executors, agents, discovery, available, userSettingsResponse] =
await Promise.all([
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
@@ -358,7 +396,6 @@ async function loadSettingsInitialState(pathname: string): Promise