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 (
- + {(ws) => }
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