fix: restore office sidebar navigation (#1396)

This commit is contained in:
Carlos Florêncio
2026-06-16 20:19:13 +01:00
committed by GitHub
parent 3bac35b574
commit d3575e2683
27 changed files with 828 additions and 75 deletions
@@ -14,13 +14,13 @@ const PAGE_TITLES: Record<string, string> = {
"/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 ? (
<div id="office-topbar-slot" className="flex items-center gap-2 flex-1 min-w-0" />
) : (
title && (
<h1 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h1>
)
title && <h1 className="truncate text-sm font-medium text-foreground">{title}</h1>
)}
</div>
);
@@ -89,7 +89,7 @@ export function OrgChartCanvas({ agents }: OrgChartCanvasProps) {
if (agents.length === 0) return <EmptyOrgState />;
return (
<div className="relative flex-1 min-h-0 overflow-auto">
<div className="relative flex-1 min-h-0 overflow-auto p-4 md:p-6">
<OrgZoomControls
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
@@ -98,6 +98,7 @@ export function OrgChartCanvas({ agents }: OrgChartCanvasProps) {
/>
<div
className="mx-auto"
style={{
width: canvasW * zoom,
height: canvasH * zoom,
@@ -1,6 +1,8 @@
/** Section IDs used for both display and persistence keys in the AppSidebar. */
export const APP_SIDEBAR_SECTION_IDS = {
tasks: "tasks",
officeWork: "office-work",
officeWorkspace: "office-workspace",
projects: "projects",
agents: "agents",
integrations: "integrations",
@@ -8,7 +8,13 @@ const mocks = vi.hoisted(() => ({
}));
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");
});
});
@@ -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 && (
<FooterIconButton
icon={IconBuildings}
label="Office"
icon={activeIsOffice ? IconLayoutKanban : IconBuildings}
label={activeIsOffice ? "Kanban" : "Office"}
collapsed={collapsed}
onClick={() => 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"}
/>
)}
<ThemeToggle />
@@ -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: () => <div data-testid="workspace-picker" />,
}));
import { AppSidebarHeader } from "./app-sidebar-header";
function renderHeader(collapsed = false) {
return render(
<TooltipProvider>
<AppSidebarHeader collapsed={collapsed} onToggleCollapse={vi.fn()} />
</TooltipProvider>,
);
}
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",
);
});
});
@@ -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
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
href={homeHref}
aria-label="Kandev home"
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground/80 hover:bg-muted/60 cursor-pointer"
>
@@ -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"
>
<Link
href="/"
href={homeHref}
aria-label="Kandev home"
className={cn(
"shrink-0 cursor-pointer text-sm font-semibold tracking-tight",
@@ -103,6 +103,28 @@ describe("AppSidebarSection", () => {
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(
<TooltipProvider>
<AppSidebarSection
id="office-work"
label="Work"
icon={IconCircleDot}
collapsed={false}
grow
defaultExpanded
>
<div data-testid={CHILDREN_TESTID}>children</div>
</AppSidebarSection>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Work" }));
expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("office-work", true);
});
});
@@ -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
@@ -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);
});
});
@@ -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;
}
@@ -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" : "/";
@@ -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: () => <div data-testid="integrations-section" />,
}));
vi.mock("./sections/office-navigation-section", () => ({
OfficeNavigationSection: ({ section }: { section?: "all" | "work" | "office" }) => (
<div data-testid={`office-navigation-section-${section ?? "all"}`} />
),
}));
vi.mock("./app-sidebar-footer", () => ({
AppSidebarFooter: () => <div data-testid="footer" />,
}));
@@ -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(<AppSidebar />);
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(<AppSidebar />);
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(<AppSidebar />);
@@ -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() {
<>
<div className="shrink-0 flex flex-col gap-2 overflow-y-auto">
<AppSidebarPrimaryNav collapsed={collapsed} />
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="work" />}
<ProjectsSection collapsed={collapsed} />
<AgentsSection collapsed={collapsed} />
<IntegrationsSection collapsed={collapsed} />
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="office" />}
{!inOffice && <IntegrationsSection collapsed={collapsed} />}
</div>
{/* 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. */}
<TasksSection collapsed={collapsed} />
{/* 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 && <TasksSection collapsed={collapsed} />}
</>
)}
</nav>
@@ -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<string, boolean>,
},
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 }) => <div>{children}</div>,
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
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(<AgentsSection collapsed={false} />);
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,
);
});
});
@@ -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 = (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
aria-label="Add agent"
onClick={() => router.push("/office/agents")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add agent</TooltipContent>
</Tooltip>
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
aria-label="Agent topology"
>
<Link href="/office/workspace/org">
<IconSitemap className="h-3 w-3 text-muted-foreground/60" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Agent topology</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
aria-label="Add agent"
onClick={() => router.push("/office/agents")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add agent</TooltipContent>
</Tooltip>
</div>
);
return (
@@ -72,6 +90,8 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
collapsed={collapsed}
icon={IconRobot}
headerAction={headerAction}
headerActionVisibility="always"
defaultExpanded
>
{agents.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No agents yet</p>
@@ -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<string, boolean>,
},
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 }) => <div>{children}</div>,
CollapsibleContent: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
}));
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(<OfficeNavigationSection collapsed={false} />);
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(<OfficeNavigationSection collapsed={false} />);
expect(screen.getByRole("link", { name: /Tasks/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Preferences/i })).toBeTruthy();
});
});
@@ -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") && (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.officeWork}
label="Work"
collapsed={collapsed}
icon={IconCircleDot}
defaultExpanded
>
{workItems.map((item) => (
<AppSidebarNavItem
key={item.href}
icon={item.icon}
label={item.label}
href={item.href}
badge={getWorkBadge(item.href, taskCount, routineCount)}
collapsed={collapsed}
/>
))}
</AppSidebarSection>
)}
{(section === "all" || section === "office") && (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.officeWorkspace}
label="Office"
collapsed={collapsed}
icon={IconSettings}
defaultExpanded
>
{workspaceItems.map((item) => (
<AppSidebarNavItem
key={item.href}
icon={item.icon}
label={item.label}
href={item.href}
badge={getWorkspaceBadge(item.href, skillCount)}
collapsed={collapsed}
/>
))}
</AppSidebarSection>
)}
</>
);
}
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;
}
@@ -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<string, boolean>,
},
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 }) => <div>{children}</div>,
CollapsibleContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
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(<ProjectsSection collapsed={false} />);
const projectsHeader = screen
.getByRole("button", { name: "Projects" })
.closest(".group\\/section");
expect(projectsHeader).toBeTruthy();
within(projectsHeader as HTMLElement).getByRole("button", { name: "Add project" });
});
});
@@ -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")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
@@ -46,6 +47,8 @@ export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
collapsed={collapsed}
icon={IconBoxMultiple}
headerAction={headerAction}
headerActionVisibility="always"
defaultExpanded
>
{activeProjects.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No projects yet</p>
@@ -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,
});
});
+2 -2
View File
@@ -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,
});
});
@@ -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
// (`<aside data-testid="app-sidebar">`) inside a COLLAPSIBLE section that
// defaults to collapsed on the `/office` dashboard (SECTION_ROUTE_MAP only
// auto-expands it on `/office/agents`). Expand it first, then assert the
// row. Each agent row is a single `<Link href="/office/agents/<id>">` whose
// Each agent row is a single `<Link href="/office/agents/<id>">` whose
// accessible name is the agent name (the avatar is aria-hidden). The
// sidebar agent list hydrates from a client-side fetch after first paint —
// 10s gives that hydration headroom on a heavily-loaded run without
// affecting the happy path (<1s in isolation).
// sidebar agent list hydrates from a client-side fetch after first paint.
const sidebar = new AppSidebarPage(testPage);
await sidebar.expandSection("Agents");
await expect(sidebar.root.getByRole("link", { name: /CEO/i }).first()).toBeVisible({
timeout: 10_000,
});
@@ -24,7 +17,33 @@ test.describe("Sidebar navigation", () => {
test("sidebar shows tasks link", async ({ testPage, officeSeed: _ }) => {
await testPage.goto("/office");
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByRole("link", { name: /Tasks/i }).first()).toBeVisible();
const sidebar = new AppSidebarPage(testPage);
await expect(sidebar.root.getByRole("link", { name: /Tasks/i })).toBeVisible();
await expect(sidebar.root.getByText("No tasks yet.")).toHaveCount(0);
await expect(sidebar.root.getByRole("button", { name: "Integrations" })).toHaveCount(0);
});
test("sidebar shows office workspace pages", async ({ testPage, officeSeed: _ }) => {
await testPage.goto("/office");
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
const sidebar = new AppSidebarPage(testPage);
await expect(sidebar.root.getByRole("link", { name: "Preferences" })).toHaveAttribute(
"href",
"/office/workspace/settings",
);
await expect(sidebar.root.getByRole("link", { name: "Skills" })).toHaveAttribute(
"href",
"/office/workspace/skills",
);
await expect(sidebar.root.getByRole("link", { name: "Agent topology" })).toHaveAttribute(
"href",
"/office/workspace/org",
);
await expect(sidebar.root.getByRole("link", { name: "Costs" })).toHaveAttribute(
"href",
"/office/workspace/costs",
);
});
test("navigate to agents page via sidebar", async ({ testPage, officeSeed: _ }) => {
@@ -40,11 +59,8 @@ test.describe("Sidebar navigation", () => {
test("navigate to tasks page via dashboard card", async ({ testPage, officeSeed: _ }) => {
await testPage.goto("/office");
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
// Post-overhaul: the sidebar "Tasks" entry is a collapsible section header
// (a toggle button), not a navigation link — there is no longer an
// in-sidebar link to the /office/tasks page. Navigate via the dashboard
// "Tasks In Progress" metric card link instead (mirrors the sibling
// "navigate to agents page via sidebar" test, which uses "Agents Enabled").
// Keep dashboard-card navigation covered separately from the sidebar Tasks
// link asserted above; both are intentional entry points to /office/tasks.
await testPage.getByRole("link", { name: /Tasks In Progress/i }).click();
// Scope the heading assertion to the page content (`<main>` in the office
// layout). The unified AppSidebar's collapsible "Tasks" section header also
@@ -0,0 +1,84 @@
import { test, expect } from "../../fixtures/office-fixture";
import { AppSidebarPage } from "../../pages/app-sidebar-page";
async function sectionTop(sidebar: AppSidebarPage, label: string): Promise<number> {
const box = await sidebar.root.getByRole("button", { name: label, exact: true }).boundingBox();
if (!box) throw new Error(`Missing sidebar section header: ${label}`);
return box.y;
}
test.describe("Sidebar workspace mode navigation", () => {
test("routes brand and footer toggle by active workspace type", async ({
testPage,
apiClient,
officeSeed,
}) => {
const kanbanWorkspace = await apiClient.createWorkspace("Sidebar Mode Kanban Workspace");
const sidebar = new AppSidebarPage(testPage);
await testPage.goto("/");
await testPage.getByTestId("sidebar-workspace-trigger").click();
await testPage.getByTestId(`sidebar-workspace-item-${kanbanWorkspace.id}`).click();
await expect(testPage).toHaveURL(
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id,
{ timeout: 10_000 },
);
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
"href",
`/?workspaceId=${kanbanWorkspace.id}`,
);
await expect(sidebar.root.getByRole("button", { name: "Office" })).toBeVisible();
await sidebar.root.getByRole("button", { name: "Office" }).click();
await expect(testPage).toHaveURL(
(url) =>
url.pathname === "/office" &&
url.searchParams.get("workspaceId") === officeSeed.workspaceId,
{ timeout: 10_000 },
);
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
"href",
`/office?workspaceId=${officeSeed.workspaceId}`,
);
await expect(sidebar.root.getByRole("button", { name: "Kanban" })).toBeVisible();
await sidebar.root.getByRole("button", { name: "Kanban" }).click();
await expect(testPage).toHaveURL(
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id,
{ timeout: 10_000 },
);
await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute(
"href",
`/?workspaceId=${kanbanWorkspace.id}`,
);
});
test("orders office groups and keeps collapsed group actions visible", async ({
testPage,
officeSeed: _,
}) => {
await testPage.goto("/office");
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
const sidebar = new AppSidebarPage(testPage);
const orderedTops = await Promise.all(
["Work", "Projects", "Agents", "Office"].map((label) => sectionTop(sidebar, label)),
);
expect(orderedTops).toEqual([...orderedTops].sort((a, b) => a - b));
const projectsHeader = sidebar.root.getByRole("button", { name: "Projects", exact: true });
await expect(projectsHeader).toHaveAttribute("aria-expanded", "true");
await projectsHeader.click();
await expect(projectsHeader).toHaveAttribute("aria-expanded", "false");
await expect(sidebar.root.getByRole("button", { name: "Add project" })).toBeVisible();
const agentsHeader = sidebar.root.getByRole("button", { name: "Agents", exact: true });
await expect(agentsHeader).toHaveAttribute("aria-expanded", "true");
await agentsHeader.click();
await expect(agentsHeader).toHaveAttribute("aria-expanded", "false");
await expect(sidebar.root.getByRole("link", { name: "Agent topology" })).toBeVisible();
await expect(sidebar.root.getByRole("button", { name: "Add agent" })).toBeVisible();
});
});
@@ -10,13 +10,14 @@ import {
import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
import type { AppSidebarState, UISlice } from "./types";
/** Tasks expanded by default; other sections collapsed. Mirrors the
* "open question / risks" note in the spec: keep the unified sidebar from
* defaulting too tall on first open. */
/** Keep primary navigation and entity groups open by default so first-time
* Office users can see projects, agents, and workspace tools immediately. */
export const DEFAULT_SECTION_EXPANDED: Record<string, boolean> = {
tasks: true,
projects: false,
agents: false,
"office-work": true,
"office-workspace": true,
projects: true,
agents: true,
integrations: false,
settings: false,
};
@@ -47,9 +48,9 @@ export function buildAppSidebarActions(set: ImmerSet) {
draft.appSidebar.collapsed = collapsed;
setStoredAppSidebarCollapsed(collapsed);
}),
toggleAppSidebarSection: (sectionId: string) =>
toggleAppSidebarSection: (sectionId: string, defaultExpanded = false) =>
set((draft) => {
const current = draft.appSidebar.sectionExpanded[sectionId] ?? false;
const current = draft.appSidebar.sectionExpanded[sectionId] ?? defaultExpanded;
draft.appSidebar.sectionExpanded[sectionId] = !current;
setStoredAppSidebarSectionExpanded({ ...draft.appSidebar.sectionExpanded });
}),
+1 -1
View File
@@ -220,7 +220,7 @@ export type UISliceActions = {
removeTaskFromSidebarPrefs: (taskId: string) => void;
toggleAppSidebar: () => void;
setAppSidebarCollapsed: (collapsed: boolean) => void;
toggleAppSidebarSection: (sectionId: string) => void;
toggleAppSidebarSection: (sectionId: string, defaultExpanded?: boolean) => void;
setAppSidebarWidth: (width: number) => void;
toggleAppSidebarSettingsMode: () => void;
};
+20 -4
View File
@@ -227,7 +227,10 @@ describe("appSidebar actions", () => {
expect(store.getState().appSidebar.collapsed).toBe(false);
expect(store.getState().appSidebar.width).toBe(APP_SIDEBAR_EXPANDED_WIDTH);
expect(store.getState().appSidebar.sectionExpanded.tasks).toBe(true);
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
expect(store.getState().appSidebar.sectionExpanded["office-work"]).toBe(true);
expect(store.getState().appSidebar.sectionExpanded["office-workspace"]).toBe(true);
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
expect(store.getState().appSidebar.sectionExpanded.agents).toBe(true);
});
it("hydrates collapsed flag from localStorage", () => {
@@ -257,12 +260,25 @@ describe("appSidebar actions", () => {
it("toggleAppSidebarSection flips per-section state and persists the map", () => {
const store = makeStore();
store.getState().toggleAppSidebarSection("projects");
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}");
expect(persisted.projects).toBe(true);
expect(persisted.projects).toBe(false);
store.getState().toggleAppSidebarSection("projects");
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
});
it("toggleAppSidebarSection honors the caller default for missing section keys", () => {
const store = makeStore();
store.setState((draft) => {
delete draft.appSidebar.sectionExpanded["future-section"];
});
store.getState().toggleAppSidebarSection("future-section", true);
expect(store.getState().appSidebar.sectionExpanded["future-section"]).toBe(false);
const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}");
expect(persisted["future-section"]).toBe(false);
});
it("settingsMode defaults off and is never read from storage", () => {