diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 0bce5bd96e..d8e732a27c 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -1,10 +1,12 @@ package controller import ( + "cmp" "context" "database/sql" "errors" "fmt" + "slices" "strings" "github.com/kandev/kandev/internal/agent/agents" @@ -47,9 +49,48 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e c.applyBillingType(&entry, agent.Name) payload = append(payload, entry) } + c.sortAgentsByDisplayOrder(payload) return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil } +// sortAgentsByDisplayOrder puts saved agents in the same order the rest of the +// app presents agents in — each agent implementation's DisplayOrder, which is +// also what GET /agents/discovery is sorted by. +// +// The store returns newest-configured-first, which is setup history rather than +// an order anyone chose. That reached the UI: the settings menu ranks agents by +// discovery, so until the scan lands it had nothing but this order to show and +// the list reshuffled underneath the reader. Sorting here means the order is +// already right the moment the agents arrive, scan or no scan. +// +// Agents the registry does not know (a removed CLI, a custom row) keep their +// store order after the ranked ones: the comparator groups them last and the +// stable sort leaves their relative order untouched. +func (c *Controller) sortAgentsByDisplayOrder(payload []dto.AgentDTO) { + if c.agentRegistry == nil { + return + } + known := c.agentRegistry.List() + rank := make(map[string]int, len(known)) + for _, ag := range known { + rank[ag.ID()] = ag.DisplayOrder() + } + slices.SortStableFunc(payload, func(a, b dto.AgentDTO) int { + aOrder, aKnown := rank[a.Name] + bOrder, bKnown := rank[b.Name] + if aKnown != bKnown { + if aKnown { + return -1 + } + return 1 + } + if !aKnown { + return 0 + } + return cmp.Compare(aOrder, bOrder) + }) +} + // filterGlobalProfiles drops workspace-scoped (office) rows from a profile // list so the kanban-facing GET /api/v1/agents endpoints only surface global // CLI profiles. Office agents live in the same agent_profiles table since diff --git a/apps/backend/internal/agent/settings/controller/agent_crud_order_test.go b/apps/backend/internal/agent/settings/controller/agent_crud_order_test.go new file mode 100644 index 0000000000..144c481cf9 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_crud_order_test.go @@ -0,0 +1,40 @@ +package controller + +import ( + "slices" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +type orderedTestAgent struct { + *testAgent + order int +} + +func (a *orderedTestAgent) DisplayOrder() int { return a.order } + +func TestSortAgentsByDisplayOrder_PutsUnknownAgentsLast(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{ + "early": &orderedTestAgent{testAgent: &testAgent{id: "early"}, order: 1}, + "late": &orderedTestAgent{testAgent: &testAgent{id: "late"}, order: 99}, + }) + payload := []dto.AgentDTO{ + {Name: "unknown-first"}, + {Name: "late"}, + {Name: "early"}, + {Name: "unknown-second"}, + } + + ctrl.sortAgentsByDisplayOrder(payload) + + got := make([]string, 0, len(payload)) + for _, agent := range payload { + got = append(got, agent.Name) + } + want := []string{"early", "late", "unknown-first", "unknown-second"} + if !slices.Equal(got, want) { + t.Fatalf("agent order = %v, want %v", got, want) + } +} diff --git a/apps/backend/internal/jira/poller_test.go b/apps/backend/internal/jira/poller_test.go index 4a65ece2d7..498eecbadd 100644 --- a/apps/backend/internal/jira/poller_test.go +++ b/apps/backend/internal/jira/poller_test.go @@ -57,10 +57,15 @@ func (f *pollerFixture) saveConfigForWorkspace(t *testing.T, workspaceID, secret }); err != nil { t.Fatalf("save config: %v", err) } - key := SecretKey - if workspaceID != "" { - key = SecretKeyForWorkspace(workspaceID) + secretWorkspaceID := workspaceID + if secretWorkspaceID == "" { + var err error + secretWorkspaceID, err = f.store.defaultWorkspaceID() + if err != nil { + t.Fatalf("resolve default workspace: %v", err) + } } + key := SecretKeyForWorkspace(secretWorkspaceID) if err := f.secrets.Set(ctx, key, "jira", secret); err != nil { t.Fatalf("save secret: %v", err) } diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index 750beb2a41..914cad78f3 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -680,6 +680,9 @@ func (s *Service) secretExists(ctx context.Context, workspaceID string) (bool, e if err != nil || exists { return exists, err } + if !s.legacySecretAppliesTo(workspaceID) { + return false, nil + } return s.secrets.Exists(ctx, SecretKey) } @@ -691,6 +694,9 @@ func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, if err == nil && secret != "" { return secret, nil } + if !s.legacySecretAppliesTo(workspaceID) { + return "", err + } legacy, legacyErr := s.secrets.Reveal(ctx, SecretKey) if legacyErr == nil && legacy != "" { return legacy, nil @@ -701,6 +707,11 @@ func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, return "", legacyErr } +func (s *Service) legacySecretAppliesTo(workspaceID string) bool { + target := s.store.MigratedFromWorkspace() + return target != "" && workspaceID == target +} + func validateConfigRequest(req *SetConfigRequest) error { if req.SiteURL == "" { return errors.New("siteUrl required") diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go index a832e4ea68..4c0b1a9300 100644 --- a/apps/backend/internal/jira/service_test.go +++ b/apps/backend/internal/jira/service_test.go @@ -241,6 +241,44 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { } } +func TestService_LegacySecretOnlyAppliesToMigratedWorkspace(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + f.store.migratedToWorkspace = "default" + for _, workspaceID := range []string{"default", "other"} { + if err := f.store.UpsertConfigForWorkspace(ctx, workspaceID, &JiraConfig{ + SiteURL: "https://acme.atlassian.net", + Email: "user@example.com", + AuthMethod: AuthMethodAPIToken, + InstanceType: InstanceTypeCloud, + }); err != nil { + t.Fatalf("upsert config for %s: %v", workspaceID, err) + } + } + if err := f.secrets.Set(ctx, SecretKey, "Legacy Jira token", "legacy-token"); err != nil { + t.Fatalf("set legacy secret: %v", err) + } + + migrated, err := f.svc.GetConfigForWorkspace(ctx, "default") + if err != nil { + t.Fatalf("get migrated workspace config: %v", err) + } + if !migrated.HasSecret { + t.Fatal("expected migrated workspace to retain the legacy secret fallback") + } + + other, err := f.svc.GetConfigForWorkspace(ctx, "other") + if err != nil { + t.Fatalf("get unrelated workspace config: %v", err) + } + if other.HasSecret { + t.Fatal("legacy secret must not leak into an unrelated workspace") + } + if secret, _ := f.svc.revealSecret(ctx, "other"); secret != "" { + t.Fatalf("reveal unrelated workspace secret = %q", secret) + } +} + func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() diff --git a/apps/backend/internal/linear/handlers_test.go b/apps/backend/internal/linear/handlers_test.go index bb07f57734..ebe599a672 100644 --- a/apps/backend/internal/linear/handlers_test.go +++ b/apps/backend/internal/linear/handlers_test.go @@ -131,7 +131,7 @@ func TestHTTPListStates_RoutesThroughService(t *testing.T) { }); err != nil { t.Fatalf("upsert: %v", err) } - if err := ctrl.service.secrets.Set(ctx, SecretKey, "linear", "tok"); err != nil { + if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("default"), "linear", "tok"); err != nil { t.Fatalf("set secret: %v", err) } var seenTeam string @@ -168,7 +168,7 @@ func TestHTTPListLabels_RoutesThroughService(t *testing.T) { }); err != nil { t.Fatalf("upsert: %v", err) } - if err := ctrl.service.secrets.Set(ctx, SecretKey, "linear", "tok"); err != nil { + if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("default"), "linear", "tok"); err != nil { t.Fatalf("set secret: %v", err) } var seenTeam string @@ -205,7 +205,7 @@ func TestHTTPListUsers_RoutesThroughService(t *testing.T) { }); err != nil { t.Fatalf("upsert: %v", err) } - if err := ctrl.service.secrets.Set(ctx, SecretKey, "linear", "tok"); err != nil { + if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("default"), "linear", "tok"); err != nil { t.Fatalf("set secret: %v", err) } var seenTeam string @@ -232,7 +232,7 @@ func TestHTTPSearchIssues_RejectsBadNumericParams(t *testing.T) { }); err != nil { t.Fatalf("upsert: %v", err) } - if err := ctrl.service.secrets.Set(ctx, SecretKey, "linear", "tok"); err != nil { + if err := ctrl.service.secrets.Set(ctx, SecretKeyForWorkspace("default"), "linear", "tok"); err != nil { t.Fatalf("set secret: %v", err) } cases := map[string]string{ diff --git a/apps/backend/internal/linear/poller_test.go b/apps/backend/internal/linear/poller_test.go index aaf20b902e..1ded6b3fe9 100644 --- a/apps/backend/internal/linear/poller_test.go +++ b/apps/backend/internal/linear/poller_test.go @@ -54,10 +54,15 @@ func (f *pollerFixture) saveConfigForWorkspace(t *testing.T, workspaceID, secret }); err != nil { t.Fatalf("save config: %v", err) } - key := SecretKey - if workspaceID != "" { - key = SecretKeyForWorkspace(workspaceID) + secretWorkspaceID := workspaceID + if secretWorkspaceID == "" { + var err error + secretWorkspaceID, err = f.store.defaultWorkspaceID() + if err != nil { + t.Fatalf("resolve default workspace: %v", err) + } } + key := SecretKeyForWorkspace(secretWorkspaceID) if err := f.secrets.Set(ctx, key, "linear", secret); err != nil { t.Fatalf("save secret: %v", err) } diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index 4c92b2b87d..a36b2e2773 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -588,6 +588,9 @@ func (s *Service) secretExists(ctx context.Context, workspaceID string) (bool, e if err != nil || exists { return exists, err } + if !s.legacySecretAppliesTo(workspaceID) { + return false, nil + } return s.secrets.Exists(ctx, SecretKey) } @@ -599,6 +602,9 @@ func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, if err == nil && secret != "" { return secret, nil } + if !s.legacySecretAppliesTo(workspaceID) { + return "", err + } legacy, legacyErr := s.secrets.Reveal(ctx, SecretKey) if legacyErr == nil && legacy != "" { return legacy, nil @@ -609,6 +615,11 @@ func (s *Service) revealSecret(ctx context.Context, workspaceID string) (string, return "", legacyErr } +func (s *Service) legacySecretAppliesTo(workspaceID string) bool { + target := s.store.MigratedFromWorkspace() + return target != "" && workspaceID == target +} + func validateConfigRequest(req *SetConfigRequest) error { if req.AuthMethod == "" { req.AuthMethod = AuthMethodAPIKey diff --git a/apps/backend/internal/linear/service_test.go b/apps/backend/internal/linear/service_test.go index 3ecb4ac8c4..07c8092a8a 100644 --- a/apps/backend/internal/linear/service_test.go +++ b/apps/backend/internal/linear/service_test.go @@ -247,6 +247,41 @@ func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { } } +func TestService_LegacySecretOnlyAppliesToMigratedWorkspace(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + f.store.migratedToWorkspace = "default" + for _, workspaceID := range []string{"default", "other"} { + if err := f.store.UpsertConfigForWorkspace(ctx, workspaceID, &LinearConfig{ + AuthMethod: AuthMethodAPIKey, + }); err != nil { + t.Fatalf("upsert config for %s: %v", workspaceID, err) + } + } + if err := f.secrets.Set(ctx, SecretKey, "Legacy Linear API key", "legacy-token"); err != nil { + t.Fatalf("set legacy secret: %v", err) + } + + migrated, err := f.svc.GetConfigForWorkspace(ctx, "default") + if err != nil { + t.Fatalf("get migrated workspace config: %v", err) + } + if !migrated.HasSecret { + t.Fatal("expected migrated workspace to retain the legacy secret fallback") + } + + other, err := f.svc.GetConfigForWorkspace(ctx, "other") + if err != nil { + t.Fatalf("get unrelated workspace config: %v", err) + } + if other.HasSecret { + t.Fatal("legacy secret must not leak into an unrelated workspace") + } + if secret, _ := f.svc.revealSecret(ctx, "other"); secret != "" { + t.Fatalf("reveal unrelated workspace secret = %q", secret) + } +} + func TestService_SetConfig_InvalidatesClientCache(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() diff --git a/apps/web/app/azure-devops/azure-devops-page-client.tsx b/apps/web/app/azure-devops/azure-devops-page-client.tsx index 7c48f4bed6..37877cb101 100644 --- a/apps/web/app/azure-devops/azure-devops-page-client.tsx +++ b/apps/web/app/azure-devops/azure-devops-page-client.tsx @@ -8,7 +8,7 @@ import { IconAdjustments } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon"; import { AzureDevOpsFilters, @@ -84,7 +84,7 @@ type PageProps = { function NotConfigured({ workspaceId }: { workspaceId?: string }) { const href = workspaceId - ? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations/azure-devops` + ? `/settings/workspaces/${encodeURIComponent(workspaceId)}/integrations/azure-devops` : "/settings/integrations/azure-devops"; return (
@@ -593,27 +593,27 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: }; return ( -
- } - actions={ - - } - /> + } + scroll="none" + actions={ + + } + > -
+ ); } diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 32383431b8..133b8325b8 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -6,7 +6,7 @@ import { IconBrandGithub, IconMenu2 } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { usePRKeyToTasks } from "@/hooks/domains/github/use-pr-key-to-tasks"; import { useIssueKeyToTasks } from "@/hooks/domains/github/use-issue-key-to-tasks"; @@ -61,28 +61,20 @@ type GitHubPageClientProps = { repositories: Repository[]; }; -function PageHeader({ onOpenMobileSidebar }: { onOpenMobileSidebar?: () => void }) { +function MobileFiltersButton({ show, onClick }: { show: boolean; onClick: () => void }) { const { t } = useTranslation(); + if (!show) return null; return ( - } - actions={ - onOpenMobileSidebar && ( - - ) - } - /> + ); } @@ -95,7 +87,7 @@ function NotAuthenticatedNotice({ }) { const { t } = useTranslation(); const settingsHref = workspaceId - ? `/settings/workspace/${workspaceId}/integrations/github` + ? `/settings/workspaces/${workspaceId}/integrations/github` : "/settings/integrations/github"; return ( @@ -378,7 +370,8 @@ function AuthenticatedLayout({ const issueKeyToTasks = useIssueKeyToTasks(workspaceId ?? null); useAllWorkflowSnapshots(workspaceId ?? null); return ( -
+ // Not a
: AppShell owns that landmark, one per page. +
-
+
+ ); +} + +function MobileFiltersSheet({ + open, + onOpenChange, + state, + onSelect, + onSaveCurrent, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + state: GitHubPageState; + onSelect: (s: Parameters[0]) => void; + onSaveCurrent: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + {t("github:filters")} + + + + ); } @@ -453,7 +487,6 @@ export function GitHubPageClient({ const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []); const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); - const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); // Close the mobile sheet after any sidebar selection. KindToggle clicks also // route through onSelect — closing on every selection is acceptable UX since // the user always wants to see the list after picking a kind or preset. @@ -469,51 +502,46 @@ export function GitHubPageClient({ }; return ( -
- - {!loaded && ( -
{t("github:checkingGithubStatus")}
- )} - {loaded && !authed && ( -
- } + scroll="none" + actions={ + setMobileSidebarOpen(true)} /> + } + > +
+ {!loaded && ( +
+ {t("github:checkingGithubStatus")} +
+ )} + {loaded && !authed && ( +
+ +
+ )} + {loaded && authed && ( + -
- )} - {loaded && authed && ( - - )} - - - - {t("github:filters")} - - - - + )} +
+ -
+ ); } diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 7af3993e29..8a768d8cd2 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -6,7 +6,7 @@ import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api"; import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab"; import { MRList } from "@/components/gitlab/my-gitlab/mr-list"; @@ -47,37 +47,6 @@ type GitLabPageClientProps = { repositories?: Repository[]; }; -function PageHeader({ - host, - onOpenMobileSidebar, -}: { - host: string; - onOpenMobileSidebar?: () => void; -}) { - const { t } = useTranslation(); - return ( - } - actions={ - onOpenMobileSidebar && ( - - ) - } - /> - ); -} - function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) { const { t } = useTranslation(); return ( @@ -315,7 +284,8 @@ function AuthenticatedLayout({ // later pages that may contain more matches. const displayedCount = state.projectFilter ? search.items.length : search.total; return ( -
+ // Not a
: AppShell owns that landmark, one per page. +
-
+ ); } @@ -551,6 +521,7 @@ export function GitLabPageClient({ steps = [], repositories = [], }: GitLabPageClientProps = {}) { + const { t } = useTranslation(); const scope = useGitLabWorkspaceScope(workspaceId); const { status, statusLoading, connected, reconnect } = scope; const host = status?.host ?? "https://gitlab.com"; @@ -564,22 +535,40 @@ export function GitLabPageClient({ const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); return ( -
- - + } + scroll="none" + actions={ + !statusLoading && + connected && ( + + ) + } + > +
+ +
-
+ ); } diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index 6e122371de..56e5e58340 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -2,9 +2,10 @@ import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { IconTicket } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { getJiraConfig, listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; import type { JiraProject, JiraTicket } from "@/lib/types/jira"; import type { Workflow, WorkflowStep } from "@/lib/types/http"; @@ -32,7 +33,6 @@ import { ResultsPagination } from "@/components/jira/my-jira/results-pagination" import { JqlEditor } from "@/components/jira/my-jira/jql-editor"; import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets"; import type { JiraTaskPreset } from "@/components/jira/my-jira/presets"; -import { Trans, useTranslation } from "react-i18next"; type JiraPageClientProps = { workspaceId?: string; @@ -243,7 +243,8 @@ function AuthenticatedView({ onClear={() => state.updateFilters(DEFAULT_FILTERS)} /> )} -
+ {/* Not a
: AppShell owns that landmark, one per page. */} +
-
+ setOpenTicket(ticket), []); return ( -
- } - /> - {!loaded && ( -
{t("jira:checkingJiraStatus")}
- )} - {loaded && !configured && } - {loaded && configured && ( - - )} + } + scroll="none" + > +
+ {!loaded && ( +
{t("jira:checkingJiraStatus")}
+ )} + {loaded && !configured && } + {loaded && configured && ( + + )} +
-
+ ); } diff --git a/apps/web/app/linear/linear-page-client.tsx b/apps/web/app/linear/linear-page-client.tsx index 493cbc9568..93b5974299 100644 --- a/apps/web/app/linear/linear-page-client.tsx +++ b/apps/web/app/linear/linear-page-client.tsx @@ -16,7 +16,7 @@ import { PaginationPrevious, } from "@kandev/ui/pagination"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability"; import { formatRelative, @@ -297,12 +297,11 @@ function PaginationBar({ ); } -function PageShell({ children }: { children: React.ReactNode }) { +function LinearFrame({ children }: { children: React.ReactNode }) { return ( -
- } /> + } scroll="none"> {children} -
+ ); } @@ -384,28 +383,28 @@ export function LinearPageClient({ workspaceId, workflows, steps }: LinearPageCl if (!workspaceId) { return ( - + - + ); } if (loaded && !configured) { return ( - + - + ); } if (!available && loaded && configured) { return ( - + - + ); } return ( - + setLaunchIssue(null)} /> - + ); } diff --git a/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx b/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx index 84e3d0b492..8bd559b281 100644 --- a/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx +++ b/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx @@ -42,7 +42,8 @@ export function RunDetailView({ agentId, initial, recent }: Props) { -
+ {/* Not a
: AppShell owns that landmark, one per page. */} +
@@ -52,7 +53,7 @@ export function RunDetailView({ agentId, initial, recent }: Props) { -
+ ); } diff --git a/apps/web/app/office/components/office-shell.tsx b/apps/web/app/office/components/office-shell.tsx new file mode 100644 index 0000000000..eee039b92b --- /dev/null +++ b/apps/web/app/office/components/office-shell.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { IconBoxMultiple, IconInbox, IconRobot, IconSitemap } from "@tabler/icons-react"; +import { AppSidebarNavItem } from "@/components/app-sidebar/app-sidebar-nav-item"; +import { OfficeNavigationSection } from "@/components/app-sidebar/sections/office-navigation-section"; +import { PageShell } from "@/components/page-shell"; +import { usePathname } from "@/lib/routing/client-router"; +// Route -> catalog key, not route -> title. The map is module scope, so a `t()` +// here would resolve once at import and freeze at the boot locale; the keys are +// resolved at render below. The route paths are URLs, not copy. +const PAGE_TITLE_KEYS: Record = { + "/office": "office:dashboard", + "/office/inbox": "office:inbox", + "/office/tasks": "office:tasks", + "/office/routines": "office:routines", + "/office/projects": "office:projects", + "/office/agents": "office:agents", + "/office/workspace/org": "office:orgChart", + "/office/workspace/skills": "office:skills", + "/office/workspace/costs": "office:costs", + "/office/workspace/activity": "office:activity", + "/office/workspace/routing": "office:providerRouting", + "/office/workspace/settings": "office:preferences", +}; + +function resolveTitleKey(pathname: string): string | null { + const exact = PAGE_TITLE_KEYS[pathname]; + if (exact) return exact; + if (pathname.startsWith("/office/workspace/settings")) return "office:preferences"; + return null; +} + +function isDetailPage(pathname: string): boolean { + return ( + /^\/office\/tasks\/[^/]+$/.test(pathname) || + /^\/office\/agents\/[^/]+(?:\/.*)?$/.test(pathname) || + /^\/office\/projects\/[^/]+$/.test(pathname) || + /^\/office\/routines\/[^/]+$/.test(pathname) + ); +} + +// Destinations the desktop sidebar reaches through sections other than +// `OfficeNavigationSection` — Inbox via `AppSidebarPrimaryNav`, Projects via +// `ProjectsSection`, Agents and Org Chart via `AgentsSection`. Those three build +// rows from live office data and drive some of them through `router.push` +// buttons, which the sheet's close-on-link-click would miss, so the sheet +// restates the destinations as plain links instead of rendering the sections. +// Mobile owes desktop capability parity, not layout parity. +const OFFICE_SHEET_DESTINATIONS = [ + { icon: IconInbox, labelKey: "sidebar:inbox", href: "/office/inbox" }, + { icon: IconBoxMultiple, labelKey: "sidebar:projects", href: "/office/projects" }, + { icon: IconRobot, labelKey: "common:agents", href: "/office/agents" }, + { icon: IconSitemap, labelKey: "sidebar:orgChart", href: "/office/workspace/org" }, +] as const; + +/** + * Office's sections for the phone nav sheet — the same component the desktop + * sidebar renders, compacted to the sheet's touch-row sizing, plus the + * destinations that live in the sidebar's other sections. Without this, office + * navigation exists only in the `hidden md:block` sidebar. + */ +function OfficePageNav() { + const { t } = useTranslation(); + return ( +
+ +
+ {OFFICE_SHEET_DESTINATIONS.map(({ icon, labelKey, href }) => ( + + ))} +
+ +
+ ); +} + +/** + * Office page chrome on the shared `PageShell`. List pages show a static + * title; detail pages render a portal target (#office-topbar-slot) that the + * page component fills with its breadcrumb via OfficeTopbarPortal. Both paint + * paths (`src/office-routes.tsx` and `app/office/layout.tsx`) wrap their route + * output in this shell so they cannot drift. + */ +export function OfficeShell({ + children, + routePath, +}: { + children: ReactNode; + /** Resolved route for the `data-office-route` anchor; defaults to the URL. */ + routePath?: string; +}) { + const { t } = useTranslation(); + const pathname = usePathname(); + const titleKey = resolveTitleKey(pathname); + const detail = isDetailPage(pathname); + const title = titleKey ? t(titleKey) : ""; + + return ( + } + scroll="none" + // The manifest's Tasks row goes to kanban. Office's own Tasks row above it + // goes to /office/tasks, and two rows labelled "Tasks" leading to + // different surfaces is a coin flip for the user. + navOmitDestinations={["tasks"]} + leading={ + detail ? ( +
+ ) : ( + titleKey &&

{title}

+ ) + } + > + {/* `data-office-route` stamps the RESOLVED route onto the outlet, and is + the render anchor the pseudo-coverage oracle waits on for every + `office — …` screen (e2e/tests/i18n/pseudo-coverage.spec.ts). + + It is an attribute on the outlet rather than a testid inside each + page because these pages are mounted with empty collections + (`initialItems={[]}`), so they legitimately render empty states in + e2e — an anchor inside the populated branch would never match. This + attribute is present whichever branch a page takes, and it is not + shell-satisfiable: this element lives in the office chunk, is not + rendered while `OfficeRouteLoading` holds, and its VALUE identifies + the specific route, so a mis-pointed URL cannot match another + screen's anchor. + + The shell owns this outlet (`scroll="none"`) so both paint paths — + `src/office-routes.tsx` and `app/office/layout.tsx` — carry the + anchor without either one re-declaring it. */} +
+ {children} +
+ + ); +} diff --git a/apps/web/app/office/components/office-topbar-portal.tsx b/apps/web/app/office/components/office-topbar-portal.tsx index d53f34bde6..c96d89769a 100644 --- a/apps/web/app/office/components/office-topbar-portal.tsx +++ b/apps/web/app/office/components/office-topbar-portal.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; /** - * Renders children into the #office-topbar-slot element defined by OfficeTopbar. + * Renders children into the #office-topbar-slot element defined by OfficeShell. * Used by detail pages (task, agent) to inject breadcrumbs into the topbar. * * The target lookup runs in useEffect (not useState initializer) so it sees diff --git a/apps/web/app/office/components/office-topbar.tsx b/apps/web/app/office/components/office-topbar.tsx deleted file mode 100644 index 1ec8e9cb5a..0000000000 --- a/apps/web/app/office/components/office-topbar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { usePathname } from "@/lib/routing/client-router"; -import { AppStatusDrawerTrigger } from "@/components/app-status-bar/app-status-surface-provider"; -import { useTranslation } from "react-i18next"; - -// Route -> catalog key, not route -> title. The map is module scope, so a `t()` -// here would resolve once at import and freeze at the boot locale; the keys are -// resolved at render below. The route paths are URLs, not copy. -const PAGE_TITLE_KEYS: Record = { - "/office": "office:dashboard", - "/office/inbox": "office:inbox", - "/office/tasks": "office:tasks", - "/office/routines": "office:routines", - "/office/projects": "office:projects", - "/office/agents": "office:agents", - "/office/workspace/org": "office:orgChart", - "/office/workspace/skills": "office:skills", - "/office/workspace/costs": "office:costs", - "/office/workspace/activity": "office:activity", - "/office/workspace/routing": "office:providerRouting", - "/office/workspace/settings": "office:preferences", -}; - -function resolveTitleKey(pathname: string): string | null { - const exact = PAGE_TITLE_KEYS[pathname]; - if (exact) return exact; - if (pathname.startsWith("/office/workspace/settings")) return "office:preferences"; - return null; -} - -function isDetailPage(pathname: string): boolean { - return ( - /^\/office\/tasks\/[^/]+$/.test(pathname) || - /^\/office\/agents\/[^/]+(?:\/.*)?$/.test(pathname) || - /^\/office\/projects\/[^/]+$/.test(pathname) || - /^\/office\/routines\/[^/]+$/.test(pathname) - ); -} - -/** - * Office topbar. For list pages shows a static title. For detail pages - * renders a portal target (#office-topbar-slot) that the page component - * fills with its breadcrumb via OfficeTopbarPortal. - */ -export function OfficeTopbar() { - const { t } = useTranslation(); - const pathname = usePathname(); - const titleKey = resolveTitleKey(pathname); - const detail = isDetailPage(pathname); - - return ( -
- {detail ? ( -
- ) : ( - titleKey &&

{t(titleKey)}

- )} - -
- ); -} diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index 1a4a41596b..a494ac91c8 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -13,7 +13,7 @@ import { import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { readCookies } from "@/lib/server/cookies"; import type { AppState } from "@/lib/state/store"; -import { OfficeTopbar } from "./components/office-topbar"; +import { OfficeShell } from "./components/office-shell"; function resolveActiveOfficeWorkspaceId( workspaceItems: { id: string }[], @@ -166,8 +166,7 @@ export default async function OfficeLayout({ children }: { children: React.React
- -
{children}
+ {children}
); diff --git a/apps/web/app/settings/agent/[id]/page.tsx b/apps/web/app/settings/agent/[id]/page.tsx deleted file mode 100644 index de991215af..0000000000 --- a/apps/web/app/settings/agent/[id]/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { useRouter } from "@/lib/routing/client-router"; -import { Card, CardContent } from "@kandev/ui/card"; -import { Button } from "@kandev/ui/button"; - -export default function AgentEditPage() { - const { t } = useTranslation(); - const router = useRouter(); - - useEffect(() => { - router.replace("/settings/agents"); - }, [router]); - - return ( - - -

{t("agents:manageAgentsFromMainPage")}

- -
-
- ); -} diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 895648b378..9dd9edd6b4 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -23,6 +23,7 @@ import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { deleteAgentAction } from "@/app/actions/agents"; +import { SettingsRedirect } from "@/src/settings-route-helpers"; import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers"; import type { DraftProfile, DraftAgent } from "./agent-save-helpers"; import { AgentHeader, ProfilesCard } from "./agent-setup-parts"; @@ -554,6 +555,13 @@ export default function AgentSetupPage() { }); }; + // Saved agents have no page of their own — the Agents index shows each + // agent with its profiles inline. The route only serves creation (new agent + // from browse, or ?mode=create for a new profile). + if (savedAgent && !isCreateMode) { + return ; + } + return ( +
+

{t("agents:browseAvailableAgents")}

+

+ {t("agents:availableToInstallDescription")} +

+
+ + +
+ ); +} diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 7af17c82c3..27ea5a3b9b 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -1,13 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import Link from "@/components/routing/app-link"; import { IconAlertTriangle, - IconCheck, - IconClipboard, IconDownload, - IconExternalLink, IconLoader2, IconPlus, IconRefresh, @@ -19,151 +17,33 @@ import { Separator } from "@kandev/ui/separator"; import { useAppStore } from "@/components/state-provider"; import { createCustomTUIAgent, - installAgent, listAgentDiscovery, listAgents, listAvailableAgents, - listInstallJobs, } from "@/lib/api"; import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; -import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; +import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; -import { InstallAgentCard } from "@/components/settings/install-agent-card"; import { InstalledAgentCard } from "@/components/settings/installed-agent-card"; +import { AGENTS_BROWSE_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; +import { + detectedAgents, + orderAgentsForDisplay, + orphanedAgents, + type DiscoveredAgent, +} from "@/lib/settings/agent-display-order"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; -import { ProfileListItem } from "@/app/settings/agents/profile-list-item"; -import { useProfileEnabledToggle } from "@/hooks/domains/settings/use-profile-enabled-toggle"; -import { ProfileEnabledHelp } from "@/components/settings/profile-enabled-help"; -import type { - AgentDiscovery, - Agent, - AvailableAgent, - AgentProfile, - RuntimeUpdate, - ToolStatus, -} from "@/lib/types/http"; - -function useCopyCommand() { - const [copiedValue, setCopiedValue] = useState(null); - const copy = useCallback(async (text: string) => { - if (await copyToClipboard(text)) { - setCopiedValue(text); - setTimeout(() => setCopiedValue(null), 2000); - } - }, []); - return { copiedValue, copy }; -} - -function CopyButton({ - text, - copiedValue, - onCopy, -}: { - text: string; - copiedValue: string | null; - onCopy: (text: string) => void; -}) { - const { t } = useTranslation(); - const isCopied = copiedValue === text; - return ( - - ); -} - -function InstallCard({ - agent, - copiedValue, - onCopy, - job, - onInstall, -}: { - agent: AvailableAgent; - copiedValue: string | null; - onCopy: (text: string) => void; - job: InstallJob | undefined; - onInstall: (name: string) => void; -}) { - return ( - - {agent.install_script} - -
- ) : null - } - /> - ); -} - -function ToolInstallCard({ - tool, - copiedValue, - onCopy, -}: { - tool: ToolStatus; - copiedValue: string | null; - onCopy: (text: string) => void; -}) { - const { t } = useTranslation(); - return ( - - -
- -

{tool.display_name}

- {tool.available && ( - - - {t("agents:installed")} - - )} -
- {tool.description &&

{tool.description}

} - {!tool.available && tool.install_script && ( -
- {tool.install_script} - -
- )} - {tool.info_url && ( - - - {tool.info_url} - - )} -
-
- ); -} +import type { AgentDiscovery, Agent, AvailableAgent, RuntimeUpdate } from "@/lib/types/http"; type InstalledAgentsSectionProps = { installedAgents: AgentDiscovery[]; + /** The full scan, detected or not — the backend's ranking of every agent. */ + discoveryOrder: AgentDiscovery[]; + savedAgents: Agent[]; discoveryLoading: boolean; rescanning: boolean; savedAgentsByName: Map; @@ -230,8 +110,48 @@ function InstalledAgentsHeader({ ); } +type AgentCard = { key: string; agent: AgentDiscovery; detected: boolean }; + +/** + * Every agent worth a card, in the backend's rank order: the ones the scan + * found, plus configured agents it did not, the latter given a synthetic + * discovery record so a missing CLI never hides its profiles. + */ +function agentCards( + installedAgents: AgentDiscovery[], + savedAgents: Agent[], + discoveryOrder: DiscoveredAgent[], +): AgentCard[] { + const detected: AgentCard[] = installedAgents.map((agent) => ({ + key: agent.name, + agent, + detected: true, + })); + const orphans: AgentCard[] = orphanedAgents(installedAgents, savedAgents).map((agent) => ({ + key: agent.id, + agent: { + name: agent.name, + supports_mcp: agent.supports_mcp, + mcp_config_path: agent.mcp_config_path ?? null, + installation_paths: [], + available: false, + matched_path: null, + }, + detected: false, + })); + // Rank by name against the same list the menu ranks against, so the two + // surfaces cannot order the same agents differently. + const ordered = orderAgentsForDisplay( + discoveryOrder, + [...detected, ...orphans].map((card) => ({ name: card.agent.name, profiles: [], card })), + ); + return ordered.map((entry) => entry.card); +} + function InstalledAgentsSection({ installedAgents, + discoveryOrder, + savedAgents, discoveryLoading, rescanning, savedAgentsByName, @@ -248,6 +168,14 @@ function InstalledAgentsSection({ const { t } = useTranslation(); const [shellOpen, setShellOpen] = useState(false); + // One ranked list rather than "detected, then the rest". Two groups meant an + // agent the scan misses always sorted below every detected one — which put + // the dev-only mock agent, ranked last by the backend, ahead of real agents + // whose CLI happened to be absent. Undetected agents still render, via a + // synthetic discovery record, so their profiles never vanish; they just keep + // their rank. The settings menu ranks the same way — see `agent-display-order`. + const cards = agentCards(installedAgents, savedAgents, discoveryOrder); + return (
- {installedAgents.length === 0 && ( + {cards.length === 0 && (
@@ -283,178 +211,36 @@ function InstalledAgentsSection({ )} -
- {installedAgents.map((agent: AgentDiscovery) => ( +
+ {cards.map(({ key, agent, detected }) => ( void handleRescan()} - /> - ))} -
-
- ); -} - -function SuggestInstallSection({ - notInstalledAgents, - tools, - copiedValue, - onCopy, - installJobs, - onInstall, -}: { - notInstalledAgents: AvailableAgent[]; - tools: ToolStatus[]; - copiedValue: string | null; - onCopy: (text: string) => void; - installJobs: Record; - onInstall: (name: string) => void; -}) { - const { t } = useTranslation(); - const notInstalledTools = tools.filter((tool) => !tool.available); - if (notInstalledAgents.length === 0 && notInstalledTools.length === 0) return null; - - return ( -
- -
-

{t("agents:availableToInstall")}

-

{t("agents:availableToInstallDescription")}

-
- -
- {notInstalledAgents.map((agent) => ( - - ))} - {notInstalledTools.map((tool) => ( - - ))} -
-
- ); -} - -type AgentProfilesSectionProps = { - savedAgents: Agent[]; - onToggleEnabled: (profile: AgentProfile, enabled: boolean) => void; -}; - -function AgentProfilesSection({ savedAgents, onToggleEnabled }: AgentProfilesSectionProps) { - const { t } = useTranslation(); - if (!savedAgents.some((agent: Agent) => agent.profiles.length > 0)) { - return null; - } - - return ( -
- -
-
-

{t("agents:agentProfiles")}

- -
-

{t("agents:agentProfilesDescription")}

-
- -
- {savedAgents.flatMap((agent: Agent) => - agent.profiles.map((profile: AgentProfile) => ( - void handleRescan(), + } + : {})} + > + - )), - )} + + ))}
); } -/** - * Install state is held in the store (driven by WS events - * agent.install.{started,output,finished}). This hook: - * - Rehydrates jobs on mount so a page reload picks up in-flight installs. - * - Subscribes to agent.available.updated → calls onSuccess() to rescan. - * - Exposes handleInstall(name) which POSTs to enqueue (idempotent on the - * server: clicking again while running returns the same job_id). - */ -function useInstallAgent(onSuccess: () => Promise) { - const installJobs = useAppStore((state) => state.installJobs.byAgent); - const upsertInstallJob = useAppStore((state) => state.upsertInstallJob); - - useEffect(() => { - let cancelled = false; - listInstallJobs() - .then((resp) => { - if (cancelled) return; - // Upsert per-job rather than wholesale-replace: if a WS event - // already seeded an in-flight job with output chunks between page - // mount and this HTTP response, the snapshot from the server may - // be older, and a full replace would clobber the live output. - for (const job of resp.jobs) upsertInstallJob(job); - }) - .catch(() => { - /* page mount; ignore */ - }); - return () => { - cancelled = true; - }; - }, [upsertInstallJob]); - - // When any install finishes successfully, trigger the page-level rescan so - // the agent disappears from "Available to Install" and shows up under - // "Installed Agents". - useEffect(() => { - const succeeded = Object.values(installJobs).filter((j) => j.status === "succeeded"); - if (succeeded.length > 0) { - void onSuccess(); - } - // Intentionally only depends on the count of succeeded jobs to avoid - // re-firing on every output chunk during a running install. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [Object.values(installJobs).filter((j) => j.status === "succeeded").length]); - - const handleInstall = useCallback( - async (name: string) => { - try { - const job = await installAgent(name); - // The WS event will normally arrive first, but seed the store in case - // the WS round-trip is slower than the HTTP response. - upsertInstallJob(job); - } catch (err) { - upsertInstallJob({ - job_id: `local-error-${name}`, - agent_name: name, - status: "failed", - error: err instanceof Error ? err.message : String(err), - started_at: new Date().toISOString(), - }); - } - }, - [upsertInstallJob], - ); - - return { installJobs, handleInstall }; -} - function useAgentPageState() { const { items: discoveryAgents, loading: discoveryLoading } = useAgentDiscovery(); const savedAgents = useAppStore((state) => state.settingsAgents.items); @@ -462,18 +248,12 @@ function useAgentPageState() { const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); - const { items: availableAgents, tools } = useAvailableAgents(); + const installJobs = useAppStore((state) => state.installJobs.byAgent); + const { items: availableAgents } = useAvailableAgents(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); - const installedAgents = useMemo( - () => discoveryAgents.filter((agent: AgentDiscovery) => agent.available), - [discoveryAgents], - ); - const notInstalledAgents = useMemo( - () => availableAgents.filter((a: AvailableAgent) => !a.available && a.install_script), - [availableAgents], - ); + const installedAgents = useMemo(() => detectedAgents(discoveryAgents), [discoveryAgents]); const savedAgentsByName = useMemo( () => new Map(savedAgents.map((agent: Agent) => [agent.name, agent])), [savedAgents], @@ -502,9 +282,7 @@ function useAgentPageState() { } }; - const { installJobs, handleInstall } = useInstallAgent(handleRescan); const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); - const handleToggleProfileEnabled = useProfileEnabledToggle(); const handleCreateCustomTUI = async (data: { display_name: string; @@ -530,8 +308,7 @@ function useAgentPageState() { return { savedAgents, installedAgents, - notInstalledAgents, - tools, + discoveryAgents, savedAgentsByName, discoveryLoading, rescanning, @@ -543,11 +320,9 @@ function useAgentPageState() { handleRescan, handleCreateCustomTUI, installJobs, - handleInstall, updateJobs, previewUpdate, startUpdate, - handleToggleProfileEnabled, }; } @@ -555,8 +330,7 @@ export default function AgentsSettingsPage() { const { savedAgents, installedAgents, - notInstalledAgents, - tools, + discoveryAgents, savedAgentsByName, discoveryLoading, rescanning, @@ -568,26 +342,35 @@ export default function AgentsSettingsPage() { handleRescan, handleCreateCustomTUI, installJobs, - handleInstall, updateJobs, previewUpdate, startUpdate, - handleToggleProfileEnabled, } = useAgentPageState(); - const { copiedValue, copy } = useCopyCommand(); const { t } = useTranslation(); return (
-
-

{t("common:agents")}

-

{t("agents:pageDescription")}

+
+
+

{t("common:agents")}

+

{t("agents:pageDescription")}

+
+ {/* The page's primary action: everything else on this page manages what + is already installed. */} +
- - - - { - it("renders the profile row with a switch reflecting enabled state", () => { - render(); - const toggle = screen.getByTestId("profile-enabled-toggle-p1"); - expect(toggle.getAttribute("data-state")).toBe("checked"); - expect(toggle.getAttribute("aria-label")).toBe("Disable default"); - }); - - it("shows a disabled profile with the switch off", () => { - render( - , - ); - const toggle = screen.getByTestId("profile-enabled-toggle-p1"); - expect(toggle.getAttribute("data-state")).toBe("unchecked"); - expect(toggle.getAttribute("aria-label")).toBe("Enable default"); - }); - - it("calls onToggleEnabled with the profile and the next value on click", () => { - const onToggle = vi.fn(); - render(); - fireEvent.click(screen.getByTestId("profile-enabled-toggle-p1")); - expect(onToggle).toHaveBeenCalledTimes(1); - expect(onToggle).toHaveBeenCalledWith(profile, false); - }); -}); diff --git a/apps/web/app/settings/agents/profile-list-item.tsx b/apps/web/app/settings/agents/profile-list-item.tsx deleted file mode 100644 index d3acc3d829..0000000000 --- a/apps/web/app/settings/agents/profile-list-item.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; -import Link from "@/components/routing/app-link"; -import { Badge } from "@kandev/ui/badge"; -import { Card, CardContent } from "@kandev/ui/card"; -import { Switch } from "@kandev/ui/switch"; -import { AgentLogo } from "@/components/agent-logo"; -import type { Agent, AgentProfile } from "@/lib/types/http"; - -type ProfileListItemProps = { - agent: Agent; - profile: AgentProfile; - onToggleEnabled: (profile: AgentProfile, enabled: boolean) => void; -}; - -export function ProfileListItem({ agent, profile, onToggleEnabled }: ProfileListItemProps) { - const { t } = useTranslation(); - const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`; - const enabled = profile.enabled ?? true; - return ( - - - - - - {agent.profiles[0]?.agentDisplayName ?? agent.name} - - {agent.supports_mcp && MCP} - {profile.name} - - {/* Outside the Link on purpose: a click on a switch nested inside an - anchor still follows the href as the native default action (only - preventDefault cancels it, and Radix skips its toggle when the - consumer calls preventDefault), and interactive-inside-interactive - is an a11y anti-pattern. As a sibling, toggling never navigates. */} - onToggleEnabled(profile, next)} - data-testid={`profile-enabled-toggle-${profile.id}`} - aria-label={ - enabled - ? t("agents:disableProfileNamed", { name: profile.name }) - : t("agents:enableProfileNamed", { name: profile.name }) - } - /> - - - ); -} diff --git a/apps/web/app/settings/automations/page.test.tsx b/apps/web/app/settings/automations/page.test.tsx deleted file mode 100644 index 829ab1f010..0000000000 --- a/apps/web/app/settings/automations/page.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -// Regression coverage for the top-level /settings/automations page. -// -// The original implementation was an async server-style component that called -// `await listWorkspaces({ cache: "no-store" })` in its render body. Rendered on -// the client under , React re-invoked it on every -// suspense retry, issuing a fresh uncached fetch each time — an infinite -// render/refetch loop that left the panel blank and flooded the backend with -// ~500 req/s. The page must instead read workspaces from the hydrated store and -// never fetch during render. - -type Workspace = { id: string; name: string; description?: string | null }; - -let mockWorkspaces: Workspace[] = []; -const { replaceSpy, listWorkspacesSpy } = vi.hoisted(() => ({ - replaceSpy: vi.fn(), - listWorkspacesSpy: vi.fn(), -})); - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: { workspaces: { items: Workspace[] } }) => unknown) => - selector({ workspaces: { items: mockWorkspaces } }), -})); - -// Mirror the production useRouter(), which returns a memoized (useMemo([])) -// object — one stable reference across renders. A fresh object per call would -// change the effect's `router` dependency on re-render and could mask a -// re-fire regression. -vi.mock("@/lib/routing/client-router", () => { - const router = { - replace: replaceSpy, - push: vi.fn(), - refresh: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - prefetch: vi.fn(), - }; - return { useRouter: () => router }; -}); - -vi.mock("@/lib/api", () => ({ - listWorkspaces: (...args: unknown[]) => { - listWorkspacesSpy(...args); - return Promise.resolve({ workspaces: mockWorkspaces }); - }, -})); - -import AutomationsTopLevelPage from "./page"; - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); - mockWorkspaces = []; -}); - -describe("AutomationsTopLevelPage", () => { - it("renders a workspace picker from store state without fetching", () => { - mockWorkspaces = [ - { id: "ws-1", name: "Alpha" }, - { id: "ws-2", name: "Beta", description: "second workspace" }, - ]; - - render(); - - expect(screen.getByText("Alpha")).toBeTruthy(); - expect(screen.getByText("Beta")).toBeTruthy(); - // The core regression: no data fetch happens during render. - expect(listWorkspacesSpy).not.toHaveBeenCalled(); - expect(replaceSpy).not.toHaveBeenCalled(); - }); - - it("redirects to the single workspace's automations when exactly one exists", () => { - mockWorkspaces = [{ id: "only-ws", name: "Solo" }]; - - render(); - - // Exactly once — `useRouter()` returns a stable (useMemo([])) reference, so the - // effect must not re-fire and re-redirect on re-render. - expect(replaceSpy).toHaveBeenCalledTimes(1); - expect(replaceSpy).toHaveBeenCalledWith("/settings/workspace/only-ws/automations"); - expect(listWorkspacesSpy).not.toHaveBeenCalled(); - // Graceful degradation: the picker renders while the redirect is pending, so - // the page is never blank if navigation is delayed or blocked. - expect(screen.getByText("Solo")).toBeTruthy(); - }); - - it("shows an empty state when there are no workspaces", () => { - mockWorkspaces = []; - - render(); - - expect(screen.getByText("No workspaces yet")).toBeTruthy(); - expect(listWorkspacesSpy).not.toHaveBeenCalled(); - expect(replaceSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/app/settings/automations/page.tsx b/apps/web/app/settings/automations/page.tsx deleted file mode 100644 index 72d9b139ae..0000000000 --- a/apps/web/app/settings/automations/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; - -import Link from "@/components/routing/app-link"; -import { useAppStore } from "@/components/state-provider"; -import { useRouter } from "@/lib/routing/client-router"; - -export default function AutomationsTopLevelPage() { - const { t } = useTranslation(); - const router = useRouter(); - const workspaces = useAppStore((s) => s.workspaces.items); - - const soleWorkspaceId = workspaces.length === 1 ? workspaces[0].id : null; - - useEffect(() => { - if (soleWorkspaceId) { - router.replace(`/settings/workspace/${soleWorkspaceId}/automations`); - } - }, [soleWorkspaceId, router]); - - // Render the picker even while a single-workspace redirect is pending: the - // effect navigates away on the next tick, but if navigation is delayed or - // blocked the page degrades to the workspace list instead of a blank panel. - if (workspaces.length === 0) { - return ( -
-

{t("automations:noWorkspacesYet")}

-

- {t("automations:noWorkspacesDescription")} -

- - {t("automations:createWorkspace")} - -
- ); - } - - return ( -
-
-

{t("common:automations")}

-

{t("automations:pickWorkspace")}

-
-
- {workspaces.map((ws) => ( - -
{ws.name}
- {ws.description && ( -
- {ws.description} -
- )} - - ))} -
-
- ); -} diff --git a/apps/web/app/settings/changelog/page.tsx b/apps/web/app/settings/changelog/page.tsx deleted file mode 100644 index 7ca3f8cf56..0000000000 --- a/apps/web/app/settings/changelog/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function ChangelogPage() { - redirect("/settings/system/updates"); -} diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index 9fb5158ae7..36796e4b0c 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -235,6 +235,16 @@ export default function ExecutorsHubPage() {

{t("executors:hubDescription")}

+ {allProfiles.length > 0 && ( +
+

{t("executors:profiles")}

+
+ {allProfiles.map((profile) => ( + + ))} +
+
+ )}

{t("executors:createNewProfile")}

@@ -247,16 +257,6 @@ export default function ExecutorsHubPage() { ))}
- {allProfiles.length > 0 && ( -
-

{t("executors:profiles")}

-
- {allProfiles.map((profile) => ( - - ))} -
-
- )} - - - ); -} diff --git a/apps/web/app/settings/general/changes-panel/page.tsx b/apps/web/app/settings/general/changes-panel/page.tsx deleted file mode 100644 index 6c06d744aa..0000000000 --- a/apps/web/app/settings/general/changes-panel/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function GeneralChangesPanelPage() { - redirect("/settings/general/appearance"); -} diff --git a/apps/web/app/settings/general/chat-input/page.tsx b/apps/web/app/settings/general/chat-input/page.tsx deleted file mode 100644 index 516affb2e9..0000000000 --- a/apps/web/app/settings/general/chat-input/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function GeneralChatInputPage() { - redirect("/settings/general/keyboard-shortcuts"); -} diff --git a/apps/web/app/settings/general/editors/page.tsx b/apps/web/app/settings/general/editors/page.tsx deleted file mode 100644 index 6aefe42b9e..0000000000 --- a/apps/web/app/settings/general/editors/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { EditorsSettings } from "@/components/settings/editors-settings"; -import { StateProvider } from "@/components/state-provider"; -import { fetchUserSettings, listEditors } from "@/lib/api"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; - -export default async function GeneralEditorsPage() { - let initialState = {}; - try { - const [editorsResponse, settingsResponse] = await Promise.all([ - listEditors({ cache: "no-store" }), - fetchUserSettings({ cache: "no-store" }), - ]); - const mapped = mapUserSettingsResponse(settingsResponse); - initialState = { - editors: { - items: editorsResponse.editors ?? [], - loaded: true, - loading: false, - }, - userSettings: mapped.loaded ? mapped : undefined, - }; - } catch { - initialState = {}; - } - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/keyboard-shortcuts/page.tsx b/apps/web/app/settings/general/keyboard-shortcuts/page.tsx deleted file mode 100644 index e3f9e50bfa..0000000000 --- a/apps/web/app/settings/general/keyboard-shortcuts/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { KeyboardShortcutsSettings } from "@/components/settings/general-settings"; -import { StateProvider } from "@/components/state-provider"; -import { getUserSettingsInitialState } from "../user-settings-state"; - -export default async function GeneralKeyboardShortcutsPage() { - const initialState = await getUserSettingsInitialState(); - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/layouts/page.tsx b/apps/web/app/settings/general/layouts/page.tsx deleted file mode 100644 index ba6bfe8ac5..0000000000 --- a/apps/web/app/settings/general/layouts/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StateProvider } from "@/components/state-provider"; -import { LayoutSettings } from "@/components/settings/layouts/layout-settings"; -import { getUserSettingsInitialState } from "../user-settings-state"; - -export default async function GeneralLayoutsPage() { - const initialState = await getUserSettingsInitialState(); - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/message-queue/page.tsx b/apps/web/app/settings/general/message-queue/page.tsx deleted file mode 100644 index c654c0eae2..0000000000 --- a/apps/web/app/settings/general/message-queue/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; -import { MessageQueueSettings } from "@/components/settings/system/message-queue-settings"; -import { SystemPageShell } from "@/components/settings/system/system-page-shell"; - -export default function MessageQueueSettingsPage() { - const { t } = useTranslation(); - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/notifications/page.tsx b/apps/web/app/settings/general/notifications/page.tsx deleted file mode 100644 index b09f2284c8..0000000000 --- a/apps/web/app/settings/general/notifications/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { NotificationsSettings } from "@/components/settings/notifications-settings"; -import { StateProvider } from "@/components/state-provider"; -import { listNotificationProviders } from "@/lib/api"; - -export default async function GeneralNotificationsPage() { - let initialState = {}; - try { - const response = await listNotificationProviders({ cache: "no-store" }); - initialState = { - notificationProviders: { - items: response.providers ?? [], - events: response.events ?? [], - appriseAvailable: response.apprise_available ?? false, - loaded: true, - loading: false, - }, - }; - } catch { - initialState = {}; - } - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx deleted file mode 100644 index 70770bf3d4..0000000000 --- a/apps/web/app/settings/general/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GeneralSettings } from "@/components/settings/general-settings"; - -export default function GeneralSettingsPage() { - return ; -} diff --git a/apps/web/app/settings/general/resource-metrics/page.tsx b/apps/web/app/settings/general/resource-metrics/page.tsx deleted file mode 100644 index 1d0dcdb36f..0000000000 --- a/apps/web/app/settings/general/resource-metrics/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function GeneralResourceMetricsPage() { - redirect("/settings/general/appearance"); -} diff --git a/apps/web/app/settings/general/secrets/page.tsx b/apps/web/app/settings/general/secrets/page.tsx deleted file mode 100644 index b952e39a1a..0000000000 --- a/apps/web/app/settings/general/secrets/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SecretsSettings } from "@/components/settings/secrets-settings"; -import { StateProvider } from "@/components/state-provider"; -import { listSecrets } from "@/lib/api/domains/secrets-api"; - -export default async function GeneralSecretsPage() { - let initialState = {}; - try { - const items = await listSecrets({ cache: "no-store" }); - initialState = { - secrets: { - items: items ?? [], - loaded: true, - loading: false, - }, - }; - } catch { - initialState = {}; - } - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/shell/page.tsx b/apps/web/app/settings/general/shell/page.tsx deleted file mode 100644 index 7c9a8d3464..0000000000 --- a/apps/web/app/settings/general/shell/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function GeneralShellPage() { - redirect("/settings/general/terminal"); -} diff --git a/apps/web/app/settings/general/sprites/page.tsx b/apps/web/app/settings/general/sprites/page.tsx deleted file mode 100644 index 100bfbda57..0000000000 --- a/apps/web/app/settings/general/sprites/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { SpritesSettings } from "@/components/settings/sprites-settings"; -import { StateProvider } from "@/components/state-provider"; -import { getSpritesStatus, listSpritesInstances } from "@/lib/api/domains/sprites-api"; - -export default async function GeneralSpritesPage() { - let initialState = {}; - try { - const [status, instances] = await Promise.all([ - getSpritesStatus(undefined, { cache: "no-store" }), - listSpritesInstances(undefined, { cache: "no-store" }), - ]); - initialState = { - sprites: { - status, - instances: instances ?? [], - loaded: true, - loading: false, - }, - }; - } catch { - initialState = {}; - } - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/task-actions/page.tsx b/apps/web/app/settings/general/task-actions/page.tsx deleted file mode 100644 index cf768606e1..0000000000 --- a/apps/web/app/settings/general/task-actions/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { TaskActionsSettings } from "@/components/settings/general-settings"; -import { StateProvider } from "@/components/state-provider"; -import { getUserSettingsInitialState } from "../user-settings-state"; - -export default async function GeneralTaskActionsPage() { - const initialState = await getUserSettingsInitialState(); - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/terminal/page.tsx b/apps/web/app/settings/general/terminal/page.tsx deleted file mode 100644 index abec4dda6e..0000000000 --- a/apps/web/app/settings/general/terminal/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { TerminalSettings } from "@/components/settings/terminal-settings"; -import { StateProvider } from "@/components/state-provider"; -import { getUserSettingsInitialState } from "../user-settings-state"; - -export default async function GeneralTerminalPage() { - const initialState = await getUserSettingsInitialState(); - - return ( - - - - ); -} diff --git a/apps/web/app/settings/general/user-settings-state.ts b/apps/web/app/settings/general/user-settings-state.ts deleted file mode 100644 index 91e0c30aea..0000000000 --- a/apps/web/app/settings/general/user-settings-state.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fetchUserSettings } from "@/lib/api"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; - -export async function getUserSettingsInitialState() { - try { - const response = await fetchUserSettings({ cache: "no-store" }); - const mapped = mapUserSettingsResponse(response); - return { userSettings: mapped.loaded ? mapped : undefined }; - } catch { - return {}; - } -} diff --git a/apps/web/app/settings/integrations/page.test.tsx b/apps/web/app/settings/integrations/page.test.tsx index 4b286e3125..f8fac7d6a7 100644 --- a/apps/web/app/settings/integrations/page.test.tsx +++ b/apps/web/app/settings/integrations/page.test.tsx @@ -99,8 +99,10 @@ describe("IntegrationsIndexPage", () => { it("keeps workspace-scoped integration links on the workspace route", () => { renderPage("ws-1"); + // Plural: the restructure serves workspace settings from + // /settings/workspaces/, with the singular path kept as a redirect. expect(screen.getByRole("link", { name: "Azure DevOps" }).getAttribute("href")).toBe( - "/settings/workspace/ws-1/integrations/azure-devops", + "/settings/workspaces/ws-1/integrations/azure-devops", ); }); diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx deleted file mode 100644 index 011e59d656..0000000000 --- a/apps/web/app/settings/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function SettingsPage() { - redirect("/settings/general"); -} diff --git a/apps/web/app/settings/system/message-queue/page.tsx b/apps/web/app/settings/system/message-queue/page.tsx deleted file mode 100644 index 7d2fefb21a..0000000000 --- a/apps/web/app/settings/system/message-queue/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "@/lib/routing/server-navigation"; - -export default function MessageQueueSettingsPage() { - redirect("/settings/general/message-queue"); -} diff --git a/apps/web/app/settings/system/storage/page.tsx b/apps/web/app/settings/system/storage/page.tsx deleted file mode 100644 index 80a6a06d5c..0000000000 --- a/apps/web/app/settings/system/storage/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; -import { StorageMaintenanceSettings } from "@/components/settings/system/storage/storage-maintenance-settings"; -import { SystemPageShell } from "@/components/settings/system/system-page-shell"; - -export default function StoragePage() { - const { t } = useTranslation(); - return ( - - - - ); -} diff --git a/apps/web/app/settings/workspace/[id]/repositories/page.tsx b/apps/web/app/settings/workspace/[id]/repositories/page.tsx deleted file mode 100644 index 584510953e..0000000000 --- a/apps/web/app/settings/workspace/[id]/repositories/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { getWorkspaceAction, listRepositoriesAction } from "@/app/actions/workspaces"; -import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace-repositories-client"; -import type { Repository, RepositoryScript } from "@/lib/types/http"; - -type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; - -export default async function WorkspaceRepositoriesPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - let workspace = null; - let repositoriesWithScripts: RepositoryWithScripts[] = []; - - try { - const [ws, repoResponse] = await Promise.all([ - getWorkspaceAction(id), - listRepositoriesAction(id, { includeScripts: true }), - ]); - workspace = ws; - repositoriesWithScripts = repoResponse.repositories.map((repository) => ({ - ...repository, - scripts: repository.scripts ?? [], - })); - } catch { - workspace = null; - repositoriesWithScripts = []; - } - - return ( - - ); -} diff --git a/apps/web/app/settings/workspace/[id]/workflows/page.tsx b/apps/web/app/settings/workspace/[id]/workflows/page.tsx deleted file mode 100644 index 3ca7952f63..0000000000 --- a/apps/web/app/settings/workspace/[id]/workflows/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - getWorkspaceAction, - listWorkflowsAction, - listWorkflowTemplatesAction, -} from "@/app/actions/workspaces"; -import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client"; -import type { Workflow, WorkflowTemplate } from "@/lib/types/http"; - -export default async function WorkspaceWorkflowsPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - let workspace = null; - let workflows: Workflow[] = []; - let workflowTemplates: WorkflowTemplate[] = []; - - try { - workspace = await getWorkspaceAction(id); - const [workflowsResponse, templatesResponse] = await Promise.all([ - listWorkflowsAction(id), - listWorkflowTemplatesAction(), - ]); - workflows = workflowsResponse.workflows; - workflowTemplates = templatesResponse.templates ?? []; - } catch { - workspace = null; - workflows = []; - workflowTemplates = []; - } - - return ( - - ); -} diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 5e1e1456ba..17f100f37d 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -5,7 +5,7 @@ import { Trans, useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; -import { IconGitBranch, IconLayoutColumns, IconTrash } from "@tabler/icons-react"; +import { IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -34,6 +34,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { SettingsTarget } from "@/components/settings/settings-target"; import { workspaceDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; +import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header"; type WorkspaceEditClientProps = { workspaceId: string; @@ -52,7 +53,7 @@ export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) {

{t("workspaces:workspaceNotFound")}

@@ -204,37 +205,6 @@ function WorkspaceSettingsCard({ ); } -type WorkspaceLinksCardProps = { - workspaceId: string; -}; - -function WorkspaceLinksCard({ workspaceId }: WorkspaceLinksCardProps) { - const { t } = useTranslation(); - return ( - - - {t("workspaces:workspaceLinks")} - - -
- - -
-
-
- ); -} - type DeleteWorkspaceCardProps = { workspaceName: string; deleteDialogOpen: boolean; @@ -482,7 +452,7 @@ function useWorkspaceEditForm(workspace: Workspace) { try { await deleteWorkspaceRequest.run(currentWorkspace.id, currentWorkspace.name, officeEnabled); setWorkspaces(workspaces.filter((ws: Workspace) => ws.id !== currentWorkspace.id)); - runWithNavigationBlockerBypassed(() => router.push("/settings/workspace")); + runWithNavigationBlockerBypassed(() => router.push("/settings/workspaces")); } catch (error) { toast({ title: t("workspaces:failedToDeleteWorkspace"), @@ -567,12 +537,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { return (
-
-

{currentWorkspace.name}

-

- {t("workspaces:manageWorkspaceDetails")} -

-
+ - router.push("/settings/workspace")} />; + return router.push("/settings/workspaces")} />; return (
- - + {/* No section header: the Repositories section below already carries the + name, mark and description, and the tab strip above says which tab you + are on. A second copy of all three read as the page repeating itself. */} } title={t("workspaces:repositories")} - description={t("workspaces:repositoriesInThisWorkspace")} + description={ + isImproveWorkspace + ? t("workspaces:repositoriesReadOnlyImprove") + : t("workspaces:repositoriesInThisWorkspace") + } action={ isImproveWorkspace ? undefined : ( -
- ); -} diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 04e4584d13..28937ae61e 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -3,7 +3,6 @@ import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { t as translate } from "@/lib/i18n"; -import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { IconGripVertical, IconArrowsShuffle } from "@tabler/icons-react"; import { @@ -21,8 +20,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Button } from "@kandev/ui/button"; -import { Separator } from "@kandev/ui/separator"; import { SettingsSection } from "@/components/settings/settings-section"; import { WorkflowCard } from "@/components/settings/workflow-card"; import { WorkflowSectionActions } from "@/components/settings/workflow-section-actions"; @@ -428,30 +425,24 @@ export function WorkspaceWorkflowsClient({ const [syncDialogOpen, setSyncDialogOpen] = useState(false); if (!workspace) - return page.router.push("/settings/workspace")} />; + return page.router.push("/settings/workspaces")} />; return (
-
-
-

{workspace.name}

-

- {isImproveWorkspace - ? t("workflows:workflowsReadOnlyImprove") - : t("workflows:manageWorkflowsForThisWorkspace")} -

-
- -
- + {/* No section header — see the Repositories tab: the section below is + already named, marked and described. */} } title={t("workflows:workflows")} - description={t("workflows:workflowsSectionDescription")} + // The read-only note is the section's description here rather than a + // heading above it: the duplicated heading is gone, and this workspace + // being immutable is the one thing the static description omits. + description={ + isImproveWorkspace + ? t("workflows:workflowsReadOnlyImprove") + : t("workflows:workflowsSectionDescription") + } action={ isImproveWorkspace ? undefined : ( s.workspaces.activeId); + const isActive = workspace.id === activeId; + const { counts, settled } = useWorkspaceSectionCounts(workspace.id); + const total = Object.values(counts).reduce((sum, value) => sum + (value ?? 0), 0); return ( - - - -
-
-
- -
-
-

{workspace.name}

-
- {t("workspaces:workflowsManagedInThisWorkspace")} -
-
+ + {/* Whole-card link as an overlay — the section tiles sit above it (z-10). */} + + +
+
+
+

{workspace.name}

+ {isActive && }
- +

+ {settled ? t("workspaces:resourceCount", { count: total }) : "\u00a0"} +

- - - + +
+ + +
+
); } diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index bdff8093b0..dfe7b42d3b 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -2,9 +2,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Button } from "@kandev/ui/button"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageShell } from "@/components/page-shell"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { IconChartBar } from "@tabler/icons-react"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; @@ -58,7 +59,6 @@ import { type StatsSections, useStatsSections, } from "./stats-data"; -import { useTranslation } from "react-i18next"; interface StatsPageClientProps { workspaceId?: string; @@ -75,16 +75,19 @@ const RANGE_LABEL_KEYS: Record = { function StatsEmptyState({ message }: { message: string }) { const { t } = useTranslation(); return ( -
- } /> -
+ } + scroll="none" + > +

{message}

-
+ ); } -type StatsHeaderProps = { +type StatsShellProps = { global: GlobalStatsDTO | null; range: RangeKey; copied: boolean; @@ -92,9 +95,10 @@ type StatsHeaderProps = { hasError: boolean; onRangeChange: (r: RangeKey) => void; onCopy: () => void; + children: React.ReactNode; }; -function StatsHeader({ +function StatsShell({ global, range, copied, @@ -102,13 +106,15 @@ function StatsHeader({ hasError, onRangeChange, onCopy, -}: StatsHeaderProps) { + children, +}: StatsShellProps) { const { t } = useTranslation(); return ( - } subtitle={getSubtitle(global, hasError)} + scroll="none" actions={ <> } - /> + > + {children} + ); } @@ -370,7 +378,7 @@ function StatsContent({ const { t } = useTranslation(); const taskStatus = flattenTaskStats(sections.tasks); return ( -
+
@@ -434,30 +442,32 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat if (initialError) return ( -
- } /> -
+ } + scroll="none" + > +

{t("stats:errorLoadingStats", { error: initialError })}

-
+ ); if (!workspaceId) return ; return ( -
- + -
+ ); } diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx index 6517fdb3ec..85798a0bc8 100644 --- a/apps/web/app/tasks/tasks-list-view.tsx +++ b/apps/web/app/tasks/tasks-list-view.tsx @@ -70,8 +70,9 @@ export function TasksListView({ handleDelete, onRefresh, }: TasksListViewProps) { + // Not a
: AppShell owns that landmark, one per page. const content = ( -
+
-
+
); return onRefresh ? {content} : content; } diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index 1540af78d7..d9c062403e 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -34,7 +34,16 @@ const DEFAULT_PATHNAME = "/tasks/session-1"; let pathname = DEFAULT_PATHNAME; vi.mock("@/lib/routing/client-router", () => ({ - useRouter: () => ({ push: mocks.routerPush }), + useRouter: () => ({ + push: (...args: [string, { onNavigated?: () => void }?]) => { + // Forward verbatim so callers passing no options still assert as a + // single-argument call. + mocks.routerPush(...args); + // The real router runs onNavigated only once the push commits; the + // unsaved-changes guard can cancel it, which `blockNavigation` models. + if (!blockNavigation) args[1]?.onNavigated?.(); + }, + }), usePathname: () => pathname, })); @@ -121,25 +130,33 @@ function renderFooter() { ); } +function resetFooterState() { + officeEnabled = false; + blockNavigation = false; + pathname = DEFAULT_PATHNAME; + state.workspaces.activeId = "kanban-1"; + state.workspaces.items = [ + { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, + { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, + { id: "office-2", name: "Office 2", office_workflow_id: "wf-office-2" }, + ]; + state.appSidebar.settingsMode = false; + state.auth = { mode: "disabled", user: null }; + state.connection.issueSeverity = "none"; + appStatusBarEnabled = true; + window.localStorage.clear(); + document.cookie = "office-active-workspace=; path=/; max-age=0"; + mocks.routerPush.mockClear(); + mocks.toggleSettingsMode.mockClear(); +} + +const KANBAN_HOME_HREF = "/?home=overview&workspaceId=kanban-1"; +let blockNavigation = false; + +const GEAR_TEST_ID = "sidebar-settings-gear"; + describe("AppSidebarFooter", () => { - beforeEach(() => { - officeEnabled = false; - pathname = DEFAULT_PATHNAME; - state.workspaces.activeId = "kanban-1"; - state.workspaces.items = [ - { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, - { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, - { id: "office-2", name: "Office 2", office_workflow_id: "wf-office-2" }, - ]; - state.appSidebar.settingsMode = false; - state.auth = { mode: "disabled", user: null }; - state.connection.issueSeverity = "none"; - appStatusBarEnabled = true; - window.localStorage.clear(); - document.cookie = "office-active-workspace=; path=/; max-age=0"; - mocks.routerPush.mockClear(); - mocks.toggleSettingsMode.mockClear(); - }); + beforeEach(resetFooterState); afterEach(() => cleanup()); @@ -204,7 +221,7 @@ describe("AppSidebarFooter", () => { expect(screen.queryByRole("button", { name: "Office" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Kanban" })); - expect(mocks.routerPush).toHaveBeenCalledWith("/?home=overview&workspaceId=kanban-1"); + expect(mocks.routerPush).toHaveBeenCalledWith(KANBAN_HOME_HREF); }); it("remembers the current office workspace when toggling back to kanban", () => { @@ -217,16 +234,22 @@ describe("AppSidebarFooter", () => { fireEvent.click(screen.getByRole("button", { name: "Kanban" })); expect(document.cookie).toContain("office-active-workspace=office-2"); - expect(mocks.routerPush).toHaveBeenCalledWith("/?home=overview&workspaceId=kanban-1"); + expect(mocks.routerPush).toHaveBeenCalledWith(KANBAN_HOME_HREF); }); +}); + +describe("AppSidebarFooter settings gear", () => { + beforeEach(resetFooterState); + afterEach(() => cleanup()); + it("navigates to /settings when the gear opens settings mode from a non-settings route", () => { pathname = DEFAULT_PATHNAME; state.appSidebar.settingsMode = false; renderFooter(); - fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); - expect(mocks.routerPush).toHaveBeenCalledWith("/settings"); + expect(mocks.routerPush).toHaveBeenCalledWith("/settings", expect.anything()); expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); }); @@ -235,18 +258,58 @@ describe("AppSidebarFooter", () => { state.appSidebar.settingsMode = false; renderFooter(); - fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); expect(mocks.routerPush).not.toHaveBeenCalled(); expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); }); - it("does not navigate when the gear closes an already-open settings mode", () => { - pathname = "/settings"; + it("leaves the settings surface when the gear closes an open settings mode", () => { + pathname = "/settings/general/appearance"; state.appSidebar.settingsMode = true; renderFooter(); - fireEvent.click(screen.getByTestId("sidebar-settings-gear")); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); + + // Swapping the sidebar back while the main panel stayed on a settings page + // left kanban navigation beside an open settings page. + expect(mocks.routerPush).toHaveBeenCalledWith(KANBAN_HOME_HREF, expect.anything()); + expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); + }); + + // The unsaved-changes guard cancels the push when the user picks "Continue + // editing". Toggling anyway left the URL in Settings with the sidebar already + // back on kanban navigation. + it("keeps the sidebar in settings mode when the guard cancels the navigation", () => { + pathname = "/settings/general/appearance"; + state.appSidebar.settingsMode = true; + blockNavigation = true; + + renderFooter(); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); + + expect(mocks.routerPush).toHaveBeenCalledWith(KANBAN_HOME_HREF, expect.anything()); + expect(mocks.toggleSettingsMode).not.toHaveBeenCalled(); + }); + + it("does not open settings mode when the guard cancels the way in", () => { + pathname = DEFAULT_PATHNAME; + state.appSidebar.settingsMode = false; + blockNavigation = true; + + renderFooter(); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); + + expect(mocks.routerPush).toHaveBeenCalledWith("/settings", expect.anything()); + expect(mocks.toggleSettingsMode).not.toHaveBeenCalled(); + }); + + it("does not navigate when the gear closes settings mode off a settings route", () => { + pathname = "/tasks"; + state.appSidebar.settingsMode = true; + + renderFooter(); + fireEvent.click(screen.getByTestId(GEAR_TEST_ID)); expect(mocks.routerPush).not.toHaveBeenCalled(); expect(mocks.toggleSettingsMode).toHaveBeenCalledOnce(); diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 8acc7fbd34..a5dd197ac1 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -209,10 +209,48 @@ function InsightFooterButtons({ ); } +/** + * The gear both navigates and swaps the sidebar's content, in both directions. + * + * Entering: without the navigation the main panel keeps showing whatever the + * user was on (e.g. a task session), so the first click looks like a no-op and a + * second click on a tree leaf is what actually reaches Settings. Match the + * Stats/Office buttons — one click gets you there. + * + * Leaving: swapping the sidebar back while the main panel stayed on a settings + * page left the two disagreeing — kanban navigation beside an open settings + * page, with no route back to the tree except the gear that had just closed it. + * + * Either way the swap waits for the navigation to commit. A settings page with + * unsaved edits blocks the push, and "Continue editing" cancels it: toggling + * regardless left the URL in Settings with the sidebar already back on kanban + * navigation — the same disagreement, reached from the other side. + */ +function useSettingsGearToggle( + settingsMode: boolean, + activeWorkspace: Parameters[0], + onToggleSettingsMode: () => void, +) { + const router = useRouter(); + const pathname = usePathname(); + + return () => { + const onSettingsRoute = isSettingsRoute(pathname); + if (!settingsMode && !onSettingsRoute) { + router.push("/settings", { onNavigated: onToggleSettingsMode }); + return; + } + if (settingsMode && onSettingsRoute) { + router.push(workspaceHomeHref(activeWorkspace), { onNavigated: onToggleSettingsMode }); + return; + } + onToggleSettingsMode(); + }; +} + export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) { const { t } = useTranslation(); const router = useRouter(); - const pathname = usePathname(); const workspaces = useAppStore((s) => s.workspaces); const workspaceId = workspaces.activeId; const activeWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId); @@ -221,13 +259,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar ? resolveLastKanbanWorkspace(workspaces.items) : resolveLastOfficeWorkspace(workspaces.items); const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); - const enterSettings = () => { - // One click navigates to Settings (like the Stats/Office buttons). - if (!settingsMode && !isSettingsRoute(pathname)) { - router.push("/settings"); - } - onToggleSettingsMode(); - }; + const toggleSettings = useSettingsGearToggle(settingsMode, activeWorkspace, onToggleSettingsMode); const officeEnabled = useFeature("office"); const appStatusBarEnabled = useFeature("appStatusBar"); const insightDestinations = useStaticDestinations("sidebar", "insights"); @@ -249,7 +281,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar icon={IconSettings} label={settingsMode ? t("sidebar:closeSettings") : t("common:settings")} collapsed={collapsed} - onClick={enterSettings} + onClick={toggleSettings} active={settingsMode} testId="sidebar-settings-gear" /> diff --git a/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx index 1b18198134..20f6caa481 100644 --- a/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx @@ -2,8 +2,8 @@ import { useTranslation } from "react-i18next"; import { usePathname } from "@/lib/routing/client-router"; -import { IconSettings, IconChevronLeft } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; +import { IconSettings } from "@tabler/icons-react"; +import { CollapseAllButton } from "./sections/settings/collapse-all-button"; import { SettingsTree } from "./sections/settings/settings-tree"; /** @@ -11,34 +11,27 @@ import { SettingsTree } from "./sections/settings/settings-tree"; * active. Replaces the normal primary nav + sections with just the settings * tree, which fills the remaining height and scrolls internally. * - * The header doubles as the exit affordance: the footer gear sits at the bottom - * of the rail, far from the tree, so clicking the "Settings" header at the top - * closes the takeover and returns to the normal sidebar — regardless of which - * group is currently expanded. + * The header is a plain label. It used to double as an exit button, but it + * only flipped the sidebar back without leaving the /settings route, stranding + * the main area on a settings page next to kanban navigation. Exits are the + * topbar home crumb (always visible in settings mode) and the footer gear. */ export function AppSidebarSettingsMode() { const { t } = useTranslation(); const pathname = usePathname(); - const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); return (
- + +
diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index 56fa5497b5..e7614c5666 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -114,7 +114,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { fireEvent.click(screen.getByText("New kanban workspace")); - expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspace"); + expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspaces"); }); it("keeps the legacy add-workspace route when the office feature is disabled", () => { @@ -123,7 +123,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { fireEvent.click(screen.getByText("Add workspace")); - expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspace"); + expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspaces"); }); it("calls onActionComplete after navigating to add a workspace", () => { @@ -239,6 +239,25 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( expect(screen.getByTestId(OFFICE_WORKSPACE_ITEM).textContent).toContain("Office"); }); + it("marks the active workspace with the Active badge between its name and its type", () => { + storeState.workspaces.activeId = "w1"; + render(); + + const activeRow = screen.getByTestId(KANBAN_WORKSPACE_ITEM).textContent ?? ""; + expect(activeRow).toContain("Active"); + expect(activeRow.indexOf("Active")).toBeGreaterThan(activeRow.indexOf("Default Workspace")); + expect(activeRow.indexOf("Active")).toBeLessThan(activeRow.indexOf("Kanban")); + }); + + it("leaves non-active workspace rows unbadged and drops the check icon", () => { + storeState.workspaces.activeId = "w1"; + const { container } = render(); + + expect(screen.getByTestId(OFFICE_WORKSPACE_ITEM).textContent).not.toContain("Active"); + expect(screen.getByTestId(ALTERNATE_KANBAN_WORKSPACE_ITEM).textContent).not.toContain("Active"); + expect(container.querySelector(".tabler-icon-check")).toBeNull(); + }); + it("renders the trigger as an obvious selector", () => { render(); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index 59f6df5fe3..567a1cfa50 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -1,25 +1,18 @@ "use client"; -import { forwardRef, useCallback, useState, type ComponentPropsWithoutRef } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "@/lib/routing/client-router"; -import { - IconBriefcase, - IconCheck, - IconChevronDown, - IconLayoutKanban, - IconPlus, -} from "@tabler/icons-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@kandev/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; import { cn } from "@/lib/utils"; +import { + WorkspacePickerContent, + WorkspaceTrigger, + workspaceType, + type WorkspaceItem, +} from "@/components/workspaces/workspace-picker-content"; import { rememberLastOfficeWorkspace, rememberLastKanbanWorkspace, @@ -32,42 +25,10 @@ import { * workspace name truncates with a small chevron hinting the dropdown. Only * rendered while the sidebar is expanded — the collapsed rail omits it. * - * forwardRef + prop spread so `DropdownMenuTrigger asChild` can wire the trigger - * (ref, onClick, aria-*, data-state) onto the underlying button. + * The trigger and the rows come from `workspace-picker-content`, shared with + * the workspace settings switcher; only the selection behaviour below is this + * surface's own. */ -const WorkspaceTrigger = forwardRef< - HTMLButtonElement, - ComponentPropsWithoutRef<"button"> & { activeName: string; chevronTestId: string } ->(function WorkspaceTrigger({ activeName, chevronTestId, className, ...props }, ref) { - const { t } = useTranslation(); - return ( - - ); -}); - -type WorkspaceType = "kanban" | "office"; - -type WorkspaceItem = { - id: string; - name: string; - office_workflow_id?: string | null; -}; type WorkspacePickerProps = { triggerClassName?: string; @@ -111,22 +72,6 @@ function useMenuOpenState(controlledOpen?: boolean, onOpenChange?: (open: boolea return { open: isControlled ? controlledOpen : uncontrolledOpen, setOpen }; } -function workspaceType(workspace: WorkspaceItem | undefined): WorkspaceType { - return workspace?.office_workflow_id ? "office" : "kanban"; -} - -/** Returns a catalog key, not copy: this is a plain function with no hook. */ -function workspaceTypeLabelKey(type: WorkspaceType) { - return type === "office" ? "sidebar:office" : "sidebar:kanban"; -} - -function WorkspaceTypeIcon({ type, className }: { type: WorkspaceType; className: string }) { - if (type === "office") { - return ; - } - return ; -} - function rememberSelectedWorkspace(workspace: WorkspaceItem) { if (workspaceType(workspace) === "office") { rememberLastOfficeWorkspace(workspace); @@ -135,108 +80,6 @@ function rememberSelectedWorkspace(workspace: WorkspaceItem) { } } -type WorkspacePickerContentProps = { - workspaces: WorkspaceItem[]; - activeId: string | null; - itemTestIdPrefix: string; - officeEnabled: boolean; - onWorkspaceSelect: (workspace: WorkspaceItem) => void; - onNavigate: (href: string) => void; -}; - -function WorkspacePickerContent({ - workspaces, - activeId, - itemTestIdPrefix, - officeEnabled, - onWorkspaceSelect, - onNavigate, -}: WorkspacePickerContentProps) { - return ( - <> - - - - - ); -} - -function WorkspaceList({ - workspaces, - activeId, - itemTestIdPrefix, - onWorkspaceSelect, -}: Pick< - WorkspacePickerContentProps, - "workspaces" | "activeId" | "itemTestIdPrefix" | "onWorkspaceSelect" ->) { - const { t } = useTranslation(); - if (workspaces.length === 0) { - return {t("sidebar:noWorkspaces")}; - } - - return workspaces.map((ws) => { - const type = workspaceType(ws); - return ( - onWorkspaceSelect(ws)} - className="cursor-pointer gap-2" - > - - {ws.name} - - {t(workspaceTypeLabelKey(type))} - - {ws.id === activeId && } - - ); - }); -} - -function WorkspaceCreateItems({ - officeEnabled, - onNavigate, -}: Pick) { - const { t } = useTranslation(); - if (!officeEnabled) { - return ( - onNavigate("/settings/workspace")} - > - - {t("sidebar:addWorkspace")} - - ); - } - - return ( - <> - onNavigate("/settings/workspace")} - > - - {t("sidebar:newKanbanWorkspace")} - - onNavigate("/office/setup?mode=new")} - > - - {t("sidebar:newOfficeWorkspace")} - - - ); -} - export function AppSidebarWorkspacePicker({ triggerClassName, contentClassName, @@ -316,6 +159,9 @@ export function AppSidebarWorkspacePicker({ diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index c48749ce5f..04be0a96a3 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -102,15 +102,54 @@ function AppSidebarNavigation({ collapsed, inOffice, settingsMode }: AppSidebarN * Desktop-only (`hidden md:block`) — mobile surfaces carry their own nav (mobile * headers and menu sheets), so the global rail never overlays mobile content. */ +/** + * The transient settings takeover, kept aligned with route ownership. It is + * intentionally not persisted, so a direct reload on `/settings/...` has to + * enter it from the current pathname. + * + * Keyed on actual pathname changes, and only forced when *entering* the settings + * surface: moving within it must not reopen a takeover the user closed. Leaving + * it closes the takeover unless the user's own toggle was the thing that + * navigated (`togglePathnameRef`), because history updates before React renders + * the new pathname. + */ +function useSettingsTakeover(pathname: string | null) { + const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); + const setSettingsMode = useAppStore((s) => s.setAppSidebarSettingsMode); + const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); + const togglePathnameRef = useRef(null); + const prevPathnameRef = useRef(null); + + const toggle = useCallback(() => { + togglePathnameRef.current = window.location.pathname; + toggleSettingsMode(); + }, [toggleSettingsMode]); + + useEffect(() => { + if (!pathname || prevPathnameRef.current === pathname) return; + const cameFromSettings = isSettingsRoute(prevPathnameRef.current); + prevPathnameRef.current = pathname; + if (isSettingsRoute(pathname)) { + togglePathnameRef.current = null; + // `/settings` handing off to the last page visited (see `SettingsIndex`) + // is a settings-to-settings navigation, and so is every tree click. + if (!settingsMode && !cameFromSettings) setSettingsMode(true); + return; + } + if (settingsMode && togglePathnameRef.current !== pathname) { + setSettingsMode(false); + } + }, [pathname, settingsMode, setSettingsMode]); + + return { settingsMode, toggleSettingsMode: toggle }; +} + export function AppSidebar() { const collapsed = useAppStore((s) => s.appSidebar.collapsed); - const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); const sectionExpanded = useAppStore((s) => s.appSidebar.sectionExpanded); const storedWidth = useAppStore((s) => s.appSidebar.width); const toggleSection = useAppStore((s) => s.toggleAppSidebarSection); const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar); - const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); - const setSettingsMode = useAppStore((s) => s.setAppSidebarSettingsMode); const setWidth = useAppStore((s) => s.setAppSidebarWidth); const pathname = usePathname(); const inOffice = useInOffice(); @@ -151,32 +190,8 @@ export function AppSidebar() { const expandedWidth = Math.max(APP_SIDEBAR_EXPANDED_WIDTH, storedWidth); const targetWidth = collapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth; - const settingsModeTogglePathnameRef = useRef(null); - - const handleToggleSettingsMode = useCallback(() => { - // History updates before React renders the new pathname; remember which - // route the user actually clicked on so delayed route sync cannot undo it. - settingsModeTogglePathnameRef.current = window.location.pathname; - toggleSettingsMode(); - }, [toggleSettingsMode]); - - // Keep the transient settings takeover aligned with route ownership. It is - // intentionally not persisted, so direct reloads on `/settings/...` need to - // enter it from the current pathname. Key on actual pathname changes so a - // user can still close the takeover while staying on a settings page. - const prevPathnameRef = useRef(null); - useEffect(() => { - if (!pathname || prevPathnameRef.current === pathname) return; - prevPathnameRef.current = pathname; - if (isSettingsRoute(pathname)) { - settingsModeTogglePathnameRef.current = null; - if (!settingsMode) setSettingsMode(true); - return; - } - if (settingsMode && settingsModeTogglePathnameRef.current !== pathname) { - setSettingsMode(false); - } - }, [pathname, settingsMode, setSettingsMode]); + const { settingsMode, toggleSettingsMode: handleToggleSettingsMode } = + useSettingsTakeover(pathname); useEffect(() => { if (!pathname) return; diff --git a/apps/web/components/app-sidebar/sections/settings/account-group.tsx b/apps/web/components/app-sidebar/sections/settings/account-group.tsx deleted file mode 100644 index 1a64d17ef3..0000000000 --- a/apps/web/components/app-sidebar/sections/settings/account-group.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { IconKey, IconShieldLock, IconUserCircle } from "@tabler/icons-react"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import { - ACCOUNT_DISCOVERY_DEFINITIONS, - ACCOUNT_SECURITY_SETTINGS_HREF, - ACCOUNT_SETTINGS_HREF, -} from "@/lib/settings-discovery/catalog/account"; -import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; - -const ACCOUNT_ITEM_ICONS: Record = { - "account-security": IconShieldLock, - "account-tokens": IconKey, -}; - -const ITEMS = ACCOUNT_DISCOVERY_DEFINITIONS.filter( - (item) => item.parentId === "account" && item.labelKey, -).map((item) => ({ - ...item, - labelKey: item.labelKey as string, - icon: ACCOUNT_ITEM_ICONS[item.id], -})); - -type AccountGroupProps = { - pathname: string; - expanded?: boolean; - onToggle?: () => void; -}; - -export function AccountGroup({ pathname, expanded, onToggle }: AccountGroupProps) { - const { t } = useTranslation(); - return ( - - {ITEMS.map(({ href, labelKey, icon }) => ( - - ))} - - ); -} diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx deleted file mode 100644 index e1c5deae6e..0000000000 --- a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; -import { IconRobot } from "@tabler/icons-react"; -import { AgentLogo } from "@/components/agent-logo"; -import { useAppStore } from "@/components/state-provider"; -import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; -import { AGENTS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; -import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; - -// Mirrors the integration-list EnabledBadge (workspaces-group.tsx) with an -// "off" color. Disabled profiles stay visible here so they remain editable. -function DisabledBadge() { - const { t } = useTranslation(); - return ( - - {t("sidebar:disabledBadge")} - - ); -} - -type AgentsGroupProps = { - pathname: string; - expanded?: boolean; - onToggle?: () => void; -}; - -export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) { - const { t } = useTranslation(); - const agents = useAppStore((s) => s.settingsAgents.items); - useAvailableAgents(); - - return ( - - {agents.flatMap((agent) => - agent.profiles.map((profile) => { - const encodedAgent = encodeURIComponent(agent.name); - const profilePath = `${AGENTS_SETTINGS_HREF}/${encodedAgent}/profiles/${profile.id}`; - const agentLabel = profile.agentDisplayName || agent.name; - return ( - : undefined} - leadingIcon={} - isActive={pathname === profilePath} - depth={1} - /> - ); - }), - )} - - ); -} diff --git a/apps/web/components/app-sidebar/sections/settings/collapse-all-button.test.tsx b/apps/web/components/app-sidebar/sections/settings/collapse-all-button.test.tsx new file mode 100644 index 0000000000..7953edf406 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/collapse-all-button.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode"; + +const state = { + settingsMenu: { + mode: "persistent" as SettingsMenuMode, + savedMode: "persistent" as SettingsMenuMode, + expandedKeys: ["row:/settings/workspaces", "workspace:ws-1"] as string[], + }, + setSettingsMenuExpandedKeys: vi.fn((keys: string[]) => { + state.settingsMenu.expandedKeys = keys; + }), +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +import { CollapseAllButton } from "./collapse-all-button"; + +const LABEL = "Collapse all"; + +// The app wraps the whole shell in one (app/layout.tsx); Radix needs it present. +function renderButton() { + return render( + + + , + ); +} + +describe("CollapseAllButton", () => { + beforeEach(() => { + state.settingsMenu.mode = "persistent"; + state.settingsMenu.expandedKeys = ["row:/settings/workspaces", "workspace:ws-1"]; + state.setSettingsMenuExpandedKeys.mockClear(); + }); + + afterEach(cleanup); + + it("shuts every open branch", () => { + renderButton(); + + fireEvent.click(screen.getByRole("button", { name: LABEL })); + + expect(state.setSettingsMenuExpandedKeys).toHaveBeenCalledWith([]); + }); + + it("greys out rather than vanishing when nothing is open", () => { + state.settingsMenu.expandedKeys = []; + renderButton(); + + // Present but inert: a control that disappears is harder to find again + // than one that dims. + expect(screen.getByRole("button", { name: LABEL }).hasAttribute("disabled")).toBe(true); + }); + + for (const mode of ["flat", "accordion"] as const) { + it(`stays out of ${mode} mode, which cannot accumulate open branches`, () => { + state.settingsMenu.mode = mode; + renderButton(); + + expect(screen.queryByRole("button", { name: LABEL })).toBeNull(); + }); + } +}); diff --git a/apps/web/components/app-sidebar/sections/settings/collapse-all-button.tsx b/apps/web/components/app-sidebar/sections/settings/collapse-all-button.tsx new file mode 100644 index 0000000000..eaee0261f4 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/collapse-all-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { IconChevronsUp } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; + +import { useAppStore } from "@/components/state-provider"; + +/** + * Shuts every open branch of the settings menu. + * + * Only in `persistent` mode, and only there because only there is it possible + * to be stuck: accordion holds one path open and derives it from the route, so + * moving anywhere already closes what you left, and `flat` has no branches at + * all. Persistent is the mode that accumulates — that is its whole point — so + * it is the one that needs a way back to a short menu. + * + * Disabled rather than hidden when nothing is open: a control that comes and + * goes as you expand things is harder to find than one that greys out. + */ +export function CollapseAllButton() { + const { t } = useTranslation(); + const mode = useAppStore((s) => s.settingsMenu.mode); + const expandedKeys = useAppStore((s) => s.settingsMenu.expandedKeys); + const setExpandedKeys = useAppStore((s) => s.setSettingsMenuExpandedKeys); + + if (mode !== "persistent") return null; + + const label = t("sidebar:collapseAllBranches"); + return ( + + + + + {label} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx deleted file mode 100644 index 0d277011f4..0000000000 --- a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { IconCpu } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import { useAppStore } from "@/components/state-provider"; -import { getExecutorIcon } from "@/lib/executor-icons"; -import { EXECUTORS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/executors"; -import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; - -type ExecutorsGroupProps = { - pathname: string; - expanded?: boolean; - onToggle?: () => void; -}; - -export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupProps) { - const { t } = useTranslation(); - const executors = useAppStore((s) => s.executors.items); - const allProfiles = executors.flatMap((executor) => - (executor.profiles ?? []).map((profile) => ({ ...profile, executorType: executor.type })), - ); - - return ( - - {allProfiles.map((profile) => { - const Icon = getExecutorIcon(profile.executorType); - const profilePath = `${EXECUTORS_SETTINGS_HREF}/${profile.id}`; - return ( - - ); - })} - - ); -} diff --git a/apps/web/components/app-sidebar/sections/settings/general-group.tsx b/apps/web/components/app-sidebar/sections/settings/general-group.tsx deleted file mode 100644 index b3acad0842..0000000000 --- a/apps/web/components/app-sidebar/sections/settings/general-group.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; -import { IconSettings } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import { useGeneralNavItems } from "@/components/settings/general-nav"; -import { GENERAL_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/general"; -import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; - -type GeneralGroupProps = { - pathname: string; - expanded?: boolean; - onToggle?: () => void; -}; - -export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps) { - const navItems = useGeneralNavItems(); - const { t } = useTranslation(); - return ( - - {navItems.map(({ href, label, icon }) => ( - - ))} - - ); -} diff --git a/apps/web/components/app-sidebar/sections/settings/integration-enabled.tsx b/apps/web/components/app-sidebar/sections/settings/integration-enabled.tsx new file mode 100644 index 0000000000..3688d73d20 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/integration-enabled.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +import { IntegrationEnabledBadge } from "@/components/settings/record-badges"; +import { + useEnabledIntegrations, + type IntegrationSlug, +} from "@/hooks/domains/integrations/use-enabled-integrations"; + +const NONE: ReadonlySet = new Set(); + +const EnabledIntegrationsContext = createContext>(NONE); + +/** + * Probes one workspace's integrations once and shares the answer with its rows. + * + * Per branch rather than per row: `useIntegrationAuthed` fetches from its own + * effect with no shared cache, so a hook call in each of the six integration + * rows would be six times the requests for one answer. + * + * Mounted inside the branch's collapsible content, which Radix unmounts when + * closed — so a workspace whose Integrations are shut costs nothing, and the + * probes start when you open them. That is what the accordion tree did before + * the menu replaced it. + */ +export function IntegrationsEnabledProvider({ + workspaceId, + children, +}: { + workspaceId: string; + children: ReactNode; +}) { + const enabled = useEnabledIntegrations(workspaceId); + return ( + + {children} + + ); +} + +/** + * The badge for one integration row, or nothing. Renders null outside a + * provider, which is what a row gets while its branch is still mounting. + */ +export function IntegrationEnabledBadgeFor({ slug }: { slug: IntegrationSlug }) { + const enabled = useContext(EnabledIntegrationsContext); + return enabled.has(slug) ? : null; +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.test.ts new file mode 100644 index 0000000000..8139f957c6 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentsBranch, + buildBranchRoot, + buildExecutorsBranch, + buildWorkspacesBranch, + findActiveNodePath, + findNodeKeyPath, + findSubtreeKeys, + settingsMenuRowKey, + type SettingsMenuNode, +} from "./settings-menu-branches"; + +const WORKSPACE_ID = "ws-1"; +const WORKSPACES_HREF = "/settings/workspaces"; +const WORKSPACES = [{ id: WORKSPACE_ID, name: "Main Workspace" }]; +const AGENTS = [ + { + name: "claude-code", + profiles: [ + { id: "profile-1", name: "Default", agentDisplayName: "Claude Code" }, + { id: "profile-2", name: "Review", agentDisplayName: "Claude Code" }, + ], + }, +]; +const EXECUTORS = [ + { + id: "exec-1", + name: "Local Executor", + type: "local", + profiles: [{ id: "exec-profile-1", name: "Local" }], + }, +]; + +function hrefsOf(nodes: readonly SettingsMenuNode[]): Array { + return nodes.map((node) => node.href); +} + +/** The Integrations tab among a workspace node's children. */ +function integrationsTabOf(tabs: readonly SettingsMenuNode[]): SettingsMenuNode | undefined { + return tabs.find((node) => node.href?.endsWith("/integrations")); +} + +describe("buildWorkspacesBranch", () => { + it("hangs each workspace's tabs under it, minus its own overview page", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + + expect(workspace.href).toBe(`/settings/workspaces/${WORKSPACE_ID}`); + expect(hrefsOf(workspace.children ?? [])).toEqual([ + `/settings/workspaces/${WORKSPACE_ID}/repositories`, + `/settings/workspaces/${WORKSPACE_ID}/workflows`, + `/settings/workspaces/${WORKSPACE_ID}/integrations`, + `/settings/workspaces/${WORKSPACE_ID}/automations`, + `/settings/workspaces/${WORKSPACE_ID}/secrets`, + ]); + }); + + it("goes one level deeper for integrations, the menu's deepest branch", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + const integrations = integrationsTabOf(workspace.children ?? []); + + expect(hrefsOf(integrations?.children ?? [])).toContain( + `/settings/workspaces/${WORKSPACE_ID}/integrations/github`, + ); + }); + + it("gives every tab and every integration its own mark", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + const tabs = workspace.children ?? []; + const integrations = integrationsTabOf(tabs)?.children ?? []; + + // A row with no icon sits text-first and breaks the column the rest form. + expect(tabs.every((node) => node.icon !== undefined)).toBe(true); + expect(integrations.every((node) => node.icon !== undefined)).toBe(true); + // Marks identify — two sections sharing one would defeat the point. + expect(new Set(tabs.map((node) => node.icon)).size).toBe(tabs.length); + expect(new Set(integrations.map((node) => node.icon)).size).toBe(integrations.length); + }); + + it("percent-encodes an id that would otherwise break the path", () => { + const [workspace] = buildWorkspacesBranch([{ id: "a/b", name: "Slashy" }]); + + expect(workspace.href).toBe("/settings/workspaces/a%2Fb"); + }); +}); + +describe("buildWorkspacesBranch integration visibility", () => { + function integrationSlugsOf(nodes: readonly SettingsMenuNode[]): Array { + return (integrationsTabOf(nodes)?.children ?? []).map((node) => node.integrationSlug); + } + + it("lists every integration when no visible set is given — the default", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + + expect(integrationSlugsOf(workspace.children ?? [])).toEqual([ + "azure-devops", + "github", + "gitlab", + "jira", + "linear", + "sentry", + ]); + }); + + it("drops the integrations missing from the visible set, keeping catalog order", () => { + const [workspace] = buildWorkspacesBranch( + WORKSPACES, + null, + new Set(["azure-devops", "linear"] as const), + ); + + expect(integrationSlugsOf(workspace.children ?? [])).toEqual(["azure-devops", "linear"]); + }); + + it("never consults whether an integration is configured — the badge owns that", () => { + // Nothing about credentials reaches this builder: an integration the user + // has never connected is listed exactly like a connected one, so the + // visible set is the only thing that can remove a row. + const [workspace] = buildWorkspacesBranch(WORKSPACES, null, new Set(["github"] as const)); + + expect(integrationSlugsOf(workspace.children ?? [])).toEqual(["github"]); + }); + + it("leaves the Integrations row navigable when the set hides all of them", () => { + // An empty branch would otherwise render a chevron opening onto nothing. + const [workspace] = buildWorkspacesBranch(WORKSPACES, null, new Set()); + const integrations = integrationsTabOf(workspace.children ?? []); + + expect(integrations?.children).toEqual([]); + expect(integrations?.href).toBe(`/settings/workspaces/${WORKSPACE_ID}/integrations`); + }); +}); + +describe("isUserRecord", () => { + it("marks the nodes the user created: workspaces and profiles", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + const [agent] = buildAgentsBranch(AGENTS); + const [executor] = buildExecutorsBranch(EXECUTORS); + + expect(workspace.isUserRecord).toBe(true); + expect(agent.children?.every((node) => node.isUserRecord)).toBe(true); + expect(executor.children?.every((node) => node.isUserRecord)).toBe(true); + }); + + it("leaves the agent and the executor themselves unmarked", () => { + const [agent] = buildAgentsBranch(AGENTS); + const [executor] = buildExecutorsBranch(EXECUTORS); + + // `Claude` and `Local` ship with kandev and read the same in every install. + // The user authors the profiles under them, not the agent or executor. + expect(agent.isUserRecord).toBeFalsy(); + expect(executor.isUserRecord).toBeFalsy(); + }); + + it("leaves fixed structure unmarked, including at a record's own depth", () => { + const [workspace] = buildWorkspacesBranch(WORKSPACES); + const tabs = workspace.children ?? []; + const integrations = integrationsTabOf(tabs)?.children ?? []; + + // These are the assertions that hold the line: a workspace's tabs are a + // fixed six, and the integrations a fixed seven, in every install — even + // though `GitHub` sits at the same indent as a workspace name. + expect(tabs.every((node) => !node.isUserRecord)).toBe(true); + expect(integrations.length).toBeGreaterThan(0); + expect(integrations.every((node) => !node.isUserRecord)).toBe(true); + }); + + it("does not mark a branch root, which is a menu row", () => { + const root = buildBranchRoot( + { href: WORKSPACES_HREF, labelKey: "common:workspaces" }, + buildWorkspacesBranch(WORKSPACES), + ); + + expect(root.isUserRecord).toBeFalsy(); + }); +}); + +describe("buildAgentsBranch", () => { + it("gives the agent no page of its own, only its profiles", () => { + const [agent] = buildAgentsBranch(AGENTS); + + // `/settings/agents/` redirects back to the index for a saved agent. + expect(agent.href).toBeNull(); + expect(agent.ownsPrefixes).toEqual(["/settings/agents/claude-code/"]); + expect(hrefsOf(agent.children ?? [])).toEqual([ + "/settings/agents/claude-code/profiles/profile-1", + "/settings/agents/claude-code/profiles/profile-2", + ]); + }); + + it("drops an agent with no profiles rather than rendering a dead row", () => { + expect(buildAgentsBranch([{ name: "empty", profiles: [] }])).toEqual([]); + }); +}); + +describe("buildExecutorsBranch", () => { + it("uses the executor-scoped profile route, the one with an executor crumb", () => { + const [executor] = buildExecutorsBranch(EXECUTORS); + + expect(executor.href).toBe("/settings/executor/exec-1"); + expect(hrefsOf(executor.children ?? [])).toEqual([ + "/settings/executor/exec-1/profile/exec-profile-1", + ]); + }); +}); + +describe("findActiveNodePath", () => { + const forest = [ + buildBranchRoot( + { + href: WORKSPACES_HREF, + labelKey: "common:workspaces", + activePrefixes: ["/settings/workspaces/", "/settings/workspace/"], + }, + buildWorkspacesBranch(WORKSPACES), + ), + buildBranchRoot( + { href: "/settings/agents", labelKey: "common:agents" }, + buildAgentsBranch(AGENTS), + ), + buildBranchRoot( + { + href: "/settings/executors", + labelKey: "common:executors", + activePrefixes: ["/settings/executors/", "/settings/executor/"], + }, + buildExecutorsBranch(EXECUTORS), + ), + ]; + + it("descends to the deepest node owning the route", () => { + expect( + findActiveNodePath(forest, `/settings/workspaces/${WORKSPACE_ID}/integrations/github`), + ).toEqual([ + settingsMenuRowKey(WORKSPACES_HREF), + `workspace:${WORKSPACE_ID}`, + `workspace:${WORKSPACE_ID}:integrations`, + `workspace:${WORKSPACE_ID}:integrations:github`, + ]); + }); + + it("stops at the workspace when the route is its own page", () => { + expect(findActiveNodePath(forest, `/settings/workspaces/${WORKSPACE_ID}`)).toEqual([ + settingsMenuRowKey(WORKSPACES_HREF), + `workspace:${WORKSPACE_ID}`, + ]); + }); + + it("reaches a profile through an agent that has no page", () => { + expect(findActiveNodePath(forest, "/settings/agents/claude-code/profiles/profile-2")).toEqual([ + settingsMenuRowKey("/settings/agents"), + "agent:claude-code", + "agent:claude-code:profile:profile-2", + ]); + }); + + it("stops at the row when the route has no node — the row stays active", () => { + // The install catalogue is not an agent, and the flat profile spelling has + // no executor to nest under. Both are the row's own pages. + expect(findActiveNodePath(forest, "/settings/agents/browse")).toEqual([ + settingsMenuRowKey("/settings/agents"), + ]); + expect(findActiveNodePath(forest, "/settings/executors/exec-profile-1")).toEqual([ + settingsMenuRowKey("/settings/executors"), + ]); + }); + + it("claims the row's alternate route spellings through activePrefixes", () => { + // `/settings/executor/` is not under `/settings/executors`, but the + // Executors row owns it — and so must its branch. + expect(findActiveNodePath(forest, "/settings/executor/exec-1")).toEqual([ + settingsMenuRowKey("/settings/executors"), + "executor:exec-1", + ]); + }); + + it("returns nothing for a route outside every branch", () => { + expect(findActiveNodePath(forest, "/settings/preferences/appearance")).toEqual([]); + }); + + it("does not let one workspace claim another's routes by prefix", () => { + const twoWorkspaces = buildWorkspacesBranch([ + { id: "ws-1", name: "One" }, + { id: "ws-10", name: "Ten" }, + ]); + + expect(findActiveNodePath(twoWorkspaces, "/settings/workspaces/ws-10/secrets")).toEqual([ + "workspace:ws-10", + "workspace:ws-10:secrets", + ]); + }); +}); + +describe("key path helpers", () => { + const workspaces = buildWorkspacesBranch(WORKSPACES); + + it("names every ancestor needed to reveal a node", () => { + expect(findNodeKeyPath(workspaces, `workspace:${WORKSPACE_ID}:integrations:jira`)).toEqual([ + `workspace:${WORKSPACE_ID}`, + `workspace:${WORKSPACE_ID}:integrations`, + `workspace:${WORKSPACE_ID}:integrations:jira`, + ]); + }); + + it("names the node and everything under it when collapsing", () => { + const keys = findSubtreeKeys(workspaces, `workspace:${WORKSPACE_ID}:integrations`); + + expect(keys[0]).toBe(`workspace:${WORKSPACE_ID}:integrations`); + expect(keys).toContain(`workspace:${WORKSPACE_ID}:integrations:github`); + expect(keys).not.toContain(`workspace:${WORKSPACE_ID}:secrets`); + }); + + it("returns nothing for a key no node carries — a stale stored branch", () => { + expect(findNodeKeyPath(workspaces, "workspace:deleted")).toEqual([]); + expect(findSubtreeKeys(workspaces, "workspace:deleted")).toEqual([]); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.ts new file mode 100644 index 0000000000..af36d0669c --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-branches.ts @@ -0,0 +1,356 @@ +import type { ComponentType } from "react"; + +import { getExecutorIcon } from "@/lib/executor-icons"; +import { AGENTS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents"; +import { EXECUTORS_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/executors"; +import { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations"; +import { WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/workspaces"; +import { INTEGRATION_ICONS } from "@/lib/settings/integration-icons"; +import { + WORKSPACE_SETTINGS_TABS, + workspaceSettingsHref, +} from "@/lib/settings/workspace-settings-tabs"; + +/** + * What the settings menu grows *underneath* a row when a tree mode is on. + * + * `settings-menu-sections.ts` stays the information architecture — which pages + * exist and what they are called — and is what `flat` renders on its own. This + * module answers a different question: for the three rows whose pages own + * records (Workspaces, Agents, Executors), which records exist right now and + * where does each one's sub-page live. + * + * Every href here is built from the same catalog constants and route tables the + * pages themselves use (`workspace-settings-tabs.ts`, `WORKSPACE_INTEGRATIONS`), + * never hand-spelled. A branch that invented its own paths would be a third + * surface for the menu and the breadcrumb to disagree with, which is the exact + * failure `settings-menu-sections.ts` exists to prevent. + * + * Pure data, no React: the builders take plain records so they can be tested + * without a store, and labels stay unresolved (`{ key }` for catalog copy, + * `{ text }` for a record name or brand) so `t()` runs at render. + */ + +export type IntegrationSlug = (typeof WORKSPACE_INTEGRATIONS)[number][0]; + +export type SettingsMenuNodeLabel = + /** A catalog key, translated at render. */ + | { key: string } + /** A record name or brand — never translated. */ + | { text: string }; + +export type SettingsMenuNode = { + /** + * Stable identity for expansion state. Derived from record ids rather than + * hrefs so a renamed record keeps its open branch, and so a stored key for a + * deleted record is inert rather than matching something else. + */ + key: string; + /** The page this node opens, or null when it only discloses its children. */ + href: string | null; + label: SettingsMenuNodeLabel; + icon?: ComponentType<{ className?: string }>; + /** + * Render this agent's logo as the leading visual instead of `icon`. Carried + * as a name rather than an element to keep this module free of JSX. + */ + agentName?: string; + /** + * True when the node is something the user created: a workspace, or a profile + * of an agent or an executor. Agents and executors themselves are **not** + * records — `Claude` and `Local` ship with kandev and are the same in every + * install; what the user authors is the profiles underneath them. + * + * Not a synonym for depth either: at depth 3 a workspace's name sits beside + * `GitHub`, which comes from a fixed seven-item catalog. Only a per-node flag + * separates the two, and the menu spends it on a lighter label weight. + */ + isUserRecord?: boolean; + /** + * A qualifier the row's name cannot carry on its own: the workspace commands + * act on, or a profile every picker will refuse to offer. One field rather + * than a flag per badge — a row shows at most one, and the renderer should + * not have to decide which flag outranks which. + */ + badge?: "active" | "disabled" | "not-installed"; + /** + * An integration row, by catalog slug. Whether it is connected is not known + * here — it takes a network probe — so the node names the integration and the + * renderer resolves the badge. + */ + integrationSlug?: IntegrationSlug; + /** + * Set on the Integrations row: its children need one shared probe of this + * workspace, run only while the branch is open. + */ + integrationsWorkspaceId?: string; + /** + * Routes this node owns beyond `href` and its `href/` descendants. Needed by + * nodes with no page of their own (an agent, whose profile pages live under a + * path the agent itself does not resolve to) and by branch roots, which + * inherit their row's `activePrefixes` so an alternate spelling of a record + * route still lights up the branch. + */ + ownsPrefixes?: readonly string[]; + children?: SettingsMenuNode[]; +}; + +/** The store shapes the builders need, narrowed to what they actually read. */ +export type BranchWorkspace = { id: string; name: string }; +export type BranchAgent = { + name: string; + profiles: ReadonlyArray<{ + id: string; + name: string; + agentDisplayName?: string; + /** Absent on older payloads, hence `!== false` rather than `=== true`. */ + enabled?: boolean; + }>; +}; +export type BranchExecutor = { + id: string; + name: string; + type: string; + profiles?: ReadonlyArray<{ id: string; name: string }>; +}; + +/** The menu rows that grow a branch. */ +export const BRANCHED_SETTINGS_HREFS = [ + WORKSPACES_SETTINGS_HREF, + AGENTS_SETTINGS_HREF, + EXECUTORS_SETTINGS_HREF, +] as const; + +/** Expansion key for a branched menu row. Namespaced so it cannot collide + * with a record key, which is what makes one flat forest of keys safe. */ +export function settingsMenuRowKey(href: string): string { + return `row:${href}`; +} + +/** + * A branched menu row as a node, so the row and its records live in one tree. + * + * Without this the row would need its own parallel notion of "open" and "active" + * — and the key of a row could not be resolved by the same walk that resolves + * everything below it. Inheriting `activePrefixes` matters for the alternate + * route spellings a row already claims: `/settings/executor/` is not under + * `/settings/executors`, but it is still the Executors row's page. + */ +export function buildBranchRoot( + item: { href: string; labelKey: string; activePrefixes?: readonly string[] }, + children: SettingsMenuNode[], +): SettingsMenuNode { + return { + key: settingsMenuRowKey(item.href), + href: item.href, + label: { key: item.labelKey }, + ownsPrefixes: item.activePrefixes ?? [], + children, + }; +} + +function integrationNodes( + workspaceId: string, + integrationsHref: string, + visibleSlugs?: ReadonlySet, +): SettingsMenuNode[] { + // Configured status gates the badge only, never the row itself — the branch + // always lists every integration regardless of credentials, so the filter + // here hides only disabled ones (intentionally looser than + // useNavAvailability's `configured && (!hideDisabled || enabled)`). + return WORKSPACE_INTEGRATIONS.filter(([slug]) => !visibleSlugs || visibleSlugs.has(slug)).map( + ([slug, label]) => ({ + key: `workspace:${workspaceId}:integrations:${slug}`, + href: `${integrationsHref}/${slug}`, + label: { text: label }, + icon: INTEGRATION_ICONS[slug], + integrationSlug: slug, + }), + ); +} + +/** + * One node per workspace, each holding its settings tabs — and, under + * Integrations, one node per provider. That is the deepest branch the menu has: + * Workspaces › a workspace › Integrations › GitHub, which is exactly the + * breadcrumb those pages already render. + * + * Overview is not listed as a tab: it *is* the workspace node's own page, so a + * child for it would be a row that navigates to its own parent. + */ +export function buildWorkspacesBranch( + workspaces: ReadonlyArray, + activeWorkspaceId?: string | null, + /** + * Which integrations the Integrations branch may list. Omitted — the default + * — lists all of them; a set is passed only while "Hide disabled integrations + * from left panel navigation" is on. + */ + visibleIntegrationSlugs?: ReadonlySet, +): SettingsMenuNode[] { + return workspaces.map((workspace) => { + const integrationsHref = workspaceSettingsHref(workspace.id, "integrations"); + return { + key: `workspace:${workspace.id}`, + href: workspaceSettingsHref(workspace.id, "overview"), + label: { text: workspace.name }, + // No glyph: a folder on every workspace was one repeated mark down a + // column that already says "workspace" by where it sits. The row still + // keeps the glyph's width, empty, so its label lines up with its peers. + isUserRecord: true, + // Same badge the workspace list and the workspace switcher show, so the + // menu does not leave you guessing which one commands land in. + ...(workspace.id === activeWorkspaceId ? { badge: "active" as const } : {}), + children: WORKSPACE_SETTINGS_TABS.filter(({ tab }) => tab !== "overview").map( + ({ tab, labelKey, icon }) => ({ + key: `workspace:${workspace.id}:${tab}`, + href: workspaceSettingsHref(workspace.id, tab), + label: { key: labelKey }, + icon, + ...(tab === "integrations" + ? { + children: integrationNodes(workspace.id, integrationsHref, visibleIntegrationSlugs), + integrationsWorkspaceId: workspace.id, + } + : {}), + }), + ), + }; + }); +} + +/** + * One node per installed agent, holding that agent's profiles. + * + * The agent node has no `href`: `/settings/agents/` redirects back to the + * Agents index for an already-saved agent, and a menu row that bounces you + * somewhere else is worse than one that only expands. `ownsPrefixes` is what + * still lets a profile route light up its agent's branch. + * + * An agent with no profiles is dropped rather than rendered as a node that can + * neither navigate nor expand. + */ +export function buildAgentsBranch( + agents: ReadonlyArray, + /** + * Names the CLI scan currently finds. Omitted while the scan has not run: + * "not detected yet" is not "not installed", and badging every agent for the + * moment before discovery lands would be worse than saying nothing. + */ + detectedNames?: ReadonlySet, +): SettingsMenuNode[] { + return agents + .filter((agent) => agent.profiles.length > 0) + .map((agent) => { + const encodedAgent = encodeURIComponent(agent.name); + const agentHref = `${AGENTS_SETTINGS_HREF}/${encodedAgent}`; + return { + key: `agent:${agent.name}`, + href: null, + label: { text: agent.profiles[0].agentDisplayName || agent.name }, + agentName: agent.name, + ownsPrefixes: [`${agentHref}/`], + ...(detectedNames && !detectedNames.has(agent.name) + ? { badge: "not-installed" as const } + : {}), + // The agent is kandev's; only the profiles under it are the user's. + children: agent.profiles.map((profile) => ({ + key: `agent:${agent.name}:profile:${profile.id}`, + href: `${agentHref}/profiles/${profile.id}`, + label: { text: profile.name }, + isUserRecord: true, + ...(profile.enabled === false ? { badge: "disabled" as const } : {}), + })), + }; + }); +} + +/** + * One node per executor, holding its profiles. + * + * Uses the executor-scoped spellings (`/settings/executor/` and + * `/settings/executor//profile/`) rather than the flat + * `/settings/executors/` the Executors page links to. Both are live + * routes for the same profile, but only the scoped pair has an executor + * breadcrumb — so it is the one whose crumb chain matches the branch the user + * clicked through. + */ +export function buildExecutorsBranch(executors: ReadonlyArray): SettingsMenuNode[] { + return executors.map((executor) => { + const executorHref = `/settings/executor/${encodeURIComponent(executor.id)}`; + return { + key: `executor:${executor.id}`, + href: executorHref, + label: { text: executor.name }, + icon: getExecutorIcon(executor.type), + // Same split as agents: the executor ships with kandev, the profiles do not. + children: (executor.profiles ?? []).map((profile) => ({ + key: `executor:${executor.id}:profile:${profile.id}`, + href: `${executorHref}/profile/${profile.id}`, + label: { text: profile.name }, + isUserRecord: true, + })), + }; + }); +} + +/** True when `pathname` is this node's page or lives underneath it. */ +function nodeOwns(node: SettingsMenuNode, pathname: string): boolean { + if (node.href && (pathname === node.href || pathname.startsWith(`${node.href}/`))) return true; + return (node.ownsPrefixes ?? []).some((prefix) => pathname.startsWith(prefix)); +} + +/** + * The chain of node keys from the outermost branch down to the deepest node + * that owns `pathname`, or an empty array when the branch owns none of it. + * + * Deepest-wins is the whole point: on + * `/settings/workspaces/ws-1/integrations/github` the workspace node, the + * Integrations node and the GitHub node all *contain* the route, but only + * GitHub is the page you are on. The ancestors are the open path; the last key + * is the one row allowed to mark itself active. + */ +export function findActiveNodePath( + nodes: ReadonlyArray, + pathname: string, +): string[] { + for (const node of nodes) { + if (!nodeOwns(node, pathname)) continue; + const deeper = findActiveNodePath(node.children ?? [], pathname); + return [node.key, ...deeper]; + } + return []; +} + +/** + * The chain of keys from the outermost branch down to `key`, inclusive, or an + * empty array when no node carries it. Expanding a node has to expand its + * ancestors too — otherwise the row it reveals has nowhere to appear. + */ +export function findNodeKeyPath(nodes: ReadonlyArray, key: string): string[] { + for (const node of nodes) { + if (node.key === key) return [node.key]; + const deeper = findNodeKeyPath(node.children ?? [], key); + if (deeper.length > 0) return [node.key, ...deeper]; + } + return []; +} + +function subtreeKeys(node: SettingsMenuNode): string[] { + return [node.key, ...(node.children ?? []).flatMap(subtreeKeys)]; +} + +/** + * `key` plus every key beneath it, or an empty array when no node carries it. + * Collapsing a branch drops its descendants from the persistent set as well — + * they are hidden either way, and leaving them stored reopens a sub-branch the + * user last saw closed. + */ +export function findSubtreeKeys(nodes: ReadonlyArray, key: string): string[] { + for (const node of nodes) { + if (node.key === key) return subtreeKeys(node); + const deeper = findSubtreeKeys(node.children ?? [], key); + if (deeper.length > 0) return deeper; + } + return []; +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-node.tsx b/apps/web/components/app-sidebar/sections/settings/settings-menu-node.tsx new file mode 100644 index 0000000000..35de94f264 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-node.tsx @@ -0,0 +1,136 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { AgentLogo } from "@/components/agent-logo"; +import { + ActiveWorkspaceBadge, + DisabledBadge, + NotInstalledBadge, +} from "@/components/settings/record-badges"; +import type { SettingsMenuNode } from "./settings-menu-branches"; +import { IntegrationEnabledBadgeFor, IntegrationsEnabledProvider } from "./integration-enabled"; +import { RecordGlyph, SettingsBranch, SettingsLeaf } from "./settings-nav-primitives"; +import type { SettingsMenuExpansion } from "./use-settings-menu-expansion"; + +type SettingsMenuNodeRowProps = { + node: SettingsMenuNode; + /** Nesting level. Menu rows are 0, so a branch's own children start at 1. */ + depth: number; + /** + * The single node allowed to mark itself active — the deepest one owning the + * current route. Ancestors merely hold it open. + */ + activeKey: string | null; + expansion: SettingsMenuExpansion; +}; + +/** + * The row's leading visual: an agent's logo, the record dot, or nothing — in + * which case `NavRowLabel` keeps the glyph box open. A node that already + * declares an `icon` is left to it. + */ +function resolveLeadingIcon(node: SettingsMenuNode, isRecord: boolean) { + if (node.agentName) { + return ; + } + if (isRecord && !node.icon) return ; + return undefined; +} + +/** One badge per node at most; `undefined` is much the commonest case. */ +function renderBadge(badge: SettingsMenuNode["badge"]) { + if (badge === "disabled") return ; + if (badge === "active") return ; + if (badge === "not-installed") return ; + return undefined; +} + +/** + * Wraps the Integrations branch's rows in one shared probe. Sits here, inside + * the collapsible body, so a closed branch never fetches. + */ +function withIntegrationProbe(node: SettingsMenuNode, rows: ReactNode) { + if (!node.integrationsWorkspaceId) return rows; + return ( + + {rows} + + ); +} + +/** + * One row of a settings menu branch, recursing into its children. + * + * A node with children renders as a disclosure; a node without renders as a + * plain link. A node with neither children nor an `href` renders nothing at + * all — it could not be navigated to or opened, so it would be a dead row. + */ +export function SettingsMenuNodeRow({ + node, + depth, + activeKey, + expansion, +}: SettingsMenuNodeRowProps) { + const { t } = useTranslation(); + const label = "key" in node.label ? t(node.label.key) : node.label.text; + const isRecord = node.isUserRecord === true; + // Records carry no glyph of their own, so the dot stands in as one. Supplied + // here rather than by the branch builders: it belongs to being a record, and + // stating it once beats repeating it in all three of them. + const leadingIcon = resolveLeadingIcon(node, isRecord); + const isActive = node.key === activeKey; + const children = node.children ?? []; + // An integration's badge needs a probe, so it resolves in the row rather than + // in the node; everything else is decided when the branch is built. + const trailing = node.integrationSlug ? ( + + ) : ( + renderBadge(node.badge) + ); + + if (children.length === 0) { + if (!node.href) return null; + return ( + + ); + } + + return ( + expansion.toggle(node.key)} + depth={depth} + isRecord={isRecord} + > + {withIntegrationProbe( + node, + children.map((child) => ( + + )), + )} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts new file mode 100644 index 0000000000..3e77be38db --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts @@ -0,0 +1,241 @@ +import type { ComponentType } from "react"; +import { + IconActivity, + IconArchive, + IconBell, + IconCommand, + IconCpu, + IconDatabase, + IconFlask, + IconFolder, + IconInfoCircle, + IconKey, + IconLayoutDashboard, + IconMessageCircle, + IconMicrophone, + IconPalette, + IconPlugConnected, + IconPuzzle, + IconRefresh, + IconRobot, + IconShieldLock, + IconTerminal2, + IconUsers, + IconWand, +} from "@tabler/icons-react"; + +import { + ACCOUNT_SECURITY_SETTINGS_HREF, + AGENTS_SETTINGS_HREF, + APPEARANCE_SETTINGS_HREF, + EXECUTORS_SETTINGS_HREF, + EXTERNAL_MCP_SETTINGS_HREF, + KEYBOARD_SHORTCUTS_SETTINGS_HREF, + LAYOUTS_SETTINGS_HREF, + NOTIFICATIONS_SETTINGS_HREF, + PLUGINS_SETTINGS_HREF, + PROMPTS_SETTINGS_HREF, + SECRETS_SETTINGS_HREF, + SYSTEM_ABOUT_SETTINGS_HREF, + SYSTEM_DATA_STORAGE_SETTINGS_HREF, + SYSTEM_STATUS_SETTINGS_HREF, + TASK_BEHAVIOR_SETTINGS_HREF, + TERMINAL_EDITORS_SETTINGS_HREF, + UTILITY_AGENTS_SETTINGS_HREF, + VOICE_MODE_SETTINGS_HREF, + WORKSPACES_SETTINGS_HREF, +} from "@/lib/settings-discovery/catalog"; + +/** + * The settings menu as data. + * + * This is the settings information architecture: which pages exist, what they + * are called, and which detail routes belong to which page. Two surfaces read + * it and must never disagree — the sidebar tree (`settings-tree.tsx`, which + * highlights the owning row) and the settings breadcrumb + * (`settings-breadcrumbs.ts`, which names the owning row as a parent crumb). + * Keeping it in a data-only module is what lets the second one import it + * without pulling in the sidebar's hooks and plugin slots. + */ + +type MenuCountKey = "workspaces" | "agents" | "executors" | "layouts" | "secrets" | "plugins"; + +export type SettingsMenuItem = { + href: string; + labelKey: string; + icon: ComponentType<{ className?: string }>; + /** + * Route prefixes whose pages belong to this row (detail routes, sub-routes). + * The row is active for `href` and anything under a prefix — user-created + * items never get menu rows of their own, so their detail routes highlight + * the page that owns them. + */ + activePrefixes?: string[]; + requires?: "account" | "users"; + /** Rows whose page owns a list show its size as a trailing badge. */ + countKey?: MenuCountKey; +}; + +export type SettingsMenuSection = { + id: string; + labelKey: string; + items: SettingsMenuItem[]; +}; + +export type SettingsMenuCountKey = MenuCountKey; + +/** + * The settings menu: static section headers, one row per page, exactly two + * levels. Menu length is constant regardless of how many profiles, executors, + * workspaces or plugins exist — those live as lists inside their pages. + */ +export const SETTINGS_MENU_SECTIONS: SettingsMenuSection[] = [ + { + id: "preferences", + labelKey: "settings:preferences", + items: [ + { href: APPEARANCE_SETTINGS_HREF, labelKey: "settings:appearance", icon: IconPalette }, + { + href: LAYOUTS_SETTINGS_HREF, + labelKey: "settings:layouts", + icon: IconLayoutDashboard, + countKey: "layouts", + }, + { + href: TERMINAL_EDITORS_SETTINGS_HREF, + labelKey: "settings:terminalAndEditors", + icon: IconTerminal2, + }, + { href: NOTIFICATIONS_SETTINGS_HREF, labelKey: "settings:notifications", icon: IconBell }, + { + href: KEYBOARD_SHORTCUTS_SETTINGS_HREF, + labelKey: "settings:keyboardShortcuts", + icon: IconCommand, + }, + { href: TASK_BEHAVIOR_SETTINGS_HREF, labelKey: "settings:taskBehavior", icon: IconArchive }, + ], + }, + { + id: "workspaces", + labelKey: "settings:workspacesAndAccess", + items: [ + { + href: WORKSPACES_SETTINGS_HREF, + labelKey: "common:workspaces", + icon: IconFolder, + activePrefixes: ["/settings/workspaces/", "/settings/workspace/"], + countKey: "workspaces", + }, + { + href: SECRETS_SETTINGS_HREF, + labelKey: "settings:globalSecrets", + icon: IconKey, + countKey: "secrets", + }, + { href: EXTERNAL_MCP_SETTINGS_HREF, labelKey: "common:externalMcp", icon: IconPlugConnected }, + { + href: PLUGINS_SETTINGS_HREF, + labelKey: "common:plugins", + icon: IconPuzzle, + activePrefixes: ["/settings/plugins/"], + countKey: "plugins", + }, + ], + }, + { + id: "agents", + labelKey: "common:agents", + items: [ + { + href: AGENTS_SETTINGS_HREF, + labelKey: "common:agents", + icon: IconRobot, + activePrefixes: ["/settings/agents/", "/settings/agent/"], + countKey: "agents", + }, + { + href: EXECUTORS_SETTINGS_HREF, + labelKey: "common:executors", + icon: IconCpu, + activePrefixes: ["/settings/executors/", "/settings/executor/"], + countKey: "executors", + }, + { href: PROMPTS_SETTINGS_HREF, labelKey: "common:prompts", icon: IconMessageCircle }, + { href: UTILITY_AGENTS_SETTINGS_HREF, labelKey: "settings:utilityAgents", icon: IconWand }, + { href: VOICE_MODE_SETTINGS_HREF, labelKey: "settings:voiceMode", icon: IconMicrophone }, + ], + }, + // Only rendered when the auth feature exposes at least one of its rows. + { + id: "access", + labelKey: "settings:accessControl", + items: [ + { + href: "/settings/system/users", + labelKey: "system:navUsers", + icon: IconUsers, + requires: "users", + }, + { + href: ACCOUNT_SECURITY_SETTINGS_HREF, + labelKey: "sidebar:profileAndPassword", + icon: IconShieldLock, + requires: "account", + }, + { + href: "/settings/account/tokens", + labelKey: "sidebar:apiTokens", + icon: IconKey, + requires: "account", + }, + ], + }, + { + id: "system", + labelKey: "common:system", + items: [ + { href: SYSTEM_STATUS_SETTINGS_HREF, labelKey: "common:status", icon: IconActivity }, + { + href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + labelKey: "system:navDataStorage", + icon: IconDatabase, + }, + { + href: "/settings/system/feature-toggles", + labelKey: "system:navFeatureToggles", + icon: IconFlask, + }, + { href: "/settings/system/updates", labelKey: "system:navUpdates", icon: IconRefresh }, + { href: SYSTEM_ABOUT_SETTINGS_HREF, labelKey: "system:navAbout", icon: IconInfoCircle }, + ], + }, +]; + +/** True when `pathname` is the item's page or one of its owned sub-routes. */ +export function settingsMenuItemIsActive( + item: Pick, + pathname: string, +): boolean { + if (pathname === item.href) return true; + return (item.activePrefixes ?? []).some((prefix) => pathname.startsWith(prefix)); +} + +/** + * The menu row whose page owns `pathname` as a *sub-route*, or null. + * + * Deliberately excludes the row's own page: this answers "which settings page + * should the breadcrumb name as this page's parent", and a page is not its own + * parent. Every `activePrefixes` entry ends in `/`, so a row's own href can + * never match one — no extra guard needed. + * + * `requires` gating is not consulted. It hides rows the current user has no + * page for, and every gated row is a leaf, so no crumb is ever built from one. + */ +export function settingsMenuOwnerOf(pathname: string): SettingsMenuItem | null { + for (const section of SETTINGS_MENU_SECTIONS) { + for (const item of section.items) { + if ((item.activePrefixes ?? []).some((prefix) => pathname.startsWith(prefix))) return item; + } + } + return null; +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts index 0c1a31b807..fa20c13840 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts @@ -17,10 +17,16 @@ import sidebarCatalog from "@/src/locales/en/sidebar.json"; * diff, kept. */ const NAV_LABELS: Array<[key: string, english: string]> = [ + ["settings:preferences", "Preferences"], + ["settings:workspacesAndAccess", "Workspaces & Access"], + ["settings:accessControl", "Access Control"], + ["settings:terminalAndEditors", "Terminal & Editors"], + ["settings:taskBehavior", "Task Behavior"], + ["system:navDataStorage", "Data & Logs"], ["common:prompts", "Prompts"], ["settings:voiceMode", "Voice Mode"], ["settings:utilityAgents", "Utility Agents"], - ["settings:secrets", "Secrets"], + ["settings:globalSecrets", "Global Secrets"], ["common:externalMcp", "External MCP"], ["common:plugins", "Plugins"], ["common:workspaces", "Workspaces"], @@ -31,14 +37,16 @@ const NAV_LABELS: Array<[key: string, english: string]> = [ ["common:executors", "Executors"], ["common:agents", "Agents"], ["common:settings", "Settings"], - ["sidebar:account", "Account"], + // No `sidebar:account`: the restructure folded those rows into the + // "Access Control" section, pinned above as `settings:accessControl`. The key + // and its label are gone, so pinning them here pinned nothing. ["sidebar:profileAndPassword", "Profile & Password"], ["sidebar:apiTokens", "API Tokens"], ]; /** * Labels that also exist as a route page title, keyed by - * `SEGMENT_LABEL_KEYS` in components/settings/settings-layout-client.tsx. The + * `SEGMENT_LABEL_KEYS` in components/settings/settings-breadcrumb-labels.ts. The * nav and the title are the same words for the same destination and the user * sees both at once (nav on the left, title above), so they must resolve * through ONE key. Two keys with identical English today is a translator's @@ -51,7 +59,7 @@ const OWNED_BY_ANOTHER_NAMESPACE = [ "Prompts", "Voice Mode", "Utility Agents", - "Secrets", + "Global Secrets", "External MCP", "Plugins", "Executors", 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 f661adab42..99a9253156 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 @@ -2,14 +2,16 @@ import Link from "@/components/routing/app-link"; import { IconChevronRight } from "@tabler/icons-react"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; -import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; +import { RecordDot } from "@/components/settings/record-dot"; import { cn } from "@/lib/utils"; import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants"; -const ACTIVE_CLASS = SIDEBAR_ITEM_ACTIVE; +// Same active treatment as the task list rows (task-item.tsx): a primary-tinted +// fill, layered on the sidebar's shared left accent bar. +const ACTIVE_CLASS = cn(SIDEBAR_ITEM_ACTIVE, "bg-primary/10 hover:bg-primary/15"); const INACTIVE_CLASS = SIDEBAR_ITEM_INACTIVE; type SettingsLeafProps = { @@ -19,13 +21,17 @@ type SettingsLeafProps = { /** Pre-rendered leading visual (e.g. AgentLogo). Takes precedence over `icon`. */ leadingIcon?: ReactNode; labelSuffix?: ReactNode; + /** Right-aligned qualifier, e.g. a badge. Sits in the caret's column. */ + trailing?: ReactNode; isActive: boolean; /** Nesting level — used to add left padding. */ depth?: number; + /** Row built from the user's own data — rendered with a lighter label. */ + isRecord?: boolean; }; 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 BRANCH_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"; @@ -35,121 +41,204 @@ function clampDepth(depth: number, max: number): number { return depth; } +const GLYPH_CLASS = "h-3.5 w-3.5 shrink-0"; +// A branch ends its row with `pr-1` + a 20px caret + the 4px row gap, putting +// its trailing badge 28px in from the row's edge. A caretless row reproduces +// that as padding rather than as a spacer element: a spacer would sit after the +// row's `gap-2`, pushing the badge 8px further left than the branch's. +const CARET_GUTTER_PADDING = "pr-7"; + +/** + * The mark on a row built from the user's own data. + * + * The dot is the shared one the Agents page puts in front of a profile, so a + * profile is marked the same way in the menu as on its own page. It is centred + * in a full glyph box rather than rendered loose: at 6px it would otherwise sit + * narrower than every tabler glyph around it and pull its label out of the + * column. + */ +export function RecordGlyph() { + return ( + + ); +} + +/** + * A row's icon, title and trailing detail. + * + * The count badge sits immediately after the title rather than flush against + * the row's right edge: right-aligned, it read as a column of unrelated numbers + * running down the menu, and on a branch row it collided with the chevron. The + * `flex-1` lives on this wrapper, not the title, so the title still truncates + * and a branch's chevron still gets pushed to the edge. + * + * `trailing` is the other end of the row: a qualifier badge, right-aligned + * rather than trailing the title the way a count does. A leaf has no caret, so + * it reserves the caret's width instead — otherwise a badge on a profile would + * sit 18px further right than one on the workspace above it, and the badges + * would stagger down the menu instead of forming a column. + * + * Every row opens a glyph box, and a row with nothing to put in it keeps the box + * empty. That is a fallback rather than a layout the menu relies on — records + * carry `RecordDot` — but it costs one element and keeps a future glyphless row + * from pulling its label 22px (glyph plus gap) left of the column its + * neighbours sit in. + */ +function NavRowLabel({ + label, + labelSuffix, + trailing, + icon: Icon, + leadingIcon, +}: { + label: string; + labelSuffix?: ReactNode; + trailing?: ReactNode; + icon?: ComponentType<{ className?: string }>; + leadingIcon?: ReactNode; +}) { + const visual = leadingIcon ?? (Icon && ); + return ( + <> + {visual ??