diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bbccac3fe..a8842778f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1459,3 +1459,54 @@ .animate-queue-close { animation: queue-panel-close 180ms cubic-bezier(0.32, 0.72, 0, 1); } + +/* Unified AppSidebar — header/text content fades in when the rail expands + * from its collapsed (icon-only) state. */ +@keyframes app-sidebar-fade-in { + from { + opacity: 0; + transform: translateX(-4px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.sidebar-fade-in { + animation: app-sidebar-fade-in 200ms ease-out; +} + +/* AppSidebar section expand/collapse — a quick height animation that pushes + * sibling sections, replacing the old opacity fade. Driven by the Radix + * Collapsible `data-state` + content-height CSS var. */ +@keyframes sidebar-section-expand { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height); + } +} +@keyframes sidebar-section-collapse { + from { + height: var(--radix-collapsible-content-height); + } + to { + height: 0; + } +} +.sidebar-section-content { + overflow: hidden; +} +.sidebar-section-content[data-state="open"] { + animation: sidebar-section-expand 180ms cubic-bezier(0.32, 0.72, 0, 1); +} +.sidebar-section-content[data-state="closed"] { + animation: sidebar-section-collapse 160ms cubic-bezier(0.32, 0.72, 0, 1); +} +@media (prefers-reduced-motion: reduce) { + .sidebar-section-content[data-state="open"], + .sidebar-section-content[data-state="closed"] { + animation: none; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 31a6159ce..ca7d54dc7 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -11,6 +11,7 @@ import { CommandPanel } from "@/components/command-panel"; import { GlobalCommands } from "@/components/global-commands"; import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher"; import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider"; +import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider"; import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; @@ -81,7 +82,12 @@ export default async function RootLayout({ - {children} + +
+ +
{children}
+
+
diff --git a/apps/web/app/office/components/office-sidebar.tsx b/apps/web/app/office/components/office-sidebar.tsx deleted file mode 100644 index 53961b7d8..000000000 --- a/apps/web/app/office/components/office-sidebar.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - IconSquarePlus, - IconLayoutDashboard, - IconInbox, - IconCircleDot, - IconRepeat, - IconSitemap, - IconBoxMultiple, - IconCurrencyDollar, - IconHistory, - IconSettings, - IconRoute, - IconSearch, -} from "@tabler/icons-react"; -import Link from "next/link"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { useAppStore } from "@/components/state-provider"; -import { selectTotalLiveSessions } from "@/lib/state/slices/session/selectors"; -import { SidebarNavItem } from "./sidebar-nav-item"; -import { SidebarSection } from "./sidebar-section"; -import { SidebarAgentsList } from "./sidebar-agents-list"; -import { SidebarProjectsList } from "./sidebar-projects-list"; -import { NewTaskDialog } from "./new-task-dialog"; - -interface OfficeSidebarProps { - workspaceName?: string; -} - -export function OfficeSidebar({ workspaceName: ssrName }: OfficeSidebarProps) { - const workspaces = useAppStore((s) => s.workspaces); - const inboxCount = useAppStore((s) => s.office.inboxCount); - const totalLiveSessions = useAppStore(selectTotalLiveSessions); - const dashboard = useAppStore((s) => s.office.dashboard); - const taskCount = dashboard?.task_count ?? 0; - const skillCount = dashboard?.skill_count ?? 0; - const routineCount = dashboard?.routine_count ?? 0; - const [newTaskOpen, setNewTaskOpen] = useState(false); - - // Use store if hydrated, fall back to SSR prop - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); - const workspaceName = activeWorkspace?.name || ssrName || "Workspace"; - - return ( - - ); -} diff --git a/apps/web/app/office/components/sidebar-collapsible-section.tsx b/apps/web/app/office/components/sidebar-collapsible-section.tsx deleted file mode 100644 index 9ce62a628..000000000 --- a/apps/web/app/office/components/sidebar-collapsible-section.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { IconChevronRight, IconPlus } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { cn } from "@/lib/utils"; - -type SidebarCollapsibleSectionProps = { - label: string; - children: React.ReactNode; - onAdd?: () => void; - defaultOpen?: boolean; -}; - -export function SidebarCollapsibleSection({ - label, - children, - onAdd, - defaultOpen = true, -}: SidebarCollapsibleSectionProps) { - const [open, setOpen] = useState(defaultOpen); - - return ( - -
- - - - {label} - - - {onAdd && ( - - - - - Add {label.toLowerCase()} - - )} -
- -
{children}
-
-
- ); -} diff --git a/apps/web/app/office/components/sidebar-nav-item.test.tsx b/apps/web/app/office/components/sidebar-nav-item.test.tsx deleted file mode 100644 index 3422de0bc..000000000 --- a/apps/web/app/office/components/sidebar-nav-item.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; -import { IconLayoutDashboard } from "@tabler/icons-react"; - -// next/navigation pathname stub — not core to these assertions. -vi.mock("next/navigation", () => ({ - usePathname: () => "/office/other", -})); - -import { SidebarNavItem } from "./sidebar-nav-item"; - -afterEach(() => cleanup()); - -describe("SidebarNavItem live badge", () => { - it("does not render the live badge when liveCount is 0", () => { - render( - , - ); - expect(screen.queryByText(/live/)).toBeNull(); - }); - - it("does not render the live badge when liveCount is undefined", () => { - render(); - expect(screen.queryByText(/live/)).toBeNull(); - }); - - it("renders '1 live' (LiveAgentIndicator) when liveCount is 1", () => { - render( - , - ); - expect(screen.getByText("1 live")).toBeTruthy(); - }); - - it("renders '4 live' when liveCount is 4", () => { - render( - , - ); - expect(screen.getByText("4 live")).toBeTruthy(); - }); -}); diff --git a/apps/web/app/office/components/sidebar-nav-item.tsx b/apps/web/app/office/components/sidebar-nav-item.tsx deleted file mode 100644 index ee23bbe4f..000000000 --- a/apps/web/app/office/components/sidebar-nav-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; -import { Badge } from "@kandev/ui/badge"; -import { cn } from "@/lib/utils"; -import { LiveAgentIndicator } from "../agents/components/live-agent-indicator"; - -type SidebarNavItemProps = { - icon: TablerIcon; - label: string; - href: string; - badge?: number; - liveCount?: number; - onClick?: () => void; -}; - -export function SidebarNavItem({ - icon: Icon, - label, - href, - badge, - liveCount, - onClick, -}: SidebarNavItemProps) { - const pathname = usePathname(); - const isActive = pathname === href || (href !== "/office" && pathname.startsWith(href + "/")); - - const content = ( - <> - - {label} - {typeof liveCount === "number" && liveCount > 0 && } - {typeof badge === "number" && badge > 0 && ( - - {badge} - - )} - - ); - - if (onClick) { - return ( - - ); - } - - return ( - - {content} - - ); -} diff --git a/apps/web/app/office/components/sidebar-section.tsx b/apps/web/app/office/components/sidebar-section.tsx deleted file mode 100644 index c19ceae2d..000000000 --- a/apps/web/app/office/components/sidebar-section.tsx +++ /dev/null @@ -1,15 +0,0 @@ -type SidebarSectionProps = { - label: string; - children: React.ReactNode; -}; - -export function SidebarSection({ label, children }: SidebarSectionProps) { - return ( -
-
- {label} -
-
{children}
-
- ); -} diff --git a/apps/web/app/office/components/workspace-rail.tsx b/apps/web/app/office/components/workspace-rail.tsx deleted file mode 100644 index fd8a0f6ef..000000000 --- a/apps/web/app/office/components/workspace-rail.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { IconPlus, IconArrowLeft } from "@tabler/icons-react"; -import Link from "next/link"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; - -const GRADIENTS = [ - "linear-gradient(135deg, #6366f1, #8b5cf6)", - "linear-gradient(135deg, #3b82f6, #06b6d4)", - "linear-gradient(135deg, #10b981, #06b6d4)", - "linear-gradient(135deg, #f59e0b, #ef4444)", - "linear-gradient(135deg, #ec4899, #8b5cf6)", - "linear-gradient(135deg, #14b8a6, #3b82f6)", - "linear-gradient(135deg, #f97316, #facc15)", - "linear-gradient(135deg, #84cc16, #10b981)", -]; - -function getWorkspaceGradient(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) >>> 0; - } - return GRADIENTS[hash % GRADIENTS.length]; -} - -function getInitials(name: string): string { - const words = (name || "W").trim().split(/\s+/); - if (words.length >= 2) { - return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase(); - } - return words[0].charAt(0).toUpperCase(); -} - -interface WorkspaceItem { - id: string; - name: string; -} - -interface WorkspaceRailProps { - workspaces: WorkspaceItem[]; - activeWorkspaceId: string | null; -} - -export function WorkspaceRail({ - workspaces: ssrWorkspaces, - activeWorkspaceId: ssrActiveId, -}: WorkspaceRailProps) { - const router = useRouter(); - const storeWorkspaces = useAppStore((s) => s.workspaces); - const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); - - // Use store if hydrated, fall back to SSR props - const items = storeWorkspaces.items.length > 0 ? storeWorkspaces.items : ssrWorkspaces; - const activeId = storeWorkspaces.activeId ?? ssrActiveId; - - const handleSelect = useCallback( - (id: string) => { - document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; - setActiveWorkspace(id); - router.push(`/office?workspaceId=${id}`); - }, - [setActiveWorkspace, router], - ); - - return ( -
- {/* Back to homepage */} - - - - - - - Back to board - - - {/* Workspace avatars + add button */} -
- {items.map((ws) => { - const isActive = ws.id === activeId; - const initials = getInitials(ws.name); - const gradient = getWorkspaceGradient(ws.id); - return ( - - -
- {/* Left edge indicator pill */} -
- -
- - {ws.name} - - ); - })} - - {/* Add workspace button */} - - - - - Add workspace - -
-
- ); -} diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index f618a2b28..0f1f1ec05 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -13,8 +13,6 @@ import { } from "@/lib/api/domains/office-api"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import type { AppState } from "@/lib/state/store"; -import { WorkspaceRail } from "./components/workspace-rail"; -import { OfficeSidebar } from "./components/office-sidebar"; import { OfficeTopbar } from "./components/office-topbar"; function resolveActiveOfficeWorkspaceId( @@ -163,17 +161,9 @@ export default async function OfficeLayout({ children }: { children: React.React return ( -
- - w.id === activeWorkspaceId)?.name || "Workspace" - } - /> -
- -
{children}
-
+
+ +
{children}
); diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 121d7f844..a52210fc7 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -413,7 +413,6 @@ export function TasksPageClient(props: TasksPageClientProps) { return (
s.setCreateDialogOpen(true)} workspaceId={s.activeWorkspaceId ?? undefined} currentPage="tasks" searchQuery={s.searchQuery} diff --git a/apps/web/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts new file mode 100644 index 000000000..6f904b241 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -0,0 +1,24 @@ +/** Section IDs used for both display and persistence keys in the AppSidebar. */ +export const APP_SIDEBAR_SECTION_IDS = { + tasks: "tasks", + projects: "projects", + agents: "agents", + integrations: "integrations", + settings: "settings", +} as const; + +export type AppSidebarSectionId = + (typeof APP_SIDEBAR_SECTION_IDS)[keyof typeof APP_SIDEBAR_SECTION_IDS]; + +export const APP_SIDEBAR_EXPANDED_WIDTH = 240; +export const APP_SIDEBAR_COLLAPSED_WIDTH = 56; + +/** + * Shared active/inactive classes for sidebar nav rows. The active state uses a + * thin left accent bar (a `before` pseudo-element) rather than a filled + * background — a saturated fill reads as garish against the brand theme. Rows + * applying `SIDEBAR_ITEM_ACTIVE` get `relative` for free so the bar anchors. + */ +export const SIDEBAR_ITEM_ACTIVE = + "relative text-foreground hover:bg-muted/60 before:absolute before:left-0 before:inset-y-1.5 before:w-[3px] before:rounded-full before:bg-primary before:content-['']"; +export const SIDEBAR_ITEM_INACTIVE = "text-foreground/80 hover:bg-muted/60"; diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx new file mode 100644 index 000000000..84870e453 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + IconBuildings, + IconChartBar, + IconSettings, + IconSparkles, + IconStethoscope, +} from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; +import { ReleaseNotesDialog } from "@/components/release-notes/release-notes-dialog"; +import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useReleaseNotes } from "@/hooks/use-release-notes"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { linkToTask } from "@/lib/links"; +import { cn } from "@/lib/utils"; + +type AppSidebarFooterProps = { + collapsed: boolean; +}; + +type FooterIconButtonProps = { + icon: TablerIcon; + label: string; + collapsed: boolean; + onClick?: () => void; + href?: string; + badge?: boolean; + testId?: string; + /** Toggle state: rotates the icon a half-turn (spins back out when cleared). */ + active?: boolean; +}; + +function FooterIconButton({ + icon: Icon, + label, + collapsed, + onClick, + href, + badge, + testId, + active, +}: FooterIconButtonProps) { + const buttonProps = { + variant: "ghost" as const, + size: "icon" as const, + className: "h-7 w-7 cursor-pointer relative", + }; + + const content = ( + <> + + {badge && ( + + )} + + ); + + const trigger = href ? ( + + ) : ( + + ); + + return ( + + {trigger} + {label} + + ); +} + +export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) { + const router = useRouter(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); + const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); + const officeEnabled = useFeature("office"); + const releaseNotes = useReleaseNotes(); + const [improveOpen, setImproveOpen] = useState(false); + + return ( +
+ + + setImproveOpen(true)} + testId="sidebar-improve-kandev-button" + /> + {releaseNotes.showTopbarButton && ( + + )} + {officeEnabled && ( + + )} + + router.push(linkToTask(task.id))} + /> + {releaseNotes.hasNotes && ( + + )} +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx new file mode 100644 index 000000000..6599ca9f4 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Link from "next/link"; +import { IconChevronsLeft, IconChevronsRight } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker"; + +type AppSidebarHeaderProps = { + collapsed: boolean; + onToggleCollapse: () => void; +}; + +const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; + +export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { + if (collapsed) { + return ( +
+ + + + K + + + Kandev + + + + + + + Expand sidebar + +
+ ); + } + + return ( +
+
+ + Kandev + + + + + + Collapse sidebar + +
+
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx new file mode 100644 index 000000000..491e117f6 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "./app-sidebar-constants"; + +type AppSidebarNavItemProps = { + icon: TablerIcon; + label: string; + href?: string; + badge?: number; + onClick?: () => void; + collapsed: boolean; + /** Override the auto-derived active-state from pathname. */ + isActive?: boolean; + /** Suppress the default href-startsWith activation (use for "Home"). */ + exactMatch?: boolean; + /** Render as visually disabled and ignore clicks. */ + disabled?: boolean; + /** Optional data-testid placed on the button/link element. */ + testId?: string; +}; + +type TriggerProps = { + onClick?: () => void; + disabled: boolean; + baseClass: string; + label: string; + href?: string; + inner: React.ReactNode; + testId?: string; +}; + +function renderTrigger({ onClick, disabled, baseClass, label, href, inner, testId }: TriggerProps) { + if (onClick) { + return ( + + ); + } + if (disabled) { + return ( + + {inner} + + ); + } + return ( + + {inner} + + ); +} + +function isPathActive(pathname: string, href: string | undefined, exactMatch: boolean): boolean { + if (!href) return false; + if (exactMatch) return pathname === href; + if (pathname === href) return true; + return href !== "/" && pathname.startsWith(`${href}/`); +} + +export function AppSidebarNavItem({ + icon: Icon, + label, + href, + badge, + onClick, + collapsed, + isActive, + exactMatch = false, + disabled = false, + testId, +}: AppSidebarNavItemProps) { + const pathname = usePathname(); + const active = isActive ?? isPathActive(pathname, href, exactMatch); + + const baseClass = cn( + "flex items-center rounded-md text-[13px] font-medium transition-colors", + collapsed ? "h-9 w-9 justify-center mx-auto" : "h-9 px-2.5 gap-2.5 w-full text-left", + disabled + ? "cursor-not-allowed text-foreground/40" + : cn("cursor-pointer", active ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE), + ); + + const inner = ( + <> + + {!collapsed && ( + <> + {label} + {typeof badge === "number" && badge > 0 && ( + + {badge} + + )} + + )} + + ); + + const buttonOrLink = renderTrigger({ onClick, disabled, baseClass, label, href, inner, testId }); + + if (!collapsed) return buttonOrLink; + return ( + + {buttonOrLink} + + {label} + {typeof badge === "number" && badge > 0 ? ` (${badge})` : ""} + + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx new file mode 100644 index 000000000..e20e58575 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +function renderItem(collapsed: boolean) { + return render( + + + , + ); +} + +const state = { + workspaces: { activeId: "ws-1" as string | null }, + kanban: { + workflowId: "wf-1" as string | null, + steps: [{ id: "s1", title: "Todo" }], + tasks: [{ id: "t-1", title: "Parent task" }] as Array<{ id: string; title: string }>, + }, + tasks: { activeTaskId: null as string | null }, +}; +let officeEnabled = false; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: () => officeEnabled, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/", +})); +vi.mock("@/app/office/components/new-task-dialog", () => ({ + NewTaskDialog: () =>
, +})); +vi.mock("@/components/task-create-dialog", () => ({ + TaskCreateDialog: () =>
, +})); +vi.mock("@/components/task/new-subtask-dialog", () => ({ + NewSubtaskDialog: () =>
, +})); + +import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; + +const SUBTASK_TESTID = "sidebar-new-subtask"; +const OFFICE_DIALOG_TESTID = "office-new-task-dialog"; + +describe("AppSidebarNewTaskItem", () => { + beforeEach(() => { + state.workspaces.activeId = "ws-1"; + state.kanban.workflowId = "wf-1"; + state.kanban.steps = [{ id: "s1", title: "Todo" }]; + state.kanban.tasks = [{ id: "t-1", title: "Parent task" }]; + state.tasks.activeTaskId = null; + officeEnabled = false; + }); + + afterEach(() => cleanup()); + + it("uses the regular task-create dialog when office is disabled", () => { + officeEnabled = false; + renderItem(false); + expect(screen.getByTestId("regular-task-create-dialog")).toBeTruthy(); + expect(screen.queryByTestId(OFFICE_DIALOG_TESTID)).toBeNull(); + }); + + it("uses the office new-issue dialog when office is enabled", () => { + officeEnabled = true; + renderItem(false); + expect(screen.getByTestId(OFFICE_DIALOG_TESTID)).toBeTruthy(); + expect(screen.queryByTestId("regular-task-create-dialog")).toBeNull(); + }); + + it("renders no dialog when there is no active workspace", () => { + state.workspaces.activeId = null; + renderItem(false); + expect(screen.queryByTestId("regular-task-create-dialog")).toBeNull(); + expect(screen.queryByTestId(OFFICE_DIALOG_TESTID)).toBeNull(); + }); + + it("offers a subtask affordance when a task is active in regular mode", () => { + state.tasks.activeTaskId = "t-1"; + renderItem(false); + expect(screen.getByTestId(SUBTASK_TESTID)).toBeTruthy(); + expect(screen.getByTestId("new-subtask-dialog")).toBeTruthy(); + }); + + it("hides the subtask affordance when no task is active", () => { + state.tasks.activeTaskId = null; + renderItem(false); + expect(screen.queryByTestId(SUBTASK_TESTID)).toBeNull(); + }); + + it("offers the subtask affordance in office mode too (compact subtask dialog)", () => { + officeEnabled = true; + state.tasks.activeTaskId = "t-1"; + renderItem(false); + // Primary New Task uses the office dialog, but subtasks still go through + // the compact NewSubtaskDialog regardless of mode. + expect(screen.getByTestId(OFFICE_DIALOG_TESTID)).toBeTruthy(); + expect(screen.getByTestId(SUBTASK_TESTID)).toBeTruthy(); + expect(screen.getByTestId("new-subtask-dialog")).toBeTruthy(); + }); + + it("hides the subtask affordance when the rail is collapsed", () => { + state.tasks.activeTaskId = "t-1"; + renderItem(true); + expect(screen.queryByTestId(SUBTASK_TESTID)).toBeNull(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx new file mode 100644 index 000000000..15f98467a --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { IconSquarePlus, IconSubtask } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { NewTaskDialog } from "@/app/office/components/new-task-dialog"; +import { TaskCreateDialog } from "@/components/task-create-dialog"; +import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog"; +import { AppSidebarNavItem } from "./app-sidebar-nav-item"; + +type AppSidebarNewTaskItemProps = { + collapsed: boolean; +}; + +/** + * "New Task" entry in the sidebar primary nav. Office mode opens the richer + * "New issue" dialog (projects/assignees/stages); regular Kandev opens the + * standard task-create dialog wired to the active workflow. The Office dialog + * must stay behind the `office` flag so it never leaks into regular mode. + * + * When the user is inside a task (an active task in regular mode), a trailing + * subtask affordance appears so a child task can be created against the current + * one — restoring the contextual action the retired dockview header dropdown + * used to provide. + */ +export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workflowId = useAppStore((s) => s.kanban.workflowId); + const steps = useAppStore((s) => s.kanban.steps); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const activeTaskTitle = useAppStore((s) => { + const id = s.tasks.activeTaskId; + if (!id) return ""; + return s.kanban.tasks.find((t) => t.id === id)?.title ?? ""; + }); + const officeEnabled = useFeature("office"); + const [open, setOpen] = useState(false); + const [subtaskOpen, setSubtaskOpen] = useState(false); + + // The subtask affordance is available in both modes (office uses the richer + // dialog for the primary New Task, but subtasks always go through the compact + // NewSubtaskDialog, matching the retired dropdown). It needs an active task + // and the expanded rail to host the trailing button. + const canCreateSubtask = !collapsed && !!workspaceId && !!activeTaskId; + + return ( + <> +
+ setOpen(true)} + collapsed={collapsed} + disabled={!workspaceId} + testId="create-task-button" + /> + {canCreateSubtask && ( + + + + + New subtask of current task + + )} +
+ {workspaceId && + (officeEnabled ? ( + + ) : ( + setOpen(false)} + /> + ))} + {canCreateSubtask && ( + + )} + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx new file mode 100644 index 000000000..50f0528f1 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { useInOffice } from "@/hooks/use-in-office"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { AppSidebarNavItem } from "./app-sidebar-nav-item"; +import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; + +type AppSidebarPrimaryNavProps = { + collapsed: boolean; +}; + +export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const inboxCount = useAppStore((s) => s.office.inboxCount); + const inOffice = useInOffice(); + const handleOpenQuickChat = useQuickChatLauncher(workspaceId); + + return ( +
+ + {inOffice && ( + + )} + {workspaceId && ( + + )} + +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx new file mode 100644 index 000000000..67b305d74 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx @@ -0,0 +1,23 @@ +"use client"; + +type AppSidebarResizeHandleProps = { + onMouseDown: (e: React.MouseEvent) => void; +}; + +/** + * Hairline drag handle on the right edge of the expanded AppSidebar. + * Hover-visible only; widens slightly when active for affordance. + */ +export function AppSidebarResizeHandle({ onMouseDown }: AppSidebarResizeHandleProps) { + return ( + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx new file mode 100644 index 000000000..5dae3a2d8 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { IconChevronRight } from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { cn } from "@/lib/utils"; + +type AppSidebarSectionProps = { + id: string; + label: string; + collapsed: boolean; + icon: TablerIcon; + children: React.ReactNode; + /** Optional control rendered between the label and the collapse chevron. */ + headerAction?: React.ReactNode; + /** Fills remaining sidebar height when expanded. Parent must be a flex column. */ + grow?: boolean; +}; + +type SectionHeaderProps = { + label: string; + expanded: boolean; + headerAction?: React.ReactNode; + onToggle: () => void; +}; + +function SectionHeader({ label, expanded, headerAction, onToggle }: SectionHeaderProps) { + return ( +
+ + {expanded && headerAction && ( +
{headerAction}
+ )} + +
+ ); +} + +export function AppSidebarSection({ + id, + label, + collapsed, + icon: Icon, + children, + headerAction, + grow, +}: AppSidebarSectionProps) { + const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? false); + const toggleSection = useAppStore((s) => s.toggleAppSidebarSection); + const setCollapsed = useAppStore((s) => s.setAppSidebarCollapsed); + + if (collapsed) { + return ( + + + + + {label} + + ); + } + + const handleToggle = () => toggleSection(id); + + // The grow section (Tasks) absorbs remaining vertical space and scrolls + // internally, so it stays flex-driven rather than animating to content + // height like the fixed-size sections below. + if (grow) { + return ( +
+ + {expanded && ( +
{children}
+ )} +
+ ); + } + + return ( + + + +
{children}
+
+
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx new file mode 100644 index 000000000..6c50be230 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { IconSettings, IconChevronLeft } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { SettingsTree } from "./sections/settings/settings-tree"; + +/** + * Full-height settings takeover for the sidebar, shown while the footer gear is + * 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. + */ +export function AppSidebarSettingsMode() { + const pathname = usePathname(); + const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx new file mode 100644 index 000000000..7a3c65402 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { IconCheck, IconChevronDown, IconFolder, IconPlus } from "@tabler/icons-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + 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"; + +type AppSidebarWorkspacePickerProps = { + collapsed: boolean; +}; + +function WorkspaceTrigger({ collapsed, activeName }: { collapsed: boolean; activeName: string }) { + return ( + + ); +} + +export function AppSidebarWorkspacePicker({ collapsed }: AppSidebarWorkspacePickerProps) { + const router = useRouter(); + const officeEnabled = useFeature("office"); + const workspaces = useAppStore((s) => s.workspaces); + const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); + const [open, setOpen] = useState(false); + + const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const activeId = activeWorkspace?.id ?? null; + const activeName = activeWorkspace?.name ?? "Workspace"; + + const handleSelect = useCallback( + (id: string) => { + document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; + setActiveWorkspace(id); + if (officeEnabled) { + router.push(`/office?workspaceId=${id}`); + } + setOpen(false); + }, + [router, setActiveWorkspace, officeEnabled], + ); + + return ( + + + + + + {workspaces.items.length === 0 ? ( + No workspaces + ) : ( + workspaces.items.map((ws) => ( + handleSelect(ws.id)} + className="cursor-pointer gap-2" + > + {ws.name} + {ws.id === activeId && } + + )) + )} + + { + router.push("/office/setup?mode=new"); + setOpen(false); + }} + > + + Add workspace + + + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx new file mode 100644 index 000000000..a347cae21 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +// 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 +// the children to keep the test focused on the shell. +vi.mock("./app-sidebar-header", () => ({ + AppSidebarHeader: ({ + collapsed, + onToggleCollapse, + }: { + collapsed: boolean; + onToggleCollapse: () => void; + }) => ( + + ), +})); + +vi.mock("./app-sidebar-primary-nav", () => ({ + AppSidebarPrimaryNav: () =>
, +})); + +vi.mock("./sections/tasks-section", () => ({ + TasksSection: ({ collapsed }: { collapsed: boolean }) => ( +
+ tasks +
+ ), +})); +vi.mock("./sections/projects-section", () => ({ + ProjectsSection: () =>
, +})); +vi.mock("./sections/agents-section", () => ({ + AgentsSection: () =>
, +})); +vi.mock("./sections/integrations-section", () => ({ + IntegrationsSection: () =>
, +})); +vi.mock("./app-sidebar-footer", () => ({ + AppSidebarFooter: () =>
, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", +})); + +const storeState = { + appSidebar: { + collapsed: false, + sectionExpanded: { tasks: true, projects: false, agents: false, settings: false }, + width: 240, + }, + toggleAppSidebar: vi.fn(), + setAppSidebarCollapsed: vi.fn(), + toggleAppSidebarSection: vi.fn(), + setAppSidebarWidth: vi.fn(), +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +import { AppSidebar } from "./app-sidebar"; + +describe("AppSidebar", () => { + beforeEach(() => { + storeState.appSidebar.collapsed = false; + storeState.toggleAppSidebar = vi.fn(); + storeState.toggleAppSidebarSection = vi.fn(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders the nav sections when expanded (no Settings section — that's the footer gear)", () => { + render(); + expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("false"); + expect(screen.getByTestId("tasks-section")).toBeTruthy(); + expect(screen.getByTestId("projects-section")).toBeTruthy(); + expect(screen.getByTestId("agents-section")).toBeTruthy(); + expect(screen.queryByTestId("settings-section")).toBeNull(); + }); + + it("renders collapsed when store reports collapsed=true", () => { + storeState.appSidebar.collapsed = true; + render(); + expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("true"); + expect(screen.getByTestId("tasks-section").getAttribute("data-collapsed")).toBe("true"); + }); + + it("invokes toggleAppSidebar when the header collapse button is clicked", () => { + render(); + fireEvent.click(screen.getByTestId("header-toggle")); + expect(storeState.toggleAppSidebar).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx new file mode 100644 index 000000000..d67e0b008 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { useAppStore } from "@/components/state-provider"; +import { cn } from "@/lib/utils"; +import { + APP_SIDEBAR_COLLAPSED_WIDTH, + APP_SIDEBAR_EXPANDED_WIDTH, + APP_SIDEBAR_SECTION_IDS, +} from "./app-sidebar-constants"; +import { AppSidebarFooter } from "./app-sidebar-footer"; +import { AppSidebarHeader } from "./app-sidebar-header"; +import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav"; +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 { 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("/task/"), + }, + { id: APP_SIDEBAR_SECTION_IDS.projects, matches: (p) => p.startsWith("/office/projects") }, + { id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") }, +]; + +/** + * Unified app sidebar mounted at the root layout. Replaces the legacy + * WorkspaceRail + OfficeSidebar + dockview-embedded sidebar surfaces. + * + * Width: w-60 expanded / w-14 collapsed, smooth 300ms transition. On mobile + * (`absolute md:relative`) it overlays content instead of pushing it. + */ +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 setWidth = useAppStore((s) => s.setAppSidebarWidth); + const pathname = usePathname(); + + const handleResize = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = storedWidth; + const maxWidth = Math.floor(window.innerWidth * 0.3); + + const onMove = (moveEvent: MouseEvent) => { + const next = Math.min( + maxWidth, + Math.max(APP_SIDEBAR_EXPANDED_WIDTH, startWidth + (moveEvent.clientX - startX)), + ); + setWidth(next); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [storedWidth, setWidth], + ); + + const expandedWidth = Math.max(APP_SIDEBAR_EXPANDED_WIDTH, storedWidth); + + useEffect(() => { + if (!pathname) return; + for (const entry of SECTION_ROUTE_MAP) { + if (entry.matches(pathname) && !sectionExpanded[entry.id]) { + toggleSection(entry.id); + } + } + // Intentionally depend only on the pathname so user-collapses aren't + // immediately re-expanded by section state churn. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + return ( + + ); +} diff --git a/apps/web/app/office/components/sidebar-agents-list.tsx b/apps/web/components/app-sidebar/sections/agents-section.tsx similarity index 57% rename from apps/web/app/office/components/sidebar-agents-list.tsx rename to apps/web/components/app-sidebar/sections/agents-section.tsx index 30eabf2f4..2848a51b8 100644 --- a/apps/web/app/office/components/sidebar-agents-list.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -3,32 +3,42 @@ import { useCallback, useEffect } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { IconPlus, IconRobot } 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 { useInOffice } from "@/hooks/use-in-office"; import { useOfficeRefetch } from "@/hooks/use-office-refetch"; import { listAgentProfiles } from "@/lib/api/domains/office-api"; import { cn } from "@/lib/utils"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { selectActiveSessionsForAgent } from "@/lib/state/slices/session/selectors"; -import { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; -import { AgentAvatar } from "./agent-avatar"; -import { AgentStatusDot } from "../agents/components/agent-status-dot"; -import { LiveAgentIndicator } from "../agents/components/live-agent-indicator"; +import { AgentAvatar } from "@/app/office/components/agent-avatar"; +import { AgentStatusDot } from "@/app/office/agents/components/agent-status-dot"; +import { LiveAgentIndicator } from "@/app/office/agents/components/live-agent-indicator"; +import { + APP_SIDEBAR_SECTION_IDS, + SIDEBAR_ITEM_ACTIVE, + SIDEBAR_ITEM_INACTIVE, +} from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; -export function SidebarAgentsList() { +type AgentsSectionProps = { + collapsed: boolean; +}; + +export function AgentsSection({ collapsed }: AgentsSectionProps) { const router = useRouter(); + const inOffice = useInOffice(); const agents = useAppStore((s) => s.office.agentProfiles); const workspaceId = useAppStore((s) => s.workspaces.activeId); const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - // Refetch agents on mount and on WS "agents" events. This ensures the - // sidebar (and any page that reads agentProfiles from the store, such - // as the org chart and agent detail layout) recovers from stale SSR - // hydration without waiting for a user action or WS event to arrive. const refetchAgents = useCallback(async () => { - if (!workspaceId) return; + if (!workspaceId || !inOffice) return; const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, setOfficeAgentProfiles]); + }, [workspaceId, inOffice, setOfficeAgentProfiles]); useEffect(() => { refetchAgents(); @@ -36,23 +46,42 @@ export function SidebarAgentsList() { useOfficeRefetch("agents", refetchAgents); + if (!inOffice) return null; + + const headerAction = ( + + + + + Add agent + + ); + return ( - router.push("/office/agents")}> + {agents.length === 0 ? (

No agents yet

) : ( - agents.map((agent) => ) + agents.map((agent) => ) )} -
+ ); } -// Row is its own component so each one can subscribe to the live-session -// count selector independently. For typical office workspaces (under ~20 -// agents) the per-row subscription is cheap; if the list ever grows much -// larger, refactor into a single bulk selector that returns a map keyed -// by agent id. -function SidebarAgentRow({ agent }: { agent: AgentProfile }) { +function AgentRow({ agent }: { agent: AgentProfile }) { const pathname = usePathname(); const href = `/office/agents/${agent.id}`; const isActive = pathname === href; @@ -71,15 +100,14 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { {agent.name} {isAutoPaused ? ( @@ -87,10 +115,7 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { ) : null} {!isAutoPaused && errorCount > 0 ? ( - + {errorCount} error{errorCount === 1 ? "" : "s"} ) : null} diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx new file mode 100644 index 000000000..858f6b0fb --- /dev/null +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -0,0 +1,83 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + IconBrandGithub, + IconBrandGitlab, + IconHexagon, + IconPlugConnected, + IconTicket, +} from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability"; +import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability"; +import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; +import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr"; +import { + getAvailableIntegrationLinks, + getGitHubIntegrationStatus, +} from "@/components/integrations/integrations-menu"; +import { cn } from "@/lib/utils"; +import { + APP_SIDEBAR_SECTION_IDS, + SIDEBAR_ITEM_ACTIVE, + SIDEBAR_ITEM_INACTIVE, +} from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; + +type IntegrationsSectionProps = { + collapsed: boolean; +}; + +const INTEGRATION_ICONS: Record = { + github: IconBrandGithub, + gitlab: IconBrandGitlab, + jira: IconTicket, + linear: IconHexagon, +}; + +export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { + const pathname = usePathname(); + const { status: githubStatusRaw, loading: githubLoading } = useGitHubStatus(); + const gitlabAvailable = useGitLabAvailable(); + const jiraAvailable = useJiraAvailable(); + const linearAvailable = useLinearAvailable(); + const githubStatus = getGitHubIntegrationStatus(githubStatusRaw, githubLoading); + + const links = getAvailableIntegrationLinks({ + githubReady: githubStatus.ready, + gitlabReady: gitlabAvailable, + jiraAvailable, + linearAvailable, + }); + + if (links.length === 0) return null; + + return ( + + {links.map(({ id, label, href }) => { + const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected; + const isActive = pathname === href || pathname.startsWith(`${href}/`); + return ( + + + {label} + + ); + })} + + ); +} diff --git a/apps/web/app/office/components/sidebar-projects-list.tsx b/apps/web/components/app-sidebar/sections/projects-section.tsx similarity index 53% rename from apps/web/app/office/components/sidebar-projects-list.tsx rename to apps/web/components/app-sidebar/sections/projects-section.tsx index f00693301..302fcf1ce 100644 --- a/apps/web/app/office/components/sidebar-projects-list.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.tsx @@ -1,18 +1,52 @@ "use client"; import { useRouter } from "next/navigation"; +import { IconBoxMultiple, IconPlus } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; +import { useInOffice } from "@/hooks/use-in-office"; import { cn } from "@/lib/utils"; +import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; -export function SidebarProjectsList() { +type ProjectsSectionProps = { + collapsed: boolean; +}; + +export function ProjectsSection({ collapsed }: ProjectsSectionProps) { const router = useRouter(); + const inOffice = useInOffice(); const projects = useAppStore((s) => s.office.projects); const activeProjects = projects.filter((p) => p.status !== "archived"); + if (!inOffice) return null; + + const headerAction = ( + + + + + Add project + + ); + return ( - router.push("/office/projects")}> + {activeProjects.length === 0 ? (

No projects yet

) : ( @@ -24,7 +58,7 @@ export function SidebarProjectsList() { type="button" onClick={() => router.push(`/office/projects/${project.id}`)} className={cn( - "flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium rounded-md", + "flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md", "cursor-pointer w-full text-left", "text-foreground/80 hover:bg-muted/60", )} @@ -46,6 +80,6 @@ export function SidebarProjectsList() { ); }) )} -
+ ); } diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx new file mode 100644 index 000000000..a5a3fbb29 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx @@ -0,0 +1,49 @@ +"use client"; + +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 { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const ROOT_HREF = "/settings/agents"; + +type AgentsGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) { + const agents = useAppStore((s) => s.settingsAgents.items); + useAvailableAgents(); + + return ( + + {agents.flatMap((agent) => + agent.profiles.map((profile) => { + const encodedAgent = encodeURIComponent(agent.name); + const profilePath = `${ROOT_HREF}/${encodedAgent}/profiles/${profile.id}`; + const agentLabel = profile.agentDisplayName || agent.name; + return ( + } + isActive={pathname === profilePath} + depth={1} + /> + ); + }), + )} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx new file mode 100644 index 000000000..9e3356584 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { IconCpu } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { getExecutorIcon } from "@/lib/executor-icons"; +import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const ROOT_HREF = "/settings/executors"; + +type ExecutorsGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupProps) { + 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 = `${ROOT_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 new file mode 100644 index 000000000..ac0bad8e3 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/general-group.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { IconBell, IconCode, IconSettings } from "@tabler/icons-react"; +import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const GENERAL_HREF = "/settings/general"; +const NOTIFICATIONS_HREF = "/settings/general/notifications"; +const EDITORS_HREF = "/settings/general/editors"; + +type GeneralGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps) { + return ( + + + + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx b/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx new file mode 100644 index 000000000..08a4e5afb --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { + IconBrandGithub, + IconBrandGitlab, + IconBrandSlack, + IconHexagon, + IconPlugConnected, + IconTicket, +} from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const ROOT_HREF = "/settings/integrations"; + +const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [ + { href: `${ROOT_HREF}/github`, label: "GitHub", icon: IconBrandGithub }, + { href: `${ROOT_HREF}/gitlab`, label: "GitLab", icon: IconBrandGitlab }, + { href: `${ROOT_HREF}/jira`, label: "Jira", icon: IconTicket }, + { href: `${ROOT_HREF}/linear`, label: "Linear", icon: IconHexagon }, + { href: `${ROOT_HREF}/slack`, label: "Slack", icon: IconBrandSlack }, +]; + +type IntegrationsGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function IntegrationsGroup({ pathname, expanded, onToggle }: IntegrationsGroupProps) { + return ( + + {ITEMS.map(({ href, label, icon }) => ( + + ))} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx new file mode 100644 index 000000000..e685bd2ec --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -0,0 +1,145 @@ +"use client"; + +import Link from "next/link"; +import { IconChevronRight } from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { useState, type ReactNode } from "react"; +import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants"; + +const ACTIVE_CLASS = SIDEBAR_ITEM_ACTIVE; +const INACTIVE_CLASS = SIDEBAR_ITEM_INACTIVE; + +type SettingsLeafProps = { + href: string; + label: string; + icon?: TablerIcon; + /** Pre-rendered leading visual (e.g. AgentLogo). Takes precedence over `icon`. */ + leadingIcon?: ReactNode; + isActive: boolean; + /** Nesting level — used to add left padding. */ + depth?: number; +}; + +const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5"] as const; +const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1"] as const; + +function clampDepth(depth: number, max: number): number { + if (depth < 0) return 0; + if (depth > max) return max; + return depth; +} + +export function SettingsLeaf({ + href, + label, + icon: Icon, + leadingIcon, + isActive, + depth = 0, +}: SettingsLeafProps) { + return ( + + {leadingIcon ?? (Icon && )} + {label} + + ); +} + +type SettingsGroupProps = { + label: string; + icon?: TablerIcon; + /** When the group itself has a destination, the label area is also a link. */ + href?: string; + isActive?: boolean; + defaultExpanded?: boolean; + depth?: number; + children: ReactNode; + /** + * Controlled expansion. When `expanded` is provided the group becomes a + * controlled accordion member (open/close driven by the parent SettingsTree) + * and `onToggle` fires on header/chevron clicks. Omit both for the legacy + * self-managed behavior used by nested (per-workspace) groups. + */ + expanded?: boolean; + onToggle?: () => void; +}; + +export function SettingsGroup({ + label, + icon: Icon, + href, + isActive, + defaultExpanded = false, + depth = 0, + children, + expanded: controlledExpanded, + onToggle, +}: SettingsGroupProps) { + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); + const isControlled = controlledExpanded !== undefined; + const expanded = isControlled ? controlledExpanded : internalExpanded; + const paddingClass = GROUP_DEPTH_PADDING[clampDepth(depth, GROUP_DEPTH_PADDING.length - 1)]; + const toggle = () => { + if (isControlled) onToggle?.(); + else setInternalExpanded((v) => !v); + }; + + const labelInner = ( + <> + {Icon && } + {label} + + ); + + return ( + +
+ {href ? ( + + {labelInner} + + ) : ( + + )} + +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts new file mode 100644 index 000000000..3571953c8 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { settingsGroupIdForPath } from "./settings-tree"; + +describe("settingsGroupIdForPath", () => { + it("maps a group root to its id", () => { + expect(settingsGroupIdForPath("/settings/executors")).toBe("executors"); + expect(settingsGroupIdForPath("/settings/agents")).toBe("agents"); + expect(settingsGroupIdForPath("/settings/general")).toBe("general"); + expect(settingsGroupIdForPath("/settings/integrations")).toBe("integrations"); + expect(settingsGroupIdForPath("/settings/system")).toBe("system"); + expect(settingsGroupIdForPath("/settings/workspace")).toBe("workspaces"); + }); + + it("maps a nested path to its owning group", () => { + expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors"); + expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces"); + expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system"); + // Editors/Secrets live under /settings/general so they belong to General. + expect(settingsGroupIdForPath("/settings/general/editors")).toBe("general"); + }); + + it("returns null for standalone leaves with no owning group", () => { + expect(settingsGroupIdForPath("/settings/automations")).toBeNull(); + expect(settingsGroupIdForPath("/settings/prompts")).toBeNull(); + expect(settingsGroupIdForPath("/settings/utility-agents")).toBeNull(); + expect(settingsGroupIdForPath("/settings/external-mcp")).toBeNull(); + expect(settingsGroupIdForPath("/settings")).toBeNull(); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx new file mode 100644 index 000000000..9cbc8c250 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + IconBolt, + IconCode, + IconKey, + IconMessageCircle, + IconPlugConnected, + IconWand, +} from "@tabler/icons-react"; +import { AgentsGroup } from "./agents-group"; +import { ExecutorsGroup } from "./executors-group"; +import { GeneralGroup } from "./general-group"; +import { IntegrationsGroup } from "./integrations-group"; +import { SettingsLeaf } from "./settings-nav-primitives"; +import { SystemGroup } from "./system-group"; +import { WorkspacesGroup } from "./workspaces-group"; + +const AUTOMATIONS_HREF = "/settings/automations"; +const PROMPTS_HREF = "/settings/prompts"; +const UTILITY_HREF = "/settings/utility-agents"; +const EDITORS_HREF = "/settings/general/editors"; +const SECRETS_HREF = "/settings/general/secrets"; +const EXT_MCP_HREF = "/settings/external-mcp"; + +// Single-open accordion: each top-level group owns a route prefix. The group +// whose prefix matches the current path is the open one. Prefixes are disjoint, +// so first match wins and ordering is irrelevant. +const GROUP_ROUTES = [ + { id: "general", prefix: "/settings/general" }, + { id: "workspaces", prefix: "/settings/workspace" }, + { id: "integrations", prefix: "/settings/integrations" }, + { id: "agents", prefix: "/settings/agents" }, + { id: "executors", prefix: "/settings/executors" }, + { id: "system", prefix: "/settings/system" }, +] as const; + +/** The settings accordion group that owns `pathname`, or null for a standalone leaf. */ +export function settingsGroupIdForPath(pathname: string): string | null { + return GROUP_ROUTES.find((g) => pathname.startsWith(g.prefix))?.id ?? null; +} + +/** + * The settings nav tree. Top-level groups behave as a single-open accordion: + * opening one closes the others and reveals its subsections. Navigating to a + * standalone leaf (Prompts, Automations, …) closes every group. + * + * Rendered both inside the collapsible "Settings" sidebar section and, when the + * footer gear is active, as the full-height sidebar takeover. + */ +export function SettingsTree({ pathname }: { pathname: string }) { + const [openGroup, setOpenGroup] = useState(() => settingsGroupIdForPath(pathname)); + + // Re-sync when navigation lands on a different section so the open group + // always reflects the current page (a leaf with no owning group → all closed). + useEffect(() => { + setOpenGroup(settingsGroupIdForPath(pathname)); + }, [pathname]); + + const groupProps = (id: string) => ({ + expanded: openGroup === id, + onToggle: () => setOpenGroup((prev) => (prev === id ? null : id)), + }); + + return ( + <> + + + + + + + + + + + + + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/system-group.tsx b/apps/web/components/app-sidebar/sections/settings/system-group.tsx new file mode 100644 index 000000000..12a912ce7 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/system-group.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { + IconActivity, + IconArchive, + IconDatabase, + IconFileText, + IconInfoCircle, + IconRefresh, + IconScale, + IconServerCog, +} from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const ROOT_HREF = "/settings/system"; +const DEFAULT_HREF = `${ROOT_HREF}/status`; + +const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [ + { href: `${ROOT_HREF}/status`, label: "Status", icon: IconActivity }, + { href: `${ROOT_HREF}/database`, label: "Database", icon: IconDatabase }, + { href: `${ROOT_HREF}/backups`, label: "Backups", icon: IconArchive }, + { href: `${ROOT_HREF}/logs`, label: "Logs", icon: IconFileText }, + { href: `${ROOT_HREF}/updates`, label: "Updates", icon: IconRefresh }, + { href: `${ROOT_HREF}/about`, label: "About", icon: IconInfoCircle }, + { href: `${ROOT_HREF}/licenses`, label: "Licenses", icon: IconScale }, +]; + +type SystemGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function SystemGroup({ pathname, expanded, onToggle }: SystemGroupProps) { + return ( + + {ITEMS.map(({ href, label, icon }) => ( + + ))} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx new file mode 100644 index 000000000..157d763c1 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { IconArrowsShuffle, IconFolder, IconGitBranch } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; + +const ROOT_HREF = "/settings/workspace"; + +type WorkspacesGroupProps = { + pathname: string; + expanded?: boolean; + onToggle?: () => void; +}; + +export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + + return ( + + {workspaces.map((workspace) => { + const workspacePath = `${ROOT_HREF}/${workspace.id}`; + const repositoriesPath = `${workspacePath}/repositories`; + const workflowsPath = `${workspacePath}/workflows`; + return ( + + + + + ); + })} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx new file mode 100644 index 000000000..a501dc348 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { IconCircleDot } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { TaskSessionSidebar } from "@/components/task/task-session-sidebar"; +import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; +import { TasksViewPicker } from "./tasks-view-picker"; + +type TasksSectionProps = { + collapsed: boolean; +}; + +export function TasksSection({ collapsed }: TasksSectionProps) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workflowId = useAppStore((s) => s.kanban.workflowId); + + return ( + } + grow + > +
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx new file mode 100644 index 000000000..5cceeae59 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconChevronDown, IconAdjustments, IconCheck } from "@tabler/icons-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useAppStore } from "@/components/state-provider"; +import { SidebarFilterPopover } from "@/components/task/sidebar-filter/sidebar-filter-popover"; +import { cn } from "@/lib/utils"; + +const TRIGGER_BUTTON_CLASS = cn( + "flex h-5 items-center justify-center rounded-sm px-1.5 cursor-pointer", + "text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors", +); + +export function TasksViewPicker() { + const views = useAppStore((s) => s.sidebarViews.views); + const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId); + const draft = useAppStore((s) => s.sidebarViews.draft); + const setActiveView = useAppStore((s) => s.setSidebarActiveView); + const [filterOpen, setFilterOpen] = useState(false); + + const activeView = useMemo( + () => views.find((v) => v.id === activeViewId) ?? views[0], + [views, activeViewId], + ); + const hasDraft = !!draft && draft.baseViewId === activeViewId; + const activeLabel = activeView?.name ?? "All"; + + return ( +
+ + + + + + {views.map((view) => { + const isActive = view.id === activeViewId; + return ( + setActiveView(view.id)} + data-testid="sidebar-view-chip" + data-view-id={view.id} + data-active={isActive} + aria-pressed={isActive} + className="cursor-pointer gap-2 text-xs" + > + + {view.name} + + ); + })} + + + + + {hasDraft && ( + + )} + + } + /> +
+ ); +} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index b9e055636..e99464113 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -342,7 +342,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
void; -}) { - return ( -
- Workspace - -
- ); -} - function WorkflowSection({ activeWorkflowId, workflows, @@ -137,16 +100,13 @@ function RepositorySection({ export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDropdownProps) { const { - workspaces, workflows, - activeWorkspaceId, activeWorkflowId, repositories, repositoriesLoading, allRepositoriesSelected, selectedRepositoryId, enablePreviewOnClick, - onWorkspaceChange, onWorkflowChange, onRepositoryChange, onTogglePreviewOnClick, @@ -168,12 +128,6 @@ export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDro
- - void; isSearchLoading?: boolean; - showReleaseNotesButton: boolean; - onOpenReleaseNotes: () => void; showHealthIndicator: boolean; onOpenHealthDialog: () => void; }; @@ -28,8 +26,6 @@ export function KanbanHeaderMobile({ searchQuery = "", onSearchChange, isSearchLoading = false, - showReleaseNotesButton, - onOpenReleaseNotes, showHealthIndicator, onOpenHealthDialog, }: KanbanHeaderMobileProps) { @@ -41,6 +37,7 @@ export function KanbanHeaderMobile({ diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index c6bb929fe..5ed045331 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -1,35 +1,19 @@ "use client"; -import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@kandev/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; -import { - IconPlus, - IconSettings, - IconList, - IconLayoutKanban, - IconMenu2, - IconChartBar, - IconTimeline, - IconStethoscope, - IconBuildings, -} from "@tabler/icons-react"; -import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; -import { useFeature } from "@/hooks/domains/features/use-feature"; -import { IntegrationsTopbarLinks } from "@/components/integrations/integrations-menu"; +import { IconList, IconLayoutKanban, IconMenu2, IconTimeline } from "@tabler/icons-react"; import { PageTopbar } from "@/components/page-topbar"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; -import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; import { HealthIndicatorButton, HealthIssuesDialog } from "../system-health/health-indicator"; import { TaskSearchInput } from "./task-search-input"; -import { QuickChatButton } from "@/components/task/quick-chat-button"; import { KanbanHeaderMobile } from "./kanban-header-mobile"; import { MobileMenuSheet } from "./mobile-menu-sheet"; -import { linkToTask, linkToTasks } from "@/lib/links"; +import { linkToTasks } from "@/lib/links"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; @@ -38,7 +22,6 @@ import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator"; import type { ComponentProps, RefObject } from "react"; type KanbanHeaderProps = { - onCreateTask: () => void; workspaceId?: string; currentPage?: "kanban" | "tasks"; searchQuery?: string; @@ -73,111 +56,9 @@ function getHeaderTitle(currentPage: string): string { return currentPage === "tasks" ? "Tasks" : "Home"; } -function SettingsTopbarButton({ size = "icon" }: { size?: ComponentProps["size"] }) { - return ( - - - - - Settings - - ); -} - -function ImproveKandevTopbarButton({ - workspaceId, - buttonSize = "icon-lg", -}: { - workspaceId: string | undefined; - buttonSize?: ComponentProps["size"]; -}) { - const router = useRouter(); - const [open, setOpen] = useState(false); - return ( - <> - - - - - Improve Kandev - - router.push(linkToTask(task.id))} - /> - - ); -} - -function StatsTopbarButton() { - return ( - - - - - Stats - - ); -} - -function OfficeTopbarButton() { - // Hidden in production where features.office=false. The hook reads SSR- - // hydrated state, so the button never appears for a single frame on - // first paint. - const officeEnabled = useFeature("office"); - if (!officeEnabled) return null; - return ( - - - - - Office - - ); -} - -function HomeLeftActions({ workspaceId }: { workspaceId?: string }) { - return ( - <> - - - - - - ); -} - -function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) { - return ( - <> - - - - - ); -} +// Integrations / Stats / Office / Improve Kandev / Settings / Release notes +// have all moved to the unified AppSidebar (Fix 6). The kanban top bar now +// focuses on task-creation, view-toggle, kanban display, and search. function ViewToggleGroup({ toggleValue, @@ -245,8 +126,6 @@ function useIsHeaderNarrow(ref: RefObject): boolean { } function TabletHeader({ - onCreateTask, - workspaceId, title, workspaceLabel, searchQuery, @@ -258,8 +137,6 @@ function TabletHeader({ showHealthIndicator, onOpenHealthDialog, }: { - onCreateTask: () => void; - workspaceId?: string; title: string; workspaceLabel: string; searchQuery: string; @@ -277,15 +154,9 @@ function TabletHeader({ - ) : ( - - ) - } actionsClassName="gap-2" actions={ <> @@ -298,16 +169,6 @@ function TabletHeader({ className="hidden md:flex w-48 lg:w-56 [&_input]:h-8" /> )} - - @@ -332,39 +193,7 @@ function TabletHeader({ ); } -function CreateTaskTopbarButton({ - onCreateTask, - compact, -}: { - onCreateTask: () => void; - compact: boolean; -}) { - const button = ( - - ); - - if (!compact) return button; - - return ( - - {button} - Add task - - ); -} - function DesktopHeader({ - onCreateTask, - workspaceId, title, workspaceLabel, searchQuery, @@ -372,14 +201,9 @@ function DesktopHeader({ isSearchLoading, toggleValue, handleViewChange, - showReleaseNotesButton, - releaseNotesHasUnseen, - onOpenReleaseNotes, showHealthIndicator, onOpenHealthDialog, }: { - onCreateTask: () => void; - workspaceId?: string; title: string; workspaceLabel: string; searchQuery: string; @@ -387,9 +211,6 @@ function DesktopHeader({ isSearchLoading: boolean; toggleValue: string; handleViewChange: (value: string) => void; - showReleaseNotesButton: boolean; - releaseNotesHasUnseen: boolean; - onOpenReleaseNotes: () => void; showHealthIndicator: boolean; onOpenHealthDialog: () => void; }) { @@ -410,26 +231,19 @@ function DesktopHeader({
{searchInput}
) : null; const actionsSearch = !isHome || isNarrow ? searchInput : null; - const leftActions = isHome ? ( - - ) : ( - - ); return ( {actionsSearch} - - @@ -439,14 +253,6 @@ function DesktopHeader({ onClick={onOpenHealthDialog} size="icon-lg" /> - {showReleaseNotesButton && ( - - )} - } /> @@ -473,7 +279,6 @@ function useHeaderViewChange( } export function KanbanHeader({ - onCreateTask, workspaceId, currentPage = "kanban", searchQuery = "", @@ -492,15 +297,11 @@ export function KanbanHeader({ const title = getHeaderTitle(currentPage); const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId); - const indicatorProps = { - showReleaseNotesButton: releaseNotes.showTopbarButton, - releaseNotesHasUnseen: releaseNotes.hasUnseen, - onOpenReleaseNotes: releaseNotes.openDialog, + const healthProps = { showHealthIndicator: healthIndicator.hasIssues, onOpenHealthDialog: healthIndicator.openDialog, }; const sharedSearch = { searchQuery, onSearchChange, isSearchLoading }; - const sharedActions = { onCreateTask, workspaceId }; const renderHeader = () => { if (isMobile) { @@ -511,7 +312,7 @@ export function KanbanHeader({ title={title} workspaceLabel={workspaceLabel} {...sharedSearch} - {...indicatorProps} + {...healthProps} /> ); } @@ -519,14 +320,13 @@ export function KanbanHeader({ return ( <> ); } return ( ); }; diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index f6de0b891..4c197516e 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -1,29 +1,17 @@ "use client"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; -import { - IconAlertTriangle, - IconChartBar, - IconLayoutKanban, - IconList, - IconSettings, - IconSparkles, - IconTimeline, -} from "@tabler/icons-react"; -import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu"; +import { IconAlertTriangle, IconLayoutKanban, IconList, IconTimeline } from "@tabler/icons-react"; import { TaskSearchInput } from "./task-search-input"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { linkToTasks } from "@/lib/links"; import type { Repository } from "@/lib/types/http"; -import type { WorkflowsState, WorkspaceState } from "@/lib/state/slices"; - -type WorkspaceItem = WorkspaceState["items"][number]; +import type { WorkflowsState } from "@/lib/state/slices"; type MobileMenuSheetProps = { open: boolean; @@ -33,8 +21,6 @@ type MobileMenuSheetProps = { searchQuery?: string; onSearchChange?: (query: string) => void; isSearchLoading?: boolean; - showReleaseNotesButton: boolean; - onOpenReleaseNotes: () => void; showHealthIndicator: boolean; onOpenHealthDialog: () => void; }; @@ -52,9 +38,6 @@ function getMobileViewValue(currentPage: string, kanbanViewMode: string | null): } type MobileDisplayOptionsProps = { - activeWorkspaceId: string | null; - workspaces: WorkspaceItem[]; - onWorkspaceChange: (id: string | null) => void; activeWorkflowId: string | null; workflows: WorkflowsState["items"]; onWorkflowChange: (id: string | null) => void; @@ -67,9 +50,6 @@ type MobileDisplayOptionsProps = { }; function MobileDisplaySelects({ - activeWorkspaceId, - workspaces, - onWorkspaceChange, activeWorkflowId, workflows, onWorkflowChange, @@ -80,25 +60,6 @@ function MobileDisplaySelects({ }: Omit) { return ( <> -
- - -
-