diff --git a/apps/web/app/office/components/office-topbar.tsx b/apps/web/app/office/components/office-topbar.tsx index 480c56a97d..8ee3e8cf21 100644 --- a/apps/web/app/office/components/office-topbar.tsx +++ b/apps/web/app/office/components/office-topbar.tsx @@ -14,13 +14,13 @@ const PAGE_TITLES: Record = { "/office/workspace/costs": "Costs", "/office/workspace/activity": "Activity", "/office/workspace/routing": "Provider Routing", - "/office/workspace/settings": "Settings", + "/office/workspace/settings": "Preferences", }; function resolveTitle(pathname: string): string | null { const exact = PAGE_TITLES[pathname]; if (exact) return exact; - if (pathname.startsWith("/office/workspace/settings")) return "Settings"; + if (pathname.startsWith("/office/workspace/settings")) return "Preferences"; return null; } @@ -51,11 +51,7 @@ export function OfficeTopbar() { {detail ? (
) : ( - title && ( -

- {title} -

- ) + title &&

{title}

)}
); diff --git a/apps/web/app/office/workspace/org/org-chart-canvas.tsx b/apps/web/app/office/workspace/org/org-chart-canvas.tsx index ec7bee2a56..8a3ba51063 100644 --- a/apps/web/app/office/workspace/org/org-chart-canvas.tsx +++ b/apps/web/app/office/workspace/org/org-chart-canvas.tsx @@ -89,7 +89,7 @@ export function OrgChartCanvas({ agents }: OrgChartCanvasProps) { if (agents.length === 0) return ; return ( -
+
({ })); const state = { - workspaces: { activeId: "ws-1" as string | null }, + workspaces: { + activeId: "kanban-1" as string | null, + items: [ + { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, + { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, + ], + }, appSidebar: { settingsMode: false }, toggleAppSidebarSettingsMode: mocks.toggleSettingsMode, }; @@ -65,7 +71,9 @@ function renderFooter() { describe("AppSidebarFooter", () => { beforeEach(() => { officeEnabled = false; + state.workspaces.activeId = "kanban-1"; state.appSidebar.settingsMode = false; + window.localStorage.clear(); mocks.routerPush.mockClear(); mocks.toggleSettingsMode.mockClear(); }); @@ -88,7 +96,7 @@ describe("AppSidebarFooter", () => { expect(screen.queryByRole("link", { name: "Office" })).toBeNull(); }); - it("navigates from the Stats and Office footer buttons", () => { + it("navigates from the Stats and Office footer buttons when kanban is active", () => { officeEnabled = true; renderFooter(); @@ -97,6 +105,20 @@ describe("AppSidebarFooter", () => { fireEvent.click(screen.getByRole("button", { name: "Office" })); expect(mocks.routerPush).toHaveBeenNthCalledWith(1, "/stats"); - expect(mocks.routerPush).toHaveBeenNthCalledWith(2, "/office"); + expect(mocks.routerPush).toHaveBeenNthCalledWith(2, "/office?workspaceId=office-1"); + expect(window.localStorage.getItem("kandev.lastKanbanWorkspaceId")).toBe("kanban-1"); + }); + + it("shows a Kanban button when an office workspace is active", () => { + officeEnabled = true; + state.workspaces.activeId = "office-1"; + window.localStorage.setItem("kandev.lastKanbanWorkspaceId", "kanban-1"); + + renderFooter(); + + expect(screen.queryByRole("button", { name: "Office" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Kanban" })); + + expect(mocks.routerPush).toHaveBeenCalledWith("/?workspaceId=kanban-1"); }); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 873daf7678..f3af07f3a3 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { IconBuildings, IconChartBar, + IconLayoutKanban, IconSettings, IconSparkles, IconStethoscope, @@ -20,6 +21,13 @@ import { useReleaseNotes } from "@/hooks/use-release-notes"; import { ThemeToggle } from "@/components/theme-toggle"; import { linkToTask } from "@/lib/links"; import { cn } from "@/lib/utils"; +import { + isOfficeWorkspace, + rememberLastKanbanWorkspace, + resolveFirstOfficeWorkspace, + resolveLastKanbanWorkspace, + workspaceHomeHref, +} from "./app-sidebar-workspace-navigation"; type AppSidebarFooterProps = { collapsed: boolean; @@ -88,7 +96,13 @@ function FooterIconButton({ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) { const router = useRouter(); - const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workspaces = useAppStore((s) => s.workspaces); + const workspaceId = workspaces.activeId; + const activeWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId); + const activeIsOffice = isOfficeWorkspace(activeWorkspace); + const targetWorkspace = activeIsOffice + ? resolveLastKanbanWorkspace(workspaces.items) + : resolveFirstOfficeWorkspace(workspaces.items); const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); const officeEnabled = useFeature("office"); @@ -136,11 +150,14 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) { )} {officeEnabled && ( router.push("/office")} - testId="sidebar-office-button" + onClick={() => { + if (!activeIsOffice) rememberLastKanbanWorkspace(activeWorkspace); + router.push(workspaceHomeHref(targetWorkspace ?? undefined)); + }} + testId={activeIsOffice ? "sidebar-kanban-button" : "sidebar-office-button"} /> )} diff --git a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx new file mode 100644 index 0000000000..29c57dc084 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = { + workspaces: { + activeId: "kanban-1" as string | null, + items: [ + { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, + { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, + ], + }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +vi.mock("./app-sidebar-workspace-picker", () => ({ + AppSidebarWorkspacePicker: () =>
, +})); + +import { AppSidebarHeader } from "./app-sidebar-header"; + +function renderHeader(collapsed = false) { + return render( + + + , + ); +} + +describe("AppSidebarHeader", () => { + beforeEach(() => { + state.workspaces.activeId = "kanban-1"; + }); + + afterEach(() => cleanup()); + + it("routes the Kandev brand to the active kanban workspace home", () => { + renderHeader(); + + expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe( + "/?workspaceId=kanban-1", + ); + }); + + it("routes the Kandev brand to the active office workspace home", () => { + state.workspaces.activeId = "office-1"; + + renderHeader(); + + expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe( + "/office?workspaceId=office-1", + ); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index 7117a649a7..09df64cdf8 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -4,7 +4,9 @@ import Link from "next/link"; import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; +import { workspaceHomeHref } from "./app-sidebar-workspace-navigation"; import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker"; type AppSidebarHeaderProps = { @@ -15,6 +17,12 @@ type AppSidebarHeaderProps = { const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { + const workspaces = useAppStore((s) => s.workspaces); + const activeWorkspace = workspaces.items.find( + (workspace) => workspace.id === workspaces.activeId, + ); + const homeHref = workspaceHomeHref(activeWorkspace); + if (collapsed) { // Minimal rail: brand home + expand. The workspace switcher lives only in // the expanded header — a lone workspace glyph here read as noise. @@ -23,7 +31,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead @@ -60,7 +68,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead className="flex items-center gap-1.5 h-10 px-3 shrink-0 border-b border-border" > { renderSection({ collapsed: true, grow: true }); fireEvent.click(screen.getByRole("button", { name: "Tasks" })); expect(storeState.setAppSidebarCollapsed).toHaveBeenCalledWith(false); - expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("tasks"); + expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("tasks", false); + }); + + it("collapses a default-expanded section on the first click when state is missing", () => { + storeState.appSidebar.sectionExpanded = {}; + render( + + +
children
+
+
, + ); + + fireEvent.click(screen.getByRole("button", { name: "Work" })); + + expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("office-work", true); }); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index 192dae665d..ef624ad806 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -21,6 +21,8 @@ type AppSidebarSectionProps = { headerActionVisibility?: "expanded" | "always"; /** Fills remaining sidebar height when expanded. Parent must be a flex column. */ grow?: boolean; + /** Initial expansion when the persisted section map does not yet contain this id. */ + defaultExpanded?: boolean; }; type SectionHeaderProps = { @@ -75,8 +77,9 @@ export function AppSidebarSection({ headerAction, headerActionVisibility = "expanded", grow, + defaultExpanded = false, }: AppSidebarSectionProps) { - const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? false); + const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? defaultExpanded); const toggleSection = useAppStore((s) => s.toggleAppSidebarSection); const setCollapsed = useAppStore((s) => s.setAppSidebarCollapsed); @@ -88,7 +91,7 @@ export function AppSidebarSection({ className="flex h-9 w-9 mx-auto items-center justify-center rounded-md text-foreground/70 hover:bg-muted/60 cursor-pointer" onClick={() => { setCollapsed(false); - if (!expanded) toggleSection(id); + if (!expanded) toggleSection(id, defaultExpanded); }} aria-label={label} > @@ -101,7 +104,7 @@ export function AppSidebarSection({ if (collapsed && !grow) return railButton; - const handleToggle = () => toggleSection(id); + const handleToggle = () => toggleSection(id, defaultExpanded); // The grow section (Tasks) absorbs remaining vertical space and scrolls // internally, so it stays flex-driven rather than animating to content diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts new file mode 100644 index 0000000000..d13a0f4658 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + LAST_KANBAN_WORKSPACE_KEY, + rememberLastKanbanWorkspace, + resolveFirstOfficeWorkspace, + resolveLastKanbanWorkspace, + workspaceHomeHref, +} from "./app-sidebar-workspace-navigation"; + +const kanban = { id: "kanban-1", office_workflow_id: "" }; +const kanbanTwo = { id: "kanban-2", office_workflow_id: null }; +const office = { id: "office-1", office_workflow_id: "wf-office" }; + +describe("app sidebar workspace navigation", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("routes workspace home by active workspace type", () => { + expect(workspaceHomeHref(kanban)).toBe("/?workspaceId=kanban-1"); + expect(workspaceHomeHref(office)).toBe("/office?workspaceId=office-1"); + expect(workspaceHomeHref(undefined)).toBe("/"); + }); + + it("remembers and resolves the last kanban workspace", () => { + rememberLastKanbanWorkspace(kanbanTwo); + + expect(window.localStorage.getItem(LAST_KANBAN_WORKSPACE_KEY)).toBe("kanban-2"); + expect(resolveLastKanbanWorkspace([kanban, office, kanbanTwo])).toBe(kanbanTwo); + }); + + it("does not overwrite the last kanban workspace with office workspaces", () => { + rememberLastKanbanWorkspace(kanban); + rememberLastKanbanWorkspace(office); + + expect(resolveLastKanbanWorkspace([kanban, office])).toBe(kanban); + }); + + it("falls back to the first kanban workspace and first office workspace", () => { + expect(resolveLastKanbanWorkspace([office, kanban, kanbanTwo])).toBe(kanban); + expect(resolveFirstOfficeWorkspace([kanban, office, kanbanTwo])).toBe(office); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts new file mode 100644 index 0000000000..36baded6d7 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts @@ -0,0 +1,42 @@ +export type SidebarWorkspace = { + id: string; + office_workflow_id?: string | null; +}; + +export const LAST_KANBAN_WORKSPACE_KEY = "kandev.lastKanbanWorkspaceId"; + +export function isOfficeWorkspace(workspace: SidebarWorkspace | undefined): boolean { + return Boolean(workspace?.office_workflow_id); +} + +export function workspaceHomeHref(workspace: SidebarWorkspace | undefined): string { + if (!workspace) return "/"; + const path = isOfficeWorkspace(workspace) ? "/office" : "/"; + return `${path}?workspaceId=${workspace.id}`; +} + +export function rememberLastKanbanWorkspace(workspace: SidebarWorkspace | undefined): void { + if (!workspace || isOfficeWorkspace(workspace) || typeof window === "undefined") return; + window.localStorage.setItem(LAST_KANBAN_WORKSPACE_KEY, workspace.id); +} + +export function resolveLastKanbanWorkspace( + workspaces: SidebarWorkspace[], +): SidebarWorkspace | null { + const kanbanWorkspaces = workspaces.filter((workspace) => !isOfficeWorkspace(workspace)); + if (kanbanWorkspaces.length === 0) return null; + + if (typeof window !== "undefined") { + const storedId = window.localStorage.getItem(LAST_KANBAN_WORKSPACE_KEY); + const stored = kanbanWorkspaces.find((workspace) => workspace.id === storedId); + if (stored) return stored; + } + + return kanbanWorkspaces[0] ?? null; +} + +export function resolveFirstOfficeWorkspace( + workspaces: SidebarWorkspace[], +): SidebarWorkspace | null { + return workspaces.find(isOfficeWorkspace) ?? null; +} 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 b3fa815608..a3c97bbef2 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -19,6 +19,7 @@ import { import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; import { cn } from "@/lib/utils"; +import { rememberLastKanbanWorkspace } from "./app-sidebar-workspace-navigation"; /** * Compact, secondary workspace switcher inlined after the Kandev brand in the @@ -93,6 +94,7 @@ export function AppSidebarWorkspacePicker() { return; } document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; + rememberLastKanbanWorkspace(workspace); setActiveWorkspace(id); if (officeEnabled) { const target = workspaceType(workspace) === "office" ? "/office" : "/"; diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx index b56355f3dc..466aa1c795 100644 --- a/apps/web/components/app-sidebar/app-sidebar.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -5,6 +5,9 @@ import { APP_SIDEBAR_EXPANDED_WIDTH } from "./app-sidebar-constants"; const navigationMock = vi.hoisted(() => ({ pathname: "/", })); +const officeRouteMock = vi.hoisted(() => ({ + inOffice: false, +})); // The AppSidebar pulls in a lot of children that touch the dockview / kanban // data layer. For unit testing the collapse + section toggle behaviour we stub @@ -48,6 +51,11 @@ vi.mock("./sections/agents-section", () => ({ vi.mock("./sections/integrations-section", () => ({ IntegrationsSection: () =>
, })); +vi.mock("./sections/office-navigation-section", () => ({ + OfficeNavigationSection: ({ section }: { section?: "all" | "work" | "office" }) => ( +
+ ), +})); vi.mock("./app-sidebar-footer", () => ({ AppSidebarFooter: () =>
, })); @@ -59,11 +67,17 @@ vi.mock("next/navigation", () => ({ usePathname: () => navigationMock.pathname, })); +vi.mock("@/hooks/use-in-office", () => ({ + useInOffice: () => officeRouteMock.inOffice, +})); + const storeState = { appSidebar: { collapsed: false, sectionExpanded: { tasks: true, + "office-work": true, + "office-workspace": true, projects: false, agents: false, integrations: false, @@ -88,6 +102,7 @@ import { AppSidebar } from "./app-sidebar"; describe("AppSidebar", () => { beforeEach(() => { navigationMock.pathname = "/"; + officeRouteMock.inOffice = false; storeState.appSidebar.collapsed = false; storeState.appSidebar.settingsMode = false; storeState.toggleAppSidebar = vi.fn(); @@ -108,6 +123,38 @@ describe("AppSidebar", () => { expect(screen.queryByTestId("settings-section")).toBeNull(); }); + it("renders office navigation without kanban-only sections in office mode", () => { + officeRouteMock.inOffice = true; + navigationMock.pathname = "/office"; + + render(); + + expect(screen.getByTestId("office-navigation-section-work")).toBeTruthy(); + expect(screen.getByTestId("office-navigation-section-office")).toBeTruthy(); + expect(screen.queryByTestId("tasks-section")).toBeNull(); + expect(screen.queryByTestId("integrations-section")).toBeNull(); + }); + + it("orders office navigation sections around entity groups", () => { + officeRouteMock.inOffice = true; + navigationMock.pathname = "/office"; + + render(); + + const nav = screen.getByRole("navigation"); + expect( + Array.from(nav.querySelectorAll("[data-testid]")).map((node) => + node.getAttribute("data-testid"), + ), + ).toEqual([ + "primary-nav", + "office-navigation-section-work", + "projects-section", + "agents-section", + "office-navigation-section-office", + ]); + }); + it("renders collapsed when store reports collapsed=true", () => { storeState.appSidebar.collapsed = true; render(); diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 0964f8aedc..7aeb998f29 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; import { useAppStore } from "@/components/state-provider"; +import { useInOffice } from "@/hooks/use-in-office"; import { cn } from "@/lib/utils"; import { APP_SIDEBAR_COLLAPSED_WIDTH, @@ -16,13 +17,22 @@ import { AppSidebarResizeHandle } from "./app-sidebar-resize-handle"; import { AppSidebarSettingsMode } from "./app-sidebar-settings-mode"; import { AgentsSection } from "./sections/agents-section"; import { IntegrationsSection } from "./sections/integrations-section"; +import { OfficeNavigationSection } from "./sections/office-navigation-section"; import { ProjectsSection } from "./sections/projects-section"; import { TasksSection } from "./sections/tasks-section"; const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean }> = [ { id: APP_SIDEBAR_SECTION_IDS.tasks, - matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/t/"), + matches: (p) => p.startsWith("/t/"), + }, + { + id: APP_SIDEBAR_SECTION_IDS.officeWork, + matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/office/routines"), + }, + { + id: APP_SIDEBAR_SECTION_IDS.officeWorkspace, + matches: (p) => p.startsWith("/office/workspace"), }, { id: APP_SIDEBAR_SECTION_IDS.projects, matches: (p) => p.startsWith("/office/projects") }, { id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") }, @@ -50,6 +60,7 @@ export function AppSidebar() { const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); const setWidth = useAppStore((s) => s.setAppSidebarWidth); const pathname = usePathname(); + const inOffice = useInOffice(); const handleResize = useCallback( (e: React.MouseEvent) => { @@ -134,15 +145,17 @@ export function AppSidebar() { <>
+ {inOffice && } - + {inOffice && } + {!inOffice && }
- {/* Tasks is the flex-grow middle section so it absorbs remaining - vertical space and scrolls internally. Settings is intentionally - not a nav section — it's reached only via the footer gear, which - takes the whole sidebar over with the settings tree. */} - + {/* In regular kanban mode, Tasks is the flex-grow middle section so + it absorbs remaining vertical space and scrolls internally. + Office has a dedicated /office/tasks page, so the sidebar only + renders a lightweight Tasks nav row above. */} + {!inOffice && } )} diff --git a/apps/web/components/app-sidebar/sections/agents-section.test.tsx b/apps/web/components/app-sidebar/sections/agents-section.test.tsx new file mode 100644 index 0000000000..cdd80145c5 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/agents-section.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const routerMock = vi.hoisted(() => ({ + push: vi.fn(), +})); + +const state = { + appSidebar: { + sectionExpanded: { + agents: true, + } as Record, + }, + office: { + agentProfiles: [], + inboxItems: [], + }, + workspaces: { + activeId: "workspace-1", + }, + setOfficeAgentProfiles: vi.fn(), + toggleAppSidebarSection: vi.fn(), + setAppSidebarCollapsed: vi.fn(), + sessions: { + byId: {}, + }, +}; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/office", + useRouter: () => routerMock, +})); + +vi.mock("@/hooks/use-in-office", () => ({ + useInOffice: () => true, +})); + +vi.mock("@/hooks/use-office-refetch", () => ({ + useOfficeRefetch: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-api", () => ({ + listAgentProfiles: vi.fn(() => Promise.resolve({ agents: [] })), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +vi.mock("@kandev/ui/collapsible", () => ({ + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import { AgentsSection } from "./agents-section"; + +describe("AgentsSection", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders Agent Topology as the header action before Add agent", () => { + render(); + + const agentsHeader = screen.getByRole("button", { name: "Agents" }).closest(".group\\/section"); + expect(agentsHeader).toBeTruthy(); + + const topology = within(agentsHeader as HTMLElement).getByRole("link", { + name: "Agent topology", + }); + const addAgent = within(agentsHeader as HTMLElement).getByRole("button", { + name: "Add agent", + }); + + expect(topology.getAttribute("href")).toBe("/office/workspace/org"); + expect(topology.compareDocumentPosition(addAgent) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/agents-section.tsx b/apps/web/components/app-sidebar/sections/agents-section.tsx index bb20e072f7..9a2bdab6b9 100644 --- a/apps/web/components/app-sidebar/sections/agents-section.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { IconPlus, IconRobot } from "@tabler/icons-react"; +import { IconPlus, IconRobot, IconSitemap } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; @@ -49,20 +49,38 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) { if (!inOffice) return null; const headerAction = ( - - - - - Add agent - +
+ + + + + Agent topology + + + + + + Add agent + +
); return ( @@ -72,6 +90,8 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) { collapsed={collapsed} icon={IconRobot} headerAction={headerAction} + headerActionVisibility="always" + defaultExpanded > {agents.length === 0 ? (

No agents yet

diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx new file mode 100644 index 0000000000..12fc12296b --- /dev/null +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let pathname = "/office"; + +const state = { + appSidebar: { + sectionExpanded: { + "office-work": true, + "office-workspace": true, + } as Record, + }, + office: { + dashboard: { + task_count: 7, + routine_count: 2, + skill_count: 3, + }, + }, + toggleAppSidebarSection: vi.fn(), + setAppSidebarCollapsed: vi.fn(), +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname, +})); + +vi.mock("@kandev/ui/collapsible", () => ({ + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, +})); + +import { OfficeNavigationSection } from "./office-navigation-section"; + +function hrefFor(label: string) { + return screen.getByRole("link", { name: new RegExp(label, "i") }).getAttribute("href"); +} + +describe("OfficeNavigationSection", () => { + beforeEach(() => { + pathname = "/office"; + state.appSidebar.sectionExpanded = { + "office-work": true, + "office-workspace": true, + }; + }); + + afterEach(() => { + cleanup(); + }); + + it("restores links for the dedicated office pages", () => { + render(); + + expect(hrefFor("Tasks")).toBe("/office/tasks"); + expect(hrefFor("Routines")).toBe("/office/routines"); + expect(hrefFor("Skills")).toBe("/office/workspace/skills"); + expect(hrefFor("Costs")).toBe("/office/workspace/costs"); + expect(hrefFor("Activity")).toBe("/office/workspace/activity"); + expect(hrefFor("Routing")).toBe("/office/workspace/routing"); + expect(hrefFor("Preferences")).toBe("/office/workspace/settings"); + expect(screen.queryByRole("link", { name: /Agent Topology/i })).toBeNull(); + }); + + it("uses expanded defaults when old persisted sidebar state lacks new office keys", () => { + state.appSidebar.sectionExpanded = {}; + + render(); + + expect(screen.getByRole("link", { name: /Tasks/i })).toBeTruthy(); + expect(screen.getByRole("link", { name: /Preferences/i })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx new file mode 100644 index 0000000000..dbbe5393fa --- /dev/null +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + IconBoxMultiple, + IconCircleDot, + IconCurrencyDollar, + IconHistory, + IconRepeat, + IconRoute, + IconSettings, +} from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; +import { AppSidebarNavItem } from "../app-sidebar-nav-item"; +import { AppSidebarSection } from "../app-sidebar-section"; + +type OfficeNavigationSectionProps = { + collapsed: boolean; + section?: "all" | "work" | "office"; +}; + +const workItems = [ + { icon: IconCircleDot, label: "Tasks", href: "/office/tasks" }, + { icon: IconRepeat, label: "Routines", href: "/office/routines" }, +] as const; + +const workspaceItems = [ + { icon: IconBoxMultiple, label: "Skills", href: "/office/workspace/skills" }, + { icon: IconCurrencyDollar, label: "Costs", href: "/office/workspace/costs" }, + { icon: IconHistory, label: "Activity", href: "/office/workspace/activity" }, + { icon: IconRoute, label: "Routing", href: "/office/workspace/routing" }, + { icon: IconSettings, label: "Preferences", href: "/office/workspace/settings" }, +] as const; + +export function OfficeNavigationSection({ + collapsed, + section = "all", +}: OfficeNavigationSectionProps) { + const dashboard = useAppStore((s) => s.office.dashboard); + const taskCount = dashboard?.task_count ?? 0; + const routineCount = dashboard?.routine_count ?? 0; + const skillCount = dashboard?.skill_count ?? 0; + + return ( + <> + {(section === "all" || section === "work") && ( + + {workItems.map((item) => ( + + ))} + + )} + {(section === "all" || section === "office") && ( + + {workspaceItems.map((item) => ( + + ))} + + )} + + ); +} + +function getWorkBadge( + href: (typeof workItems)[number]["href"], + taskCount: number, + routineCount: number, +): number | undefined { + if (href === "/office/tasks" && taskCount > 0) return taskCount; + if (href === "/office/routines" && routineCount > 0) return routineCount; + return undefined; +} + +function getWorkspaceBadge( + href: (typeof workspaceItems)[number]["href"], + skillCount: number, +): number | undefined { + if (href === "/office/workspace/skills" && skillCount > 0) return skillCount; + return undefined; +} diff --git a/apps/web/components/app-sidebar/sections/projects-section.test.tsx b/apps/web/components/app-sidebar/sections/projects-section.test.tsx new file mode 100644 index 0000000000..7c6b4ee174 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/projects-section.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const routerMock = vi.hoisted(() => ({ + push: vi.fn(), +})); + +const state = { + appSidebar: { + sectionExpanded: { + projects: false, + } as Record, + }, + office: { + projects: [], + }, + toggleAppSidebarSection: vi.fn(), + setAppSidebarCollapsed: vi.fn(), +}; + +vi.mock("next/navigation", () => ({ + useRouter: () => routerMock, +})); + +vi.mock("@/hooks/use-in-office", () => ({ + useInOffice: () => true, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +vi.mock("@kandev/ui/collapsible", () => ({ + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import { ProjectsSection } from "./projects-section"; + +describe("ProjectsSection", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("keeps the add-project action visible when the section is collapsed", () => { + render(); + + const projectsHeader = screen + .getByRole("button", { name: "Projects" }) + .closest(".group\\/section"); + expect(projectsHeader).toBeTruthy(); + + within(projectsHeader as HTMLElement).getByRole("button", { name: "Add project" }); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/projects-section.tsx b/apps/web/components/app-sidebar/sections/projects-section.tsx index 302fcf1ced..84874846ec 100644 --- a/apps/web/components/app-sidebar/sections/projects-section.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.tsx @@ -30,6 +30,7 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) { variant="ghost" size="icon" className="h-5 w-5 cursor-pointer" + aria-label="Add project" onClick={() => router.push("/office/projects")} > @@ -46,6 +47,8 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) { collapsed={collapsed} icon={IconBoxMultiple} headerAction={headerAction} + headerActionVisibility="always" + defaultExpanded > {activeProjects.length === 0 ? (

No projects yet

diff --git a/apps/web/e2e/tests/office/config-sync.spec.ts b/apps/web/e2e/tests/office/config-sync.spec.ts index fecc4a4245..7b1fd1dacd 100644 --- a/apps/web/e2e/tests/office/config-sync.spec.ts +++ b/apps/web/e2e/tests/office/config-sync.spec.ts @@ -31,9 +31,9 @@ test.describe("Config Sync", () => { expect(result).toBeDefined(); }); - test("settings page renders", async ({ testPage, officeSeed: _ }) => { + test("preferences page renders", async ({ testPage, officeSeed: _ }) => { await testPage.goto("/office/workspace/settings"); - await expect(testPage.getByRole("heading", { name: /settings/i })).toBeVisible({ + await expect(testPage.getByRole("heading", { name: /preferences/i })).toBeVisible({ timeout: 10_000, }); }); diff --git a/apps/web/e2e/tests/office/dashboard.spec.ts b/apps/web/e2e/tests/office/dashboard.spec.ts index 5f653b868f..d3b6e87732 100644 --- a/apps/web/e2e/tests/office/dashboard.spec.ts +++ b/apps/web/e2e/tests/office/dashboard.spec.ts @@ -56,9 +56,9 @@ test.describe("Dashboard", () => { }); }); - test("settings page renders", async ({ testPage, officeSeed: _ }) => { + test("preferences page renders", async ({ testPage, officeSeed: _ }) => { await testPage.goto("/office/workspace/settings"); - await expect(testPage.getByRole("heading", { name: /settings/i })).toBeVisible({ + await expect(testPage.getByRole("heading", { name: /preferences/i })).toBeVisible({ timeout: 10_000, }); }); diff --git a/apps/web/e2e/tests/office/sidebar-navigation.spec.ts b/apps/web/e2e/tests/office/sidebar-navigation.spec.ts index f96a10b3f9..9c4c17740a 100644 --- a/apps/web/e2e/tests/office/sidebar-navigation.spec.ts +++ b/apps/web/e2e/tests/office/sidebar-navigation.spec.ts @@ -5,17 +5,10 @@ test.describe("Sidebar navigation", () => { test("sidebar shows CEO agent link", async ({ testPage, officeSeed: _ }) => { await testPage.goto("/office"); await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); - // Post-overhaul: the office Agents section lives in the unified AppSidebar - // (`