From a6cf276db50013195c0e9b571eb73f4d4e0a249d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 27 May 2026 23:33:35 +0100 Subject: [PATCH 01/20] feat(web): introduce unified AppSidebar and retire legacy nav surfaces Mounts a single collapsible AppSidebar at the root layout that replaces the former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane. Wires per-section expand + collapse state through a new UI slice with localStorage persistence, ports projects/agents/settings/tasks sections, and trims integrations / quick-chat / settings buttons + the home breadcrumb from the dockview top bar (debug-only MoreToolsMenu kept). Strips the sidebar column from every preset and the panel registry; persisted sidebar panels are dropped by the existing sanitize layer at restore time. --- apps/web/app/globals.css | 20 +++ apps/web/app/layout.tsx | 8 +- .../app/office/components/office-sidebar.tsx | 138 ------------------ .../sidebar-collapsible-section.tsx | 60 -------- .../components/sidebar-nav-item.test.tsx | 40 ----- .../office/components/sidebar-nav-item.tsx | 69 --------- .../app/office/components/sidebar-section.tsx | 15 -- .../app/office/components/workspace-rail.tsx | 136 ----------------- apps/web/app/office/layout.tsx | 16 +- .../app-sidebar/app-sidebar-constants.ts | 13 ++ .../app-sidebar/app-sidebar-footer.tsx | 35 +++++ .../app-sidebar/app-sidebar-header.tsx | 45 ++++++ .../app-sidebar/app-sidebar-nav-item.tsx | 85 +++++++++++ .../app-sidebar/app-sidebar-primary-nav.tsx | 47 ++++++ .../app-sidebar/app-sidebar-section.tsx | 83 +++++++++++ .../app-sidebar-workspace-picker.tsx | 102 +++++++++++++ .../app-sidebar/app-sidebar.test.tsx | 102 +++++++++++++ .../components/app-sidebar/app-sidebar.tsx | 80 ++++++++++ .../app-sidebar/sections/agents-section.tsx} | 65 ++++++--- .../sections/projects-section.tsx} | 40 ++++- .../app-sidebar/sections/settings-section.tsx | 76 ++++++++++ .../app-sidebar/sections/tasks-section.tsx | 33 +++++ .../app-sidebar/workspace-gradient.ts | 30 ++++ .../task/dockview-desktop-layout.tsx | 20 +-- .../components/task/dockview-layout-setup.ts | 29 +--- .../components/task/dockview-session-tabs.ts | 9 +- apps/web/components/task/dockview-shared.tsx | 19 +-- apps/web/components/task/task-top-bar.tsx | 83 ++--------- apps/web/hooks/use-editor-keybinds.ts | 5 +- apps/web/lib/local-storage.ts | 34 +++++ apps/web/lib/state/dockview-store.ts | 47 ++---- .../web/lib/state/layout-manager/constants.ts | 3 - apps/web/lib/state/layout-manager/index.ts | 1 - .../lib/state/layout-manager/presets.test.ts | 41 +++--- apps/web/lib/state/layout-manager/presets.ts | 49 +------ .../state/slices/ui/app-sidebar-actions.ts | 50 +++++++ apps/web/lib/state/slices/ui/types.ts | 12 ++ apps/web/lib/state/slices/ui/ui-slice.test.ts | 51 +++++++ apps/web/lib/state/slices/ui/ui-slice.ts | 8 + apps/web/lib/state/store.ts | 4 + 40 files changed, 1045 insertions(+), 758 deletions(-) delete mode 100644 apps/web/app/office/components/office-sidebar.tsx delete mode 100644 apps/web/app/office/components/sidebar-collapsible-section.tsx delete mode 100644 apps/web/app/office/components/sidebar-nav-item.test.tsx delete mode 100644 apps/web/app/office/components/sidebar-nav-item.tsx delete mode 100644 apps/web/app/office/components/sidebar-section.tsx delete mode 100644 apps/web/app/office/components/workspace-rail.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-constants.ts create mode 100644 apps/web/components/app-sidebar/app-sidebar-footer.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-header.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-nav-item.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-section.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar.test.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar.tsx rename apps/web/{app/office/components/sidebar-agents-list.tsx => components/app-sidebar/sections/agents-section.tsx} (63%) rename apps/web/{app/office/components/sidebar-projects-list.tsx => components/app-sidebar/sections/projects-section.tsx} (56%) create mode 100644 apps/web/components/app-sidebar/sections/settings-section.tsx create mode 100644 apps/web/components/app-sidebar/sections/tasks-section.tsx create mode 100644 apps/web/components/app-sidebar/workspace-gradient.ts create mode 100644 apps/web/lib/state/slices/ui/app-sidebar-actions.ts diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bbccac3fe..1e8a9d814 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1459,3 +1459,23 @@ .animate-queue-close { animation: queue-panel-close 180ms cubic-bezier(0.32, 0.72, 0, 1); } + +/* Unified AppSidebar — staggered fade-in on expand. `sidebar-fade-in` + * is applied to header/text content (faster); `sidebar-fade-in-2` is + * applied to section bodies so labels appear slightly before lists. */ +@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; +} +.sidebar-fade-in-2 { + animation: app-sidebar-fade-in 280ms ease-out 80ms backwards; +} 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/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts new file mode 100644 index 000000000..cb433b245 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -0,0 +1,13 @@ +/** Section IDs used for both display and persistence keys in the AppSidebar. */ +export const APP_SIDEBAR_SECTION_IDS = { + tasks: "tasks", + projects: "projects", + agents: "agents", + 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; 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..727c166cb --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Link from "next/link"; +import { IconSettings } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { cn } from "@/lib/utils"; + +type AppSidebarFooterProps = { + collapsed: boolean; +}; + +export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) { + return ( +
+ + + + + + + Settings + + +
+ ); +} 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..fc5369773 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -0,0 +1,45 @@ +"use client"; + +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; +}; + +export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { + return ( +
+ + + + + + + {collapsed ? "Expand sidebar" : "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..ceeaff77a --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -0,0 +1,85 @@ +"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"; + +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; +}; + +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, +}: AppSidebarNavItemProps) { + const pathname = usePathname(); + const active = isActive ?? isPathActive(pathname, href, exactMatch); + + const baseClass = cn( + "flex items-center rounded-md text-[13px] font-medium cursor-pointer transition-colors", + collapsed ? "h-9 w-9 justify-center mx-auto" : "h-9 px-2.5 gap-2.5 w-full text-left", + active ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-muted/60", + ); + + const inner = ( + <> + + {!collapsed && ( + <> + {label} + {typeof badge === "number" && badge > 0 && ( + + {badge} + + )} + + )} + + ); + + const buttonOrLink = onClick ? ( + + ) : ( + + {inner} + + ); + + if (!collapsed) return buttonOrLink; + return ( + + {buttonOrLink} + + {label} + {typeof badge === "number" && badge > 0 ? ` (${badge})` : ""} + + + ); +} 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..efbebc55e --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { IconHome, IconInbox, IconMessageCircle, IconSquarePlus } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { NewTaskDialog } from "@/app/office/components/new-task-dialog"; +import { AppSidebarNavItem } from "./app-sidebar-nav-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 handleOpenQuickChat = useQuickChatLauncher(workspaceId); + const [newTaskOpen, setNewTaskOpen] = useState(false); + + return ( +
+ + + {workspaceId && ( + + )} + setNewTaskOpen(true)} + collapsed={collapsed} + /> + +
+ ); +} 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..8b39084cc --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { IconChevronRight } from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +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 used as the collapsed-mode label. */ + icon: TablerIcon; + children: React.ReactNode; + /** Optional right-aligned action shown in the header when expanded. */ + headerAction?: React.ReactNode; +}; + +/** + * Reusable collapsible section primitive for the AppSidebar. + * + * Reads/writes per-section expanded state via the store. When the sidebar is + * fully collapsed (icon-rail mode) we render the icon as a tooltip target and + * clicking it expands the sidebar AND the section. + */ +export function AppSidebarSection({ + id, + label, + collapsed, + icon: Icon, + children, + headerAction, +}: 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} + + ); + } + + return ( +
+
+ + {headerAction} +
+ {expanded &&
{children}
} +
+ ); +} 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..687439116 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { IconCheck, IconPlus } from "@tabler/icons-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useAppStore } from "@/components/state-provider"; +import { cn } from "@/lib/utils"; +import { getWorkspaceGradient, getWorkspaceInitials } from "./workspace-gradient"; + +type AppSidebarWorkspacePickerProps = { + collapsed: boolean; +}; + +export function AppSidebarWorkspacePicker({ collapsed }: AppSidebarWorkspacePickerProps) { + const router = useRouter(); + 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); + router.push(`/office?workspaceId=${id}`); + setOpen(false); + }, + [router, setActiveWorkspace], + ); + + return ( + + + + + + {workspaces.items.length === 0 ? ( + No workspaces + ) : ( + workspaces.items.map((ws) => ( + handleSelect(ws.id)} + className="cursor-pointer gap-2" + > + + {getWorkspaceInitials(ws.name)} + + {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..ea92e16f0 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -0,0 +1,102 @@ +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/settings-section", () => ({ + SettingsSection: () =>
, +})); +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 }, + }, + toggleAppSidebar: vi.fn(), + setAppSidebarCollapsed: vi.fn(), + toggleAppSidebarSection: 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 all sections when expanded", () => { + 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.getByTestId("settings-section")).toBeTruthy(); + }); + + 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..10bf0ab33 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { 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 { AgentsSection } from "./sections/agents-section"; +import { ProjectsSection } from "./sections/projects-section"; +import { SettingsSection } from "./sections/settings-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") }, + { id: APP_SIDEBAR_SECTION_IDS.settings, matches: (p) => p.startsWith("/settings") }, +]; + +/** + * 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 sectionExpanded = useAppStore((s) => s.appSidebar.sectionExpanded); + const toggleSection = useAppStore((s) => s.toggleAppSidebarSection); + const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar); + const pathname = usePathname(); + + 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 63% rename from apps/web/app/office/components/sidebar-agents-list.tsx rename to apps/web/components/app-sidebar/sections/agents-section.tsx index 30eabf2f4..a351bf050 100644 --- a/apps/web/app/office/components/sidebar-agents-list.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -3,27 +3,31 @@ 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 { 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 } 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 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; const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); @@ -36,23 +40,40 @@ export function SidebarAgentsList() { useOfficeRefetch("agents", refetchAgents); + 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,7 +92,7 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { @@ -79,7 +100,6 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { {agent.name} {isAutoPaused ? ( @@ -87,10 +107,7 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { ) : null} {!isAutoPaused && errorCount > 0 ? ( - + {errorCount} error{errorCount === 1 ? "" : "s"} ) : null} diff --git a/apps/web/app/office/components/sidebar-projects-list.tsx b/apps/web/components/app-sidebar/sections/projects-section.tsx similarity index 56% rename from apps/web/app/office/components/sidebar-projects-list.tsx rename to apps/web/components/app-sidebar/sections/projects-section.tsx index f00693301..d940b88ab 100644 --- a/apps/web/app/office/components/sidebar-projects-list.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.tsx @@ -1,18 +1,48 @@ "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 { 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 projects = useAppStore((s) => s.office.projects); const activeProjects = projects.filter((p) => p.status !== "archived"); + const headerAction = ( + + + + + Add project + + ); + return ( - router.push("/office/projects")}> + {activeProjects.length === 0 ? (

No projects yet

) : ( @@ -24,7 +54,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 +76,6 @@ export function SidebarProjectsList() { ); }) )} -
+ ); } diff --git a/apps/web/components/app-sidebar/sections/settings-section.tsx b/apps/web/components/app-sidebar/sections/settings-section.tsx new file mode 100644 index 000000000..fd19665ff --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings-section.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + IconBolt, + IconCode, + IconCpu, + IconFolder, + IconKey, + IconMessageCircle, + IconPlugConnected, + IconRobot, + IconServerCog, + IconSettings, + IconWand, +} from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; +import { AppSidebarSection } from "../app-sidebar-section"; + +type SettingsSectionProps = { + collapsed: boolean; +}; + +type SettingsEntry = { + label: string; + href: string; + icon: TablerIcon; +}; + +const SETTINGS_ENTRIES: SettingsEntry[] = [ + { label: "General", href: "/settings/general", icon: IconSettings }, + { label: "Workspaces", href: "/settings/workspace", icon: IconFolder }, + { label: "Integrations", href: "/settings/integrations", icon: IconPlugConnected }, + { label: "Automations", href: "/settings/automations", icon: IconBolt }, + { label: "Agents", href: "/settings/agents", icon: IconRobot }, + { label: "Prompts", href: "/settings/prompts", icon: IconMessageCircle }, + { label: "Utility Agents", href: "/settings/utility-agents", icon: IconWand }, + { label: "Executors", href: "/settings/executors", icon: IconCpu }, + { label: "Editors", href: "/settings/general/editors", icon: IconCode }, + { label: "Secrets", href: "/settings/general/secrets", icon: IconKey }, + { label: "External MCP", href: "/settings/external-mcp", icon: IconPlugConnected }, + { label: "System", href: "/settings/system/status", icon: IconServerCog }, +]; + +export function SettingsSection({ collapsed }: SettingsSectionProps) { + const pathname = usePathname(); + + return ( + + {SETTINGS_ENTRIES.map(({ label, href, icon: Icon }) => { + const isActive = pathname === href || pathname.startsWith(`${href}/`); + return ( + + + {label} + + ); + })} + + ); +} 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..bdf138042 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -0,0 +1,33 @@ +"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"; + +type TasksSectionProps = { + collapsed: boolean; +}; + +/** + * Wraps the workspace task list (formerly rendered as a dockview pane) inside + * the unified AppSidebar Tasks section. + */ +export function TasksSection({ collapsed }: TasksSectionProps) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workflowId = useAppStore((s) => s.kanban.workflowId); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/workspace-gradient.ts b/apps/web/components/app-sidebar/workspace-gradient.ts new file mode 100644 index 000000000..89c605814 --- /dev/null +++ b/apps/web/components/app-sidebar/workspace-gradient.ts @@ -0,0 +1,30 @@ +/** Stable workspace avatar gradient + initials helpers. Ported from the + * former WorkspaceRail so the unified AppSidebar header can keep the same + * per-workspace color identity. */ + +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)", +]; + +export 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]; +} + +export function getWorkspaceInitials(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(); +} diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index f94f0949d..6c99c2dec 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -28,7 +28,6 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos"; // Panel components (rendered via portals, not directly by dockview) -import { TaskSessionSidebar } from "./task-session-sidebar"; import { LeftHeaderActions, RightHeaderActions } from "./dockview-header-actions"; import { DockviewWatermark } from "./dockview-watermark"; import { TaskChatPanel } from "./task-chat-panel"; @@ -143,7 +142,6 @@ function PortalSlot(props: IDockviewPanelProps) { // All panel types use the same PortalSlot wrapper — dockview only manages // layout positioning. Actual rendering happens in PanelPortalHost below. const components: Record> = { - sidebar: PortalSlot, chat: PortalSlot, "diff-viewer": PortalSlot, "file-editor": PortalSlot, @@ -205,22 +203,6 @@ const tabComponents: Record state.workspaces.activeId); - // Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation. - const workflowId = useAppStore((state) => state.kanban.workflowId); - const workspaceName = useAppStore((state) => { - const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId); - return ws?.name ?? "Workspace"; - }); - - useEffect(() => { - setPanelTitle(panelId, workspaceName); - }, [panelId, workspaceName]); - - return ; -} - export const CHAT_PANEL_FALLBACK_LABEL = "Agent"; export function resolveChatPanelTitle(agentLabel: string | null | undefined): string { @@ -398,7 +380,7 @@ function renderPanel( switch (resolved) { case "sidebar": - return ; + return null; case "chat": return ; case "diff-viewer": diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index 35435c0aa..3f78edd95 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -4,7 +4,6 @@ import type { AppState } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { getRootSplitview } from "@/lib/state/dockview-layout-builders"; import { - computeSidebarMaxPx, computeRightMaxPx, LAYOUT_PINNED_MIN_PX, RIGHT_TOP_GROUP, @@ -68,7 +67,6 @@ function enforcePinnedTargets(api: DockviewReadyEvent["api"]): void { if (!sv || sv.length < 2) return; enforcing = true; try { - if (store.sidebarVisible) restoreColumnToTarget(sv, 0, getPinnedTarget("sidebar")); if (store.rightPanelsVisible) { restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right")); } @@ -83,14 +81,6 @@ function setLooseConstraints(api: DockviewReadyEvent["api"]): void { if (store.isRestoringLayout) return; if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return; - const sb = api.getPanel("sidebar"); - if (sb && store.sidebarVisible) { - sb.group.api.setConstraints({ - maximumWidth: computeSidebarMaxPx(), - minimumWidth: LAYOUT_PINNED_MIN_PX, - }); - } - if (store.rightPanelsVisible) { for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) { const group = api.groups.find((g) => g.id === gid); @@ -138,7 +128,6 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo const sv = getRootSplitview(api); if (!sv) return; const store = useDockviewStore.getState(); - if (store.sidebarVisible) setPinnedTarget("sidebar", sv.getViewSize(0)); if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1)); }); }; @@ -163,18 +152,6 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { const sv = getRootSplitview(api); if (!sv || sv.length < 2) return; try { - // Sidebar is grid index 0 *only when sidebar is visible*. Without the - // visibility guard, hiding the sidebar makes index 0 the center column, - // and we'd persist the center width as the sidebar's preferred width. - if (store.sidebarVisible) { - const sidebarW = sv.getViewSize(0); - if (sidebarW > 50) { - const current = store.pinnedWidths.get("sidebar"); - if (current !== sidebarW) { - store.setPinnedWidth("sidebar", sidebarW); - } - } - } // Right column is the last grid index when present. Skip when there is // no right column (compact preset, rightPanelsVisible=false). if (store.rightPanelsVisible) { @@ -383,10 +360,8 @@ export function setupPortalCleanup( ): void { api.onDidRemovePanel((panel) => { if (useDockviewStore.getState().isRestoringLayout) return; - const nonSidebarRemaining = api.panels.filter( - (p) => p.id !== panel.id && p.api.component !== "sidebar", - ).length; - handleMaximizeExitOnLastClose(api, panel.id, nonSidebarRemaining); + const remainingPanelCount = api.panels.filter((p) => p.id !== panel.id).length; + handleMaximizeExitOnLastClose(api, panel.id, remainingPanelCount); const entry = panelPortalManager.get(panel.id); const sessionForApi = resolveSessionForEntry(appStore, entry?.envId); if (entry?.component === "vscode" && sessionForApi) stopVscode(sessionForApi); diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index c5faf4aab..464802f99 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -74,10 +74,7 @@ export function setupChatPanelSafetyNet( const hasChatPanel = api.panels.some((p) => p.id === "chat" || p.id.startsWith("session:")); if (hasChatPanel) return; const activeSessionId = appStore.getState().tasks.activeSessionId; - const sb = api.getPanel("sidebar"); - const position = sb - ? { direction: "right" as const, referencePanel: "sidebar" } - : undefined; + const position = undefined; // Only recreate a panel if there's still an active session. // If all sessions were deleted, leave the layout empty — the user // can create a new session via the "+" menu. @@ -105,7 +102,7 @@ export function setupChatPanelSafetyNet( debug("setupChatPanelSafetyNet: recreating session panel", { activeSessionId, activeTaskId, - anchor: sb ? "rightOfSidebar" : "auto", + anchor: "auto", }); } api.addPanel({ @@ -264,8 +261,6 @@ function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] { // to the right of pr-detail, which contradicts the default placement // every other code path produces. Pick the consistent default. if (anchorGroupId) return { referenceGroup: anchorGroupId, index: 0 }; - const sb = api.getPanel("sidebar"); - if (sb) return { direction: "right" as const, referencePanel: "sidebar" }; return undefined; } diff --git a/apps/web/components/task/dockview-shared.tsx b/apps/web/components/task/dockview-shared.tsx index d372f0dad..b5fc7d441 100644 --- a/apps/web/components/task/dockview-shared.tsx +++ b/apps/web/components/task/dockview-shared.tsx @@ -14,7 +14,6 @@ import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; // Panel components (rendered via portals, not directly by dockview) -import { TaskSessionSidebar } from "./task-session-sidebar"; import { TaskChatPanel } from "./task-chat-panel"; import { TaskChangesPanel } from "./task-changes-panel"; import { ChangesPanel } from "./changes-panel"; @@ -106,7 +105,6 @@ function PortalSlot(props: IDockviewPanelProps) { // All panel types use the same PortalSlot wrapper — dockview only manages // layout positioning. Actual rendering happens in PanelPortalHost below. export const dockviewComponents: Record> = { - sidebar: PortalSlot, chat: PortalSlot, "diff-viewer": PortalSlot, "file-editor": PortalSlot, @@ -152,21 +150,6 @@ export { ContextMenuTab }; // Each content component renders the real panel UI. They live permanently // in the PanelPortalHost and survive dockview layout switches. -function SidebarContent({ panelId }: { panelId: string }) { - const workspaceId = useAppStore((state) => state.workspaces.activeId); - const workflowId = useAppStore((state) => state.workflows.activeId); - const workspaceName = useAppStore((state) => { - const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId); - return ws?.name ?? "Workspace"; - }); - - useEffect(() => { - setPanelTitle(panelId, workspaceName); - }, [panelId, workspaceName]); - - return ; -} - function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) { const agentLabel = useAppStore((state) => { if (!sessionId) return null; @@ -335,7 +318,7 @@ export function renderPanel( switch (resolved) { case "sidebar": - return ; + return null; case "chat": return ; case "diff-viewer": diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 1e8d6375a..d6fa5280d 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -2,22 +2,14 @@ import { memo, type ReactNode } from "react"; import Link from "next/link"; -import { IconBug, IconDots, IconHome, IconSettings } from "@tabler/icons-react"; +import { IconBug, IconDots } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@kandev/ui/breadcrumb"; +import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -35,12 +27,10 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { ExecutorSettingsButton } from "@/components/task/executor-settings-button"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; -import { QuickChatButton } from "@/components/task/quick-chat-button"; import { TopbarActionOverflow, type TopbarOverflowItem, } from "@/components/task/topbar-action-overflow"; -import { IntegrationsMenu } from "@/components/integrations/integrations-menu"; import { DEBUG_UI } from "@/lib/config"; type TaskTopBarProps = { @@ -161,7 +151,8 @@ function IssueTrackerButtons({ ); } -/** Left section: home → task name breadcrumb, integrations menu, executor info */ +/** Left section: task name breadcrumb + executor info. Home + integrations + * moved to the unified AppSidebar in the UI overhaul. */ function TopBarLeft({ taskId, activeSessionId, @@ -174,18 +165,6 @@ function TopBarLeft({
- - - - - - - - @@ -199,8 +178,6 @@ function TopBarLeft({ - - {!isArchived && showExecutorSettings && ( )} @@ -234,7 +211,6 @@ function MoreToolsMenu({ showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; }) { - const showDebugItem = DEBUG_UI && onToggleDebugOverlay; const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"; return ( @@ -256,41 +232,17 @@ function MoreToolsMenu({ More tools - {showDebugItem && ( - <> - - - {debugLabel} - - - + {onToggleDebugOverlay && ( + + + {debugLabel} + )} - - - - Settings - - ); } -function SettingsButton() { - return ( - - - - - Settings - - ); -} - function AttentionStatusGroup({ taskId, activeSessionId, @@ -351,13 +303,11 @@ function TopbarToolsGroup({ )} - {showDebugMenu ? ( + {showDebugMenu && ( - ) : ( - )} ); @@ -404,19 +354,6 @@ function TopBarRight({ }); } - if (!isArchived && workspaceId) { - items.push({ - id: "quick-chat", - label: "Quick chat", - priority: 20, - content: ( - - - - ), - }); - } - items.push({ id: "attention", label: "Task status and attention", diff --git a/apps/web/hooks/use-editor-keybinds.ts b/apps/web/hooks/use-editor-keybinds.ts index bd4ac2ea4..8bfe65456 100644 --- a/apps/web/hooks/use-editor-keybinds.ts +++ b/apps/web/hooks/use-editor-keybinds.ts @@ -84,13 +84,14 @@ function isEditableTarget(e: KeyboardEvent): boolean { function handleLayoutToggle( e: KeyboardEvent, overrides: StoredShortcutOverrides | undefined, + appStore: ReturnType, ): boolean { if (isEditableTarget(e)) return false; if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) { e.preventDefault(); e.stopPropagation(); - useDockviewStore.getState().toggleSidebar(); + appStore.getState().toggleAppSidebar(); return true; } @@ -187,7 +188,7 @@ export function useEditorKeybinds() { return; } - if (handleLayoutToggle(e, overrides)) return; + if (handleLayoutToggle(e, overrides, appStore)) return; handleBottomTerminal(e, appStore, previousFocusRef, overrides); }; diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 30d526481..980e4ede5 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -754,6 +754,40 @@ export function pruneSubtaskOrder(map: Record, taskId: string) return changed; } +// --- Unified AppSidebar collapse + section expand state (localStorage, global) --- + +const APP_SIDEBAR_COLLAPSED_KEY = "kandev.appSidebar.collapsed"; +const APP_SIDEBAR_SECTION_EXPANDED_KEY = "kandev.appSidebar.sectionExpanded"; + +export function getStoredAppSidebarCollapsed(fallback: boolean): boolean { + return getLocalStorage(APP_SIDEBAR_COLLAPSED_KEY, fallback); +} + +export function setStoredAppSidebarCollapsed(collapsed: boolean): void { + setLocalStorage(APP_SIDEBAR_COLLAPSED_KEY, collapsed); +} + +export function getStoredAppSidebarSectionExpanded( + fallback: Record, +): Record { + const raw = getLocalStorage>( + APP_SIDEBAR_SECTION_EXPANDED_KEY, + fallback, + ) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...fallback }; + const out: Record = { ...fallback }; + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof key === "string" && typeof value === "boolean") { + out[key] = value; + } + } + return out; +} + +export function setStoredAppSidebarSectionExpanded(map: Record): void { + setLocalStorage(APP_SIDEBAR_SECTION_EXPANDED_KEY, map); +} + // --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) --- const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks"; diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 0e9604db6..9babab2af 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -16,7 +16,6 @@ import { RIGHT_BOTTOM_GROUP, TERMINAL_DEFAULT_ID, getPresetLayout, - getPresetSidebarColumn, applyLayout, getRootSplitview, fromDockviewApi, @@ -288,38 +287,11 @@ function removeRightPanelTabs(state: LayoutState): LayoutState { function buildVisibilityActions(set: StoreSet, get: StoreGet) { return { + // Legacy dockview-embedded sidebar is gone after the unified AppSidebar + // landed; the keybinding redirects to the AppSidebar toggle elsewhere. + // We keep these on the store as no-ops so any stragglers compile cleanly. toggleSidebar: () => { - const { api, sidebarVisible } = get(); - if (!api) return; - const liveWidths = captureLiveWidths(api, set); - preserveChatScrollDuringLayout(); - const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); - if (sidebarVisible) { - const current = fromDockviewApi(api); - const withoutSidebar: LayoutState = { - columns: current.columns.filter((c) => c.id !== "sidebar"), - }; - set({ isRestoringLayout: true, sidebarVisible: false }); - applyLayoutAndSet(api, withoutSidebar, liveWidths, set); - requestAnimationFrame(() => { - api.layout(safeWidth, safeHeight); - syncPinnedWidthsFromApi(api, set); - set({ isRestoringLayout: false }); - }); - } else { - const current = fromDockviewApi(api); - const sidebarCol = getPresetSidebarColumn(get().defaultPreset); - const withSidebar: LayoutState = { - columns: [sidebarCol, ...current.columns], - }; - set({ isRestoringLayout: true, sidebarVisible: true }); - applyLayoutAndSet(api, withSidebar, liveWidths, set); - requestAnimationFrame(() => { - api.layout(safeWidth, safeHeight); - syncPinnedWidthsFromApi(api, set); - set({ isRestoringLayout: false }); - }); - } + /* moved to UI slice: toggleAppSidebar */ }, toggleRightPanels: () => { const { api, rightPanelsVisible, defaultPreset } = get(); @@ -361,10 +333,8 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { } }, - setSidebarVisible: (visible: boolean) => { - const { sidebarVisible } = get(); - if (sidebarVisible === visible) return; - get().toggleSidebar(); + setSidebarVisible: (_visible: boolean) => { + /* moved to UI slice: setAppSidebarCollapsed */ }, setRightPanelsVisible: (visible: boolean) => { const { rightPanelsVisible } = get(); @@ -783,8 +753,11 @@ export const useDockviewStore = create((set, get) => ({ centerGroupId: CENTER_GROUP, rightTopGroupId: RIGHT_TOP_GROUP, rightBottomGroupId: RIGHT_BOTTOM_GROUP, + // Legacy fields preserved for backwards compatibility with code that still + // reads them; the embedded dockview sidebar pane was removed in favour of + // the unified AppSidebar. Treated as inert: sidebarVisible is always false. sidebarGroupId: SIDEBAR_GROUP, - sidebarVisible: true, + sidebarVisible: false, rightPanelsVisible: true, pinnedWidths: new Map(), setPinnedWidth: (columnId, width) => { diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index 2e4de3388..e589689e2 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -23,7 +23,6 @@ export const SIDEBAR_LOCK = "no-drop-target" as const; /** Fixed panel IDs that can be saved in layout configs. */ export const KNOWN_PANEL_IDS = new Set([ - "sidebar", "chat", "plan", TERMINAL_DEFAULT_ID, @@ -37,7 +36,6 @@ export const KNOWN_PANEL_IDS = new Set([ /** Components whose panels are structural and should survive filterEphemeral, * even when the panel ID is dynamically generated. */ export const STRUCTURAL_COMPONENTS = new Set([ - "sidebar", "chat", "plan", "changes", @@ -50,7 +48,6 @@ export const STRUCTURAL_COMPONENTS = new Set([ /** Default panel configurations for known panels. */ export const PANEL_REGISTRY: Record> = { - sidebar: { component: "sidebar", title: "Sidebar" }, chat: { component: "chat", title: "Agent", tabComponent: "permanentTab" }, plan: { component: "plan", title: "Plan", tabComponent: "planTab" }, changes: { component: "changes", title: "Changes", tabComponent: "changesTab" }, diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index 2c153e5c3..0b98e85ac 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -49,7 +49,6 @@ export { planLayout, previewLayout, getPresetLayout, - getPresetSidebarColumn, } from "./presets"; export type { BuiltInPreset } from "./presets"; diff --git a/apps/web/lib/state/layout-manager/presets.test.ts b/apps/web/lib/state/layout-manager/presets.test.ts index aea2254ed..3d6919c42 100644 --- a/apps/web/lib/state/layout-manager/presets.test.ts +++ b/apps/web/lib/state/layout-manager/presets.test.ts @@ -1,32 +1,27 @@ import { describe, expect, it } from "vitest"; -import { compactLayout, defaultLayout, getPresetSidebarColumn } from "./presets"; -import { computeSidebarMaxPx } from "./caps"; +import { compactLayout, defaultLayout, planLayout, previewLayout, vscodeLayout } from "./presets"; describe("layout presets", () => { - it("keeps the compact workbench on Dockview while prioritizing the center panel", () => { - const compact = compactLayout(); - const compactSidebar = compact.columns.find((column) => column.id === "sidebar"); - // Default sidebar inherits the runtime cap (no per-column maxWidth); - // compact pins itself tighter. - const defaultSidebarCap = - defaultLayout().columns.find((column) => column.id === "sidebar")?.maxWidth ?? - computeSidebarMaxPx(); + it("default preset has center + right columns (no embedded sidebar)", () => { + const layout = defaultLayout(); + expect(layout.columns.map((c) => c.id)).toEqual(["center", "right"]); + }); - expect(compact.columns.map((column) => column.id)).toEqual(["sidebar", "center"]); - const compactSidebarWidth = compactSidebar?.width ?? Number.POSITIVE_INFINITY; - const compactSidebarMaxWidth = compactSidebar?.maxWidth ?? Number.POSITIVE_INFINITY; - expect(compactSidebarWidth).toBeLessThan(defaultSidebarCap); - expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarCap); - expect(compact.columns.find((column) => column.id === "center")?.groups[0].panels[0].id).toBe( + it("compact preset is a single center column with everything tabbed", () => { + const layout = compactLayout(); + expect(layout.columns.map((c) => c.id)).toEqual(["center"]); + expect(layout.columns[0].groups[0].panels.map((p) => p.id)).toEqual([ "chat", - ); + "files", + "changes", + "terminal-default", + ]); }); - it("returns compact sidebar sizing for compact preset restoration", () => { - const compactSidebar = compactLayout().columns.find((column) => column.id === "sidebar"); - - expect(getPresetSidebarColumn("compact")).toEqual(compactSidebar); - expect(getPresetSidebarColumn("compact").width).toBe(220); - expect(getPresetSidebarColumn("compact").maxWidth).toBe(260); + it("plan/preview/vscode presets drop the legacy sidebar column", () => { + for (const preset of [planLayout(), previewLayout(), vscodeLayout()]) { + expect(preset.columns.some((c) => c.id === "sidebar")).toBe(false); + expect(preset.columns.some((c) => c.id === "center")).toBe(true); + } }); }); diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts index ba0072202..9c40a6de0 100644 --- a/apps/web/lib/state/layout-manager/presets.ts +++ b/apps/web/lib/state/layout-manager/presets.ts @@ -1,25 +1,9 @@ -import type { LayoutColumn, LayoutState } from "./types"; -import { - SIDEBAR_GROUP, - CENTER_GROUP, - RIGHT_TOP_GROUP, - RIGHT_BOTTOM_GROUP, - panel, -} from "./constants"; - -const COMPACT_SIDEBAR_WIDTH = 220; -// Compact preset intentionally caps the sidebar tight (small toolbar look), -// so it overrides the runtime cap rather than inheriting it. -const COMPACT_SIDEBAR_MAX_PX = 260; +import type { LayoutState } from "./types"; +import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, panel } from "./constants"; export function defaultLayout(): LayoutState { return { columns: [ - { - id: "sidebar", - pinned: true, - groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], - }, { id: "center", groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], @@ -40,13 +24,6 @@ export function defaultLayout(): LayoutState { export function compactLayout(): LayoutState { return { columns: [ - { - id: "sidebar", - pinned: true, - width: COMPACT_SIDEBAR_WIDTH, - maxWidth: COMPACT_SIDEBAR_MAX_PX, - groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], - }, { id: "center", groups: [ @@ -63,11 +40,6 @@ export function compactLayout(): LayoutState { export function planLayout(): LayoutState { return { columns: [ - { - id: "sidebar", - pinned: true, - groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], - }, { id: "center", groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], @@ -83,11 +55,6 @@ export function planLayout(): LayoutState { export function previewLayout(): LayoutState { return { columns: [ - { - id: "sidebar", - pinned: true, - groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], - }, { id: "center", groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], @@ -103,11 +70,6 @@ export function previewLayout(): LayoutState { export function vscodeLayout(): LayoutState { return { columns: [ - { - id: "sidebar", - pinned: true, - groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], - }, { id: "center", groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], @@ -133,10 +95,3 @@ const PRESET_MAP: Record LayoutState> = { export function getPresetLayout(preset: BuiltInPreset): LayoutState { return PRESET_MAP[preset](); } - -export function getPresetSidebarColumn(preset: BuiltInPreset): LayoutColumn { - return ( - getPresetLayout(preset).columns.find((column) => column.id === "sidebar") ?? - defaultLayout().columns[0] - ); -} diff --git a/apps/web/lib/state/slices/ui/app-sidebar-actions.ts b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts new file mode 100644 index 000000000..c2cf5934f --- /dev/null +++ b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts @@ -0,0 +1,50 @@ +import type { StateCreator } from "zustand"; +import { + getStoredAppSidebarCollapsed, + getStoredAppSidebarSectionExpanded, + setStoredAppSidebarCollapsed, + setStoredAppSidebarSectionExpanded, +} from "@/lib/local-storage"; +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. */ +export const DEFAULT_SECTION_EXPANDED: Record = { + tasks: true, + projects: false, + agents: false, + settings: false, +}; + +export function loadAppSidebarState(): AppSidebarState { + return { + collapsed: getStoredAppSidebarCollapsed(false), + sectionExpanded: getStoredAppSidebarSectionExpanded(DEFAULT_SECTION_EXPANDED), + }; +} + +type ImmerSet = Parameters>[0]; + +export function buildAppSidebarActions(set: ImmerSet) { + return { + toggleAppSidebar: () => + set((draft) => { + const next = !draft.appSidebar.collapsed; + draft.appSidebar.collapsed = next; + setStoredAppSidebarCollapsed(next); + }), + setAppSidebarCollapsed: (collapsed: boolean) => + set((draft) => { + if (draft.appSidebar.collapsed === collapsed) return; + draft.appSidebar.collapsed = collapsed; + setStoredAppSidebarCollapsed(collapsed); + }), + toggleAppSidebarSection: (sectionId: string) => + set((draft) => { + const current = draft.appSidebar.sectionExpanded[sectionId] ?? false; + draft.appSidebar.sectionExpanded[sectionId] = !current; + setStoredAppSidebarSectionExpanded({ ...draft.appSidebar.sectionExpanded }); + }), + }; +} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 9a74e8ea8..8b709d7fc 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -115,6 +115,13 @@ export type SidebarTaskPrefsState = { subtaskOrderByParentId: Record; }; +/** Unified AppSidebar collapse + per-section expand state (localStorage). */ +export type AppSidebarState = { + collapsed: boolean; + /** Keyed by section id: "tasks", "projects", "agents", "settings". */ + sectionExpanded: Record; +}; + export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; @@ -136,6 +143,8 @@ export type UISliceState = { kanbanPreviewedTaskId: string | null; /** Sidebar pin + manual-order. Per-browser, persisted to localStorage. */ sidebarTaskPrefs: SidebarTaskPrefsState; + /** Unified AppSidebar collapse + section expand state (localStorage). */ + appSidebar: AppSidebarState; }; export type UISliceActions = { @@ -199,6 +208,9 @@ export type UISliceActions = { * deleted ID back. */ removeTaskFromSidebarPrefs: (taskId: string) => void; + toggleAppSidebar: () => void; + setAppSidebarCollapsed: (collapsed: boolean) => void; + toggleAppSidebarSection: (sectionId: string) => void; }; export type { SidebarView, SidebarViewDraft }; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index 4b379572e..4c9b56bf3 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -213,6 +213,57 @@ describe("setSubtaskOrder", () => { }); }); +describe("appSidebar actions", () => { + const COLLAPSED_KEY = "kandev.appSidebar.collapsed"; + const SECTION_KEY = "kandev.appSidebar.sectionExpanded"; + + beforeEach(() => { + window.localStorage.clear(); + }); + + it("hydrates default state when localStorage is empty", () => { + const store = makeStore(); + expect(store.getState().appSidebar.collapsed).toBe(false); + expect(store.getState().appSidebar.sectionExpanded.tasks).toBe(true); + expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false); + }); + + it("hydrates collapsed flag from localStorage", () => { + window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(true)); + const store = makeStore(); + expect(store.getState().appSidebar.collapsed).toBe(true); + }); + + it("toggleAppSidebar flips the collapsed flag and persists it", () => { + const store = makeStore(); + store.getState().toggleAppSidebar(); + expect(store.getState().appSidebar.collapsed).toBe(true); + expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(true); + + store.getState().toggleAppSidebar(); + expect(store.getState().appSidebar.collapsed).toBe(false); + expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(false); + }); + + it("setAppSidebarCollapsed writes the requested value and persists it", () => { + const store = makeStore(); + store.getState().setAppSidebarCollapsed(true); + expect(store.getState().appSidebar.collapsed).toBe(true); + expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(true); + }); + + 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); + const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}"); + expect(persisted.projects).toBe(true); + + store.getState().toggleAppSidebarSection("projects"); + expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false); + }); +}); + describe("reorderSidebarViews", () => { beforeEach(() => { window.localStorage.clear(); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index a4bb47621..32433e2ae 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -16,6 +16,11 @@ import { setStoredSidebarDraft, setStoredSidebarUserViews, } from "@/lib/local-storage"; +import { + DEFAULT_SECTION_EXPANDED, + buildAppSidebarActions, + loadAppSidebarState, +} from "./app-sidebar-actions"; import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions"; import { DEFAULT_ACTIVE_VIEW_ID, DEFAULT_VIEW } from "./sidebar-view-builtins"; import type { @@ -122,6 +127,7 @@ export const defaultUIState: UISliceState = { collapsedSubtaskParents: [], kanbanPreviewedTaskId: null, sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + appSidebar: { collapsed: false, sectionExpanded: { ...DEFAULT_SECTION_EXPANDED } }, }; type ImmerSet = Parameters[0]; @@ -528,6 +534,8 @@ export const createUISlice: StateCreator) => void; @@ -476,6 +477,9 @@ export type AppState = { setSidebarTaskOrder: UIA["setSidebarTaskOrder"]; setSubtaskOrder: UIA["setSubtaskOrder"]; removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"]; + toggleAppSidebar: UIA["toggleAppSidebar"]; + setAppSidebarCollapsed: UIA["setAppSidebarCollapsed"]; + toggleAppSidebarSection: UIA["toggleAppSidebarSection"]; // Office actions setOfficeAgentProfiles: (agents: AgentProfile[]) => void; addOfficeAgentProfile: (agent: AgentProfile) => void; From 614c900746097866059ef6e7a20b9967a622a9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 27 May 2026 23:38:19 +0100 Subject: [PATCH 02/20] test(web): point workflow-filter e2e at AppSidebar's Home link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dockview top bar no longer renders the IconHome breadcrumb after the unified sidebar landed — the home navigation now lives in AppSidebar's primary nav. Update the workflow-filter spec to click that link instead. --- apps/web/e2e/tests/kanban/workflow-filter.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts index b53d3c0e0..b5bd3b957 100644 --- a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts +++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts @@ -144,8 +144,8 @@ test.describe("Kanban workflow filter", () => { await testPage.goto(`/t/${betaTaskId}`); await expect(testPage).toHaveURL(new RegExp(`/t/${betaTaskId}`)); - // Breadcrumb = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug. - await testPage.getByTestId("task-breadcrumb-home").click(); + // AppSidebar Home link = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug. + await testPage.getByTestId("app-sidebar").getByRole("link", { name: "Home" }).click(); await expect(testPage).toHaveURL(/\/$|\?/); await expect(kanban.board).toBeVisible(); From 2e24f2b0248a3dd3fa7ff4de47026c76e8d08a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 00:33:13 +0100 Subject: [PATCH 03/20] feat(web): wire app-sidebar primary controls, nested settings, and footer migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces the gradient workspace picker with a minimal trigger when office is off, and moves Stats/Improve/What's-New/Office out of the kanban top bar into the AppSidebar footer + a dedicated Integrations section. Expands Settings into a collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages can drop their nested SidebarProvider. --- .../app-sidebar/app-sidebar-constants.ts | 1 + .../app-sidebar/app-sidebar-footer.tsx | 132 +++++- .../app-sidebar/app-sidebar-primary-nav.tsx | 39 +- .../app-sidebar/app-sidebar-section.tsx | 23 +- .../app-sidebar-workspace-picker.tsx | 106 +++-- .../components/app-sidebar/app-sidebar.tsx | 16 +- .../app-sidebar/sections/agents-section.tsx | 8 +- .../sections/integrations-section.tsx | 79 ++++ .../app-sidebar/sections/projects-section.tsx | 4 + .../app-sidebar/sections/settings-section.tsx | 98 ++-- .../sections/settings/agents-group.tsx | 45 ++ .../sections/settings/executors-group.tsx | 45 ++ .../sections/settings/general-group.tsx | 41 ++ .../sections/settings/integrations-group.tsx | 50 ++ .../settings/settings-nav-primitives.tsx | 118 +++++ .../sections/settings/system-group.tsx | 55 +++ .../sections/settings/workspaces-group.tsx | 57 +++ .../app-sidebar/sections/tasks-section.tsx | 20 +- .../kanban/kanban-header-mobile.tsx | 6 - apps/web/components/kanban/kanban-header.tsx | 167 +------ .../components/kanban/mobile-menu-sheet.tsx | 71 +-- .../settings/settings-app-sidebar.tsx | 433 ------------------ .../settings/settings-layout-client.tsx | 26 +- .../state/slices/ui/app-sidebar-actions.ts | 1 + 24 files changed, 850 insertions(+), 791 deletions(-) create mode 100644 apps/web/components/app-sidebar/sections/integrations-section.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/agents-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/executors-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/general-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/integrations-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/system-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx delete mode 100644 apps/web/components/settings/settings-app-sidebar.tsx diff --git a/apps/web/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts index cb433b245..0b75a96fc 100644 --- a/apps/web/components/app-sidebar/app-sidebar-constants.ts +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -3,6 +3,7 @@ export const APP_SIDEBAR_SECTION_IDS = { tasks: "tasks", projects: "projects", agents: "agents", + integrations: "integrations", settings: "settings", } as const; diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 727c166cb..40a659040 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -1,35 +1,141 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; -import { IconSettings } from "@tabler/icons-react"; +import { useRouter } from "next/navigation"; +import { IconBuildings, IconChartBar, 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; +}; + +function FooterIconButton({ + icon: Icon, + label, + collapsed, + onClick, + href, + badge, + testId, +}: 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 officeEnabled = useFeature("office"); + const releaseNotes = useReleaseNotes(); + const [improveOpen, setImproveOpen] = useState(false); + return (
- - - - - - - Settings - + + 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-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index efbebc55e..3d4017223 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { IconHome, IconInbox, IconMessageCircle, IconSquarePlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { NewTaskDialog } from "@/app/office/components/new-task-dialog"; import { AppSidebarNavItem } from "./app-sidebar-nav-item"; @@ -14,19 +15,22 @@ type AppSidebarPrimaryNavProps = { export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); const inboxCount = useAppStore((s) => s.office.inboxCount); + const officeEnabled = useFeature("office"); const handleOpenQuickChat = useQuickChatLauncher(workspaceId); const [newTaskOpen, setNewTaskOpen] = useState(false); return (
- + {officeEnabled && ( + + )} {workspaceId && ( )} - setNewTaskOpen(true)} - collapsed={collapsed} - /> - + {/* Kanban-mode New Task is reachable from the kanban top bar; the office + NewTaskDialog requires an office workspace context, so we only mount + the AppSidebar shortcut when office is enabled. */} + {officeEnabled && ( + <> + setNewTaskOpen(true)} + collapsed={collapsed} + /> + + + )}
); } diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index 8b39084cc..9c1619742 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -15,6 +15,11 @@ type AppSidebarSectionProps = { children: React.ReactNode; /** Optional right-aligned action shown in the header when expanded. */ headerAction?: React.ReactNode; + /** + * When true and expanded, the section wrapper + body get `flex-1 min-h-0` so + * it fills remaining sidebar height. Parent must be a flex column. + */ + grow?: boolean; }; /** @@ -31,6 +36,7 @@ export function AppSidebarSection({ icon: Icon, children, headerAction, + grow, }: AppSidebarSectionProps) { const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? false); const toggleSection = useAppStore((s) => s.toggleAppSidebarSection); @@ -57,9 +63,11 @@ export function AppSidebarSection({ ); } + const growExpanded = grow && expanded; + return ( -
-
+
+
{headerAction}
- {expanded &&
{children}
} + {expanded && ( +
+ {children} +
+ )}
); } diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index 687439116..af74b7ace 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; -import { IconCheck, IconPlus } from "@tabler/icons-react"; +import { IconCheck, IconChevronDown, IconFolder, IconPlus } from "@tabler/icons-react"; import { DropdownMenu, DropdownMenuContent, @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { cn } from "@/lib/utils"; import { getWorkspaceGradient, getWorkspaceInitials } from "./workspace-gradient"; @@ -18,8 +19,66 @@ type AppSidebarWorkspacePickerProps = { collapsed: boolean; }; +type TriggerProps = { + collapsed: boolean; + activeId: string | null; + activeName: string; +}; + +function OfficeTrigger({ collapsed, activeId, activeName }: TriggerProps) { + return ( + + ); +} + +function MinimalTrigger({ collapsed, activeName }: TriggerProps) { + 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); @@ -32,37 +91,20 @@ export function AppSidebarWorkspacePicker({ collapsed }: AppSidebarWorkspacePick (id: string) => { document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; setActiveWorkspace(id); - router.push(`/office?workspaceId=${id}`); + if (officeEnabled) { + router.push(`/office?workspaceId=${id}`); + } setOpen(false); }, - [router, setActiveWorkspace], + [router, setActiveWorkspace, officeEnabled], ); + const triggerProps: TriggerProps = { collapsed, activeId, activeName }; + return ( - + {officeEnabled ? : } {workspaces.items.length === 0 ? ( @@ -74,12 +116,14 @@ export function AppSidebarWorkspacePicker({ collapsed }: AppSidebarWorkspacePick onClick={() => handleSelect(ws.id)} className="cursor-pointer gap-2" > - - {getWorkspaceInitials(ws.name)} - + {officeEnabled && ( + + {getWorkspaceInitials(ws.name)} + + )} {ws.name} {ws.id === activeId && } diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 10bf0ab33..af4497f22 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -13,6 +13,7 @@ import { AppSidebarFooter } from "./app-sidebar-footer"; import { AppSidebarHeader } from "./app-sidebar-header"; import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav"; import { AgentsSection } from "./sections/agents-section"; +import { IntegrationsSection } from "./sections/integrations-section"; import { ProjectsSection } from "./sections/projects-section"; import { SettingsSection } from "./sections/settings-section"; import { TasksSection } from "./sections/tasks-section"; @@ -67,12 +68,17 @@ export function AppSidebar() { }} > - + {!collapsed && } ); } diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index 964362741..f79e9e8a2 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -80,16 +80,6 @@ vi.mock("@/components/task/quick-chat-button", () => ({ QuickChatButton: () => null, })); -vi.mock("@/components/task/topbar-action-overflow", () => ({ - TopbarActionOverflow: ({ items }: { items: Array<{ id: string; content: React.ReactNode }> }) => ( -
- {items.map((item) => ( -
{item.content}
- ))} -
- ), -})); - vi.mock("@/components/integrations/integrations-menu", () => ({ IntegrationsMenu: () => null, })); diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index d6fa5280d..ddfc6f51a 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -2,16 +2,9 @@ import { memo, type ReactNode } from "react"; import Link from "next/link"; -import { IconBug, IconDots } from "@tabler/icons-react"; +import { IconBug } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { EditorsMenu } from "@/components/task/editors-menu"; import { LayoutPresetSelector } from "@/components/task/layout-preset-selector"; @@ -27,10 +20,6 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { ExecutorSettingsButton } from "@/components/task/executor-settings-button"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; -import { - TopbarActionOverflow, - type TopbarOverflowItem, -} from "@/components/task/topbar-action-overflow"; import { DEBUG_UI } from "@/lib/config"; type TaskTopBarProps = { @@ -204,42 +193,29 @@ function TopbarCluster({ ); } -function MoreToolsMenu({ +function DebugOverlayToggle({ showDebugOverlay, onToggleDebugOverlay, }: { showDebugOverlay?: boolean; - onToggleDebugOverlay?: () => void; + onToggleDebugOverlay: () => void; }) { - const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"; - + const label = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"; return ( - - - - - - - - More tools - - - More tools - {onToggleDebugOverlay && ( - - - {debugLabel} - - )} - - + + + + + {label} + ); } @@ -293,7 +269,7 @@ function TopbarToolsGroup({ onToggleDebugOverlay?: () => void; isArchived?: boolean; }) { - const showDebugMenu = DEBUG_UI && onToggleDebugOverlay; + const showDebugToggle = DEBUG_UI && onToggleDebugOverlay; return ( @@ -303,8 +279,8 @@ function TopbarToolsGroup({ )} - {showDebugMenu && ( - @@ -313,7 +289,9 @@ function TopbarToolsGroup({ ); } -/** Right section: status/attention, tools menu */ +/** Right section: status/attention + tools rendered inline. + * The former overflow popover was removed in the UI overhaul — every cluster + * is always visible so users don't have to discover the dots menu. */ function TopBarRight({ taskId, activeSessionId, @@ -337,28 +315,15 @@ function TopBarRight({ taskTitle?: string; officeTaskHref?: string | null; }) { - const items: TopbarOverflowItem[] = []; - - if (officeTaskHref) { - items.push({ - id: "office-view", - label: "Open in office view", - priority: 90, - content: ( + return ( +
+ {officeTaskHref && ( - ), - }); - } - - items.push({ - id: "attention", - label: "Task status and attention", - priority: 80, - content: ( + )} - ), - }); - - items.push({ - id: "tools", - label: "Task tools", - priority: 10, - content: ( - ), - }); - - return ( - +
); } diff --git a/apps/web/components/task/topbar-action-overflow.test.ts b/apps/web/components/task/topbar-action-overflow.test.ts deleted file mode 100644 index d0436853a..000000000 --- a/apps/web/components/task/topbar-action-overflow.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getHiddenTopbarActionIds } from "./topbar-action-overflow"; - -const QUICK_CHAT = "quick-chat"; -const ATTENTION = "attention"; -const TOOLS = "tools"; - -const items = [ - { id: QUICK_CHAT, priority: 20 }, - { id: ATTENTION, priority: 80 }, - { id: TOOLS, priority: 10 }, -]; - -const widths = new Map([ - [QUICK_CHAT, 88], - [ATTENTION, 160], - [TOOLS, 120], -]); - -describe("getHiddenTopbarActionIds", () => { - it("keeps every action visible when there is enough width", () => { - expect( - getHiddenTopbarActionIds({ - items, - availableWidth: 600, - itemWidths: widths, - gap: 8, - overflowTriggerWidth: 40, - }), - ).toEqual([]); - }); - - it("hides low-priority tools before quick chat or contextual actions", () => { - expect( - getHiddenTopbarActionIds({ - items, - availableWidth: 320, - itemWidths: widths, - gap: 8, - overflowTriggerWidth: 40, - }), - ).toEqual([TOOLS]); - }); - - it("hides quick chat before contextual attention actions", () => { - expect( - getHiddenTopbarActionIds({ - items, - availableWidth: 230, - itemWidths: widths, - gap: 8, - overflowTriggerWidth: 40, - }), - ).toEqual([QUICK_CHAT, TOOLS]); - }); -}); diff --git a/apps/web/components/task/topbar-action-overflow.tsx b/apps/web/components/task/topbar-action-overflow.tsx deleted file mode 100644 index 52de62e6e..000000000 --- a/apps/web/components/task/topbar-action-overflow.tsx +++ /dev/null @@ -1,253 +0,0 @@ -"use client"; - -import { useCallback, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from "react"; -import { IconDots } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { cn } from "@kandev/ui/lib/utils"; - -export type TopbarOverflowItem = { - id: string; - label: string; - priority: number; - content: ReactNode; -}; - -type TopbarOverflowMetricItem = Pick; - -type HiddenTopbarActionArgs = { - items: TopbarOverflowMetricItem[]; - availableWidth: number; - itemWidths: ReadonlyMap; - gap: number; - overflowTriggerWidth: number; - fallbackItemWidth?: number; -}; - -const DEFAULT_FALLBACK_ITEM_WIDTH = 88; -const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40; - -function setsMatch(set: Set, values: string[]) { - if (set.size !== values.length) return false; - return values.every((value) => set.has(value)); -} - -function itemWidth( - item: TopbarOverflowMetricItem, - widths: ReadonlyMap, - fallback: number, -) { - return widths.get(item.id) ?? fallback; -} - -function totalActionWidth({ - items, - hiddenIds, - itemWidths, - gap, - overflowTriggerWidth, - fallbackItemWidth, -}: { - items: TopbarOverflowMetricItem[]; - hiddenIds: Set; - itemWidths: ReadonlyMap; - gap: number; - overflowTriggerWidth: number; - fallbackItemWidth: number; -}) { - const visibleItems = items.filter((item) => !hiddenIds.has(item.id)); - const visibleWidth = visibleItems.reduce( - (total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth), - 0, - ); - const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0); - const gapWidth = Math.max(0, controlCount - 1) * gap; - - return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth; -} - -export function getHiddenTopbarActionIds({ - items, - availableWidth, - itemWidths, - gap, - overflowTriggerWidth, - fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH, -}: HiddenTopbarActionArgs): string[] { - const hiddenIds = new Set(); - const hideableItems = [...items].sort((a, b) => a.priority - b.priority); - - while ( - totalActionWidth({ - items, - hiddenIds, - itemWidths, - gap, - overflowTriggerWidth, - fallbackItemWidth, - }) > availableWidth - ) { - const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id)); - if (!nextItem) break; - hiddenIds.add(nextItem.id); - } - - return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id); -} - -function readFlexGap(element: HTMLElement) { - const styles = window.getComputedStyle(element); - const gap = Number.parseFloat(styles.columnGap || styles.gap || "0"); - return Number.isFinite(gap) ? gap : 0; -} - -type TopbarActionOverflowProps = { - items: TopbarOverflowItem[]; - className?: string; -}; - -function measureVisibleItems( - itemRefs: Map, - itemWidths: Map, -) { - for (const [id, element] of itemRefs) { - const width = Math.ceil(element.getBoundingClientRect().width); - if (width > 0) itemWidths.set(id, width); - } -} - -function useTopbarOverflowState(items: TopbarOverflowItem[]) { - const containerRef = useRef(null); - const overflowTriggerRef = useRef(null); - const itemRefs = useRef(new Map()); - const itemWidths = useRef(new Map()); - const [hiddenIds, setHiddenIds] = useState>(() => new Set()); - const [measuredWidth, setMeasuredWidth] = useState(0); - - const registerItem = useCallback( - (id: string) => (element: HTMLDivElement | null) => { - if (element) { - itemRefs.current.set(id, element); - } else { - itemRefs.current.delete(id); - } - }, - [], - ); - - useLayoutEffect(() => { - const element = containerRef.current; - if (!element) return; - - const updateWidth = () => setMeasuredWidth(element.clientWidth); - updateWidth(); - - const observer = new ResizeObserver(updateWidth); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container || container.clientWidth <= 0) return; - - measureVisibleItems(itemRefs.current, itemWidths.current); - - const overflowWidth = - overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH; - const nextHiddenIds = getHiddenTopbarActionIds({ - items, - availableWidth: container.clientWidth, - itemWidths: itemWidths.current, - gap: readFlexGap(container), - overflowTriggerWidth: overflowWidth, - }); - - setHiddenIds((current) => - setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds), - ); - }, [items, measuredWidth]); - - return { containerRef, overflowTriggerRef, registerItem, hiddenIds }; -} - -function OverflowTrigger({ triggerRef }: { triggerRef: Ref }) { - return ( - - - - - - - More actions - - ); -} - -function OverflowPopover({ - items, - triggerRef, -}: { - items: TopbarOverflowItem[]; - triggerRef: Ref; -}) { - if (items.length === 0) return null; - - return ( - - - -
- {items.map((item) => ( -
- {item.content} -
- ))} -
-
-
- ); -} - -export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) { - const { containerRef, overflowTriggerRef, registerItem, hiddenIds } = - useTopbarOverflowState(items); - const hiddenItems = items.filter((item) => hiddenIds.has(item.id)); - - return ( -
- {items.map((item) => - hiddenIds.has(item.id) ? null : ( -
- {item.content} -
- ), - )} - -
- ); -} diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 980e4ede5..ebe222662 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -788,6 +788,18 @@ export function setStoredAppSidebarSectionExpanded(map: Record) setLocalStorage(APP_SIDEBAR_SECTION_EXPANDED_KEY, map); } +const APP_SIDEBAR_WIDTH_KEY = "kandev.appSidebar.width"; + +export function getStoredAppSidebarWidth(fallback: number): number { + const raw = getLocalStorage(APP_SIDEBAR_WIDTH_KEY, fallback) as unknown; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return fallback; + return raw; +} + +export function setStoredAppSidebarWidth(width: number): void { + setLocalStorage(APP_SIDEBAR_WIDTH_KEY, width); +} + // --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) --- const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks"; diff --git a/apps/web/lib/state/slices/ui/app-sidebar-actions.ts b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts index 61898a28d..20f5dd1d4 100644 --- a/apps/web/lib/state/slices/ui/app-sidebar-actions.ts +++ b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts @@ -2,9 +2,12 @@ import type { StateCreator } from "zustand"; import { getStoredAppSidebarCollapsed, getStoredAppSidebarSectionExpanded, + getStoredAppSidebarWidth, setStoredAppSidebarCollapsed, setStoredAppSidebarSectionExpanded, + setStoredAppSidebarWidth, } from "@/lib/local-storage"; +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 @@ -22,6 +25,7 @@ export function loadAppSidebarState(): AppSidebarState { return { collapsed: getStoredAppSidebarCollapsed(false), sectionExpanded: getStoredAppSidebarSectionExpanded(DEFAULT_SECTION_EXPANDED), + width: getStoredAppSidebarWidth(APP_SIDEBAR_EXPANDED_WIDTH), }; } @@ -47,5 +51,11 @@ export function buildAppSidebarActions(set: ImmerSet) { draft.appSidebar.sectionExpanded[sectionId] = !current; setStoredAppSidebarSectionExpanded({ ...draft.appSidebar.sectionExpanded }); }), + setAppSidebarWidth: (width: number) => + set((draft) => { + if (draft.appSidebar.width === width) return; + draft.appSidebar.width = width; + setStoredAppSidebarWidth(width); + }), }; } diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 8b709d7fc..23528d293 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -120,6 +120,8 @@ export type AppSidebarState = { collapsed: boolean; /** Keyed by section id: "tasks", "projects", "agents", "settings". */ sectionExpanded: Record; + /** User-resized expanded width in pixels. */ + width: number; }; export type UISliceState = { @@ -211,6 +213,7 @@ export type UISliceActions = { toggleAppSidebar: () => void; setAppSidebarCollapsed: (collapsed: boolean) => void; toggleAppSidebarSection: (sectionId: string) => void; + setAppSidebarWidth: (width: number) => void; }; export type { SidebarView, SidebarViewDraft }; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 32433e2ae..52654041b 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -127,7 +127,11 @@ export const defaultUIState: UISliceState = { collapsedSubtaskParents: [], kanbanPreviewedTaskId: null, sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, - appSidebar: { collapsed: false, sectionExpanded: { ...DEFAULT_SECTION_EXPANDED } }, + appSidebar: { + collapsed: false, + sectionExpanded: { ...DEFAULT_SECTION_EXPANDED }, + width: 240, + }, }; type ImmerSet = Parameters[0]; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 648b73f01..5204e14d9 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -480,6 +480,7 @@ export type AppState = { toggleAppSidebar: UIA["toggleAppSidebar"]; setAppSidebarCollapsed: UIA["setAppSidebarCollapsed"]; toggleAppSidebarSection: UIA["toggleAppSidebarSection"]; + setAppSidebarWidth: UIA["setAppSidebarWidth"]; // Office actions setOfficeAgentProfiles: (agents: AgentProfile[]) => void; addOfficeAgentProfile: (agent: AgentProfile) => void; From 660421531ad925f5aaf14295ac1f9c2daa99bad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 01:16:23 +0100 Subject: [PATCH 05/20] feat(web): polish app-sidebar headers and consolidate task views Section headers now lead with a clean uppercase label, drop the mono clash, lift contrast from /60 to /70, and end with the collapse chevron at the right so the section body aligns flush under the label. The Tasks section's filter bar (view chips + gear in a card-tinted strip) is replaced by a compact view dropdown that lives in the section header. Drag-to-reorder lives in the existing filter popover, which is now reachable from the same gear button. Settings moves below Tasks so the kanban list keeps the flex-grow seat in the sidebar. --- .../app-sidebar/app-sidebar-section.tsx | 46 +++++----- .../components/app-sidebar/app-sidebar.tsx | 8 +- .../app-sidebar/sections/tasks-section.tsx | 21 ++--- .../sections/tasks-view-picker.tsx | 91 +++++++++++++++++++ .../components/task/task-session-sidebar.tsx | 5 +- 5 files changed, 130 insertions(+), 41 deletions(-) create mode 100644 apps/web/components/app-sidebar/sections/tasks-view-picker.tsx diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index 9c1619742..10a1da4f6 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -10,25 +10,14 @@ type AppSidebarSectionProps = { id: string; label: string; collapsed: boolean; - /** Icon used as the collapsed-mode label. */ icon: TablerIcon; children: React.ReactNode; - /** Optional right-aligned action shown in the header when expanded. */ + /** Optional control rendered between the label and the collapse chevron. */ headerAction?: React.ReactNode; - /** - * When true and expanded, the section wrapper + body get `flex-1 min-h-0` so - * it fills remaining sidebar height. Parent must be a flex column. - */ + /** Fills remaining sidebar height when expanded. Parent must be a flex column. */ grow?: boolean; }; -/** - * Reusable collapsible section primitive for the AppSidebar. - * - * Reads/writes per-section expanded state via the store. When the sidebar is - * fully collapsed (icon-rail mode) we render the icon as a tooltip target and - * clicking it expands the sidebar AND the section. - */ export function AppSidebarSection({ id, label, @@ -64,26 +53,35 @@ export function AppSidebarSection({ } const growExpanded = grow && expanded; + const handleToggle = () => toggleSection(id); return (
-
+
- {headerAction} + {expanded && headerAction && ( +
{headerAction}
+ )} +
{expanded && (
-
- {/* Kanban is the bottom-most flex-grow section so it absorbs all - remaining vertical space and scrolls internally. */} + {/* Tasks is the flex-grow middle section so it absorbs remaining + vertical space and scrolls internally; Settings sits below it. */} +
+ +
{!collapsed && } diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx index 3d61c3f2c..aadf60630 100644 --- a/apps/web/components/app-sidebar/sections/tasks-section.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -1,36 +1,31 @@ "use client"; -import { IconLayoutKanban } from "@tabler/icons-react"; +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 KanbanSectionProps = { +type TasksSectionProps = { collapsed: boolean; }; -/** - * Workspace kanban task list embedded as the bottom-most AppSidebar section. - * The wrapper resets the embedded panel's card chrome (background) so it - * visually integrates with the AppSidebar instead of looking transplanted from - * its old dockview pane. The container flex-grows to fill the remaining - * sidebar height; AppSidebar gives it `flex-1 min-h-0`. - */ -export function TasksSection({ collapsed }: KanbanSectionProps) { +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/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index ed29466d5..1afbfb0c0 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -176,6 +176,8 @@ function toSidebarItem( type TaskSessionSidebarProps = { workspaceId: string | null; workflowId: string | null; + /** Hide the embedded filter bar when the host surface (e.g. AppSidebar) renders its own. */ + hideFilterBar?: boolean; }; type SidebarItem = Omit, "workflowId"> & { workflowId?: string }; @@ -542,6 +544,7 @@ function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) { export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, + hideFilterBar, }: TaskSessionSidebarProps) { const store = useAppStoreApi(); useRepositories(workspaceId); @@ -586,7 +589,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs; return ( - + {!hideFilterBar && } Date: Thu, 28 May 2026 01:19:05 +0100 Subject: [PATCH 06/20] feat(web): move settings group chevrons to the right --- .../settings/settings-nav-primitives.tsx | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx index 5ac8dca7d..9c211f587 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -27,7 +27,6 @@ function clampDepth(depth: number, max: number): number { return depth; } -/** A clickable link row inside the Settings tree. */ export function SettingsLeaf({ href, label, icon: Icon, isActive, depth = 0 }: SettingsLeafProps) { return ( setExpanded((v) => !v); + + const labelInner = ( + <> + {Icon && } + {label} + + ); return (
@@ -83,34 +83,33 @@ export function SettingsGroup({ paddingClass, )} > - {href ? ( - {Icon && } - {label} + {labelInner} ) : ( )} +
{expanded &&
{children}
}
From 8ed3cce3299f2c9540ff8ae8e148b0ae8f42b318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 01:29:35 +0100 Subject: [PATCH 07/20] fix(web): drop chat from home topbar; calm tasks group headers The kanban top bar's Quick Chat button is removed now that Quick Chat lives in the AppSidebar; workspaceId stops being threaded into the tablet and desktop header variants. Group headers inside the Tasks section (kanban workflow steps) lose the opaque background strip and the section-label uppercase styling so they read as inline subgroup rows, not duplicate section dividers. The tasks-section wrapper drops its negative margin so the group rows sit flush under the TASKS label instead of bleeding out past it. --- .../app-sidebar/sections/tasks-section.tsx | 2 +- apps/web/components/kanban/kanban-header.tsx | 7 ------- apps/web/components/task/task-switcher.tsx | 12 ++++++------ 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx index aadf60630..06da59ade 100644 --- a/apps/web/components/app-sidebar/sections/tasks-section.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -24,7 +24,7 @@ export function TasksSection({ collapsed }: TasksSectionProps) { headerAction={} grow > -
+
diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index de2b13bd4..39a3671d6 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -11,7 +11,6 @@ import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; 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 { linkToTasks } from "@/lib/links"; @@ -129,7 +128,6 @@ function useIsHeaderNarrow(ref: RefObject): boolean { function TabletHeader({ onCreateTask, - workspaceId, title, workspaceLabel, searchQuery, @@ -142,7 +140,6 @@ function TabletHeader({ onOpenHealthDialog, }: { onCreateTask: () => void; - workspaceId?: string; title: string; workspaceLabel: string; searchQuery: string; @@ -183,7 +180,6 @@ function TabletHeader({ Add task - @@ -240,7 +236,6 @@ function CreateTaskTopbarButton({ function DesktopHeader({ onCreateTask, - workspaceId, title, workspaceLabel, searchQuery, @@ -252,7 +247,6 @@ function DesktopHeader({ onOpenHealthDialog, }: { onCreateTask: () => void; - workspaceId?: string; title: string; workspaceLabel: string; searchQuery: string; @@ -293,7 +287,6 @@ function DesktopHeader({ <> {actionsSearch} - diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index abbedc16f..a1c7bf058 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -116,18 +116,18 @@ function GroupHeader({ data-testid="sidebar-group-header" data-group-key={groupKey} data-group-label={label} - className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]" + className="group/group-header flex w-full items-center gap-1.5 px-2 py-1 cursor-pointer rounded-sm hover:bg-muted/40 transition-colors" > - - {label} - - {count} + + {label} + + {count} ); } From 77d5a2e5502606aa9c1409806a4e48c502696c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 01:32:05 +0100 Subject: [PATCH 08/20] fix(web): restore agent logos on settings tree profile leaves SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents group passes so each profile row shows its agent's logo like it did before the old SettingsAppSidebar was retired. --- .../app-sidebar/sections/settings/agents-group.tsx | 2 ++ .../sections/settings/settings-nav-primitives.tsx | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx index 5a5ce060d..29bc77322 100644 --- a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx @@ -1,6 +1,7 @@ "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"; @@ -34,6 +35,7 @@ export function AgentsGroup({ pathname }: AgentsGroupProps) { key={profile.id} href={profilePath} label={`${agentLabel} • ${profile.name}`} + leadingIcon={} isActive={pathname === profilePath} depth={1} /> diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx index 9c211f587..786920069 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -13,6 +13,8 @@ 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; @@ -27,7 +29,14 @@ function clampDepth(depth: number, max: number): number { return depth; } -export function SettingsLeaf({ href, label, icon: Icon, isActive, depth = 0 }: SettingsLeafProps) { +export function SettingsLeaf({ + href, + label, + icon: Icon, + leadingIcon, + isActive, + depth = 0, +}: SettingsLeafProps) { return ( - {Icon && } + {leadingIcon ?? (Icon && )} {label} ); From dc1a5daeaa0e4d86778d410d00bcf5163ea5dae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 01:44:16 +0100 Subject: [PATCH 09/20] feat(web): brand the sidebar, move New Task off the kanban top bar The Add task button (CreateTaskTopbarButton) leaves both the desktop and tablet kanban headers; New Task is now an unconditional primary nav item on the AppSidebar, falling back to a disabled affordance when no workspace is active. AppSidebar header gains a Kandev wordmark that links to /, with a single-letter K mark in collapsed-rail mode. PageTopbar's root variant no longer renders the parent brand label when backLabel is empty, so the home view's top-left "Kandev" goes away in favor of the sidebar. --- apps/web/app/tasks/tasks-page-client.tsx | 1 - .../app-sidebar/app-sidebar-header.tsx | 93 +++++++++++++------ .../app-sidebar/app-sidebar-nav-item.tsx | 60 +++++++++--- .../app-sidebar/app-sidebar-primary-nav.tsx | 22 ++--- apps/web/components/kanban-board.tsx | 1 - .../kanban/kanban-header-mobile.tsx | 1 + apps/web/components/kanban/kanban-header.tsx | 53 +---------- apps/web/components/page-topbar.tsx | 62 ++++++++++--- 8 files changed, 176 insertions(+), 117 deletions(-) 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-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index fc5369773..c1dbb0483 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -1,5 +1,6 @@ "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"; @@ -11,35 +12,75 @@ type AppSidebarHeaderProps = { onToggleCollapse: () => void; }; +const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; + export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { - return ( -
- - - - + + Expand sidebar + +
+ ); + } + + return ( +
+
+ + + Kandev + + + + + - - - {collapsed ? "Expand sidebar" : "Collapse sidebar"} - - + + + 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 index ceeaff77a..f1bc596aa 100644 --- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -18,8 +18,48 @@ type AppSidebarNavItemProps = { isActive?: boolean; /** Suppress the default href-startsWith activation (use for "Home"). */ exactMatch?: boolean; + /** Render as visually disabled and ignore clicks. */ + disabled?: boolean; }; +type TriggerProps = { + onClick?: () => void; + disabled: boolean; + baseClass: string; + label: string; + href?: string; + inner: React.ReactNode; +}; + +function renderTrigger({ onClick, disabled, baseClass, label, href, inner }: 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; @@ -36,14 +76,20 @@ export function AppSidebarNavItem({ collapsed, isActive, exactMatch = false, + disabled = false, }: AppSidebarNavItemProps) { const pathname = usePathname(); const active = isActive ?? isPathActive(pathname, href, exactMatch); const baseClass = cn( - "flex items-center rounded-md text-[13px] font-medium cursor-pointer transition-colors", + "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", - active ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-muted/60", + disabled + ? "cursor-not-allowed text-foreground/40" + : cn( + "cursor-pointer", + active ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-muted/60", + ), ); const inner = ( @@ -62,15 +108,7 @@ export function AppSidebarNavItem({ ); - const buttonOrLink = onClick ? ( - - ) : ( - - {inner} - - ); + const buttonOrLink = renderTrigger({ onClick, disabled, baseClass, label, href, inner }); if (!collapsed) return buttonOrLink; return ( diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index 3d4017223..a25bf9d7d 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -39,20 +39,14 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { collapsed={collapsed} /> )} - {/* Kanban-mode New Task is reachable from the kanban top bar; the office - NewTaskDialog requires an office workspace context, so we only mount - the AppSidebar shortcut when office is enabled. */} - {officeEnabled && ( - <> - setNewTaskOpen(true)} - collapsed={collapsed} - /> - - - )} + setNewTaskOpen(true)} + collapsed={collapsed} + disabled={!workspaceId} + /> + {workspaceId && }
); } 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; workspaceId?: string; currentPage?: "kanban" | "tasks"; searchQuery?: string; @@ -127,7 +126,6 @@ function useIsHeaderNarrow(ref: RefObject): boolean { } function TabletHeader({ - onCreateTask, title, workspaceLabel, searchQuery, @@ -139,7 +137,6 @@ function TabletHeader({ showHealthIndicator, onOpenHealthDialog, }: { - onCreateTask: () => void; title: string; workspaceLabel: string; searchQuery: string; @@ -157,6 +154,7 @@ function TabletHeader({ )} - @@ -204,38 +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, title, workspaceLabel, searchQuery, @@ -246,7 +204,6 @@ function DesktopHeader({ showHealthIndicator, onOpenHealthDialog, }: { - onCreateTask: () => void; title: string; workspaceLabel: string; searchQuery: string; @@ -280,13 +237,13 @@ function DesktopHeader({ ref={headerRef} title={title} subtitle={workspaceLabel} + backLabel={isHome ? "" : "Kandev"} center={centerSearch} className={WORKBENCH_TOPBAR_CLASSNAME} variant={isHome ? "root" : "breadcrumb"} actions={ <> {actionsSearch} - @@ -322,7 +279,6 @@ function useHeaderViewChange( } export function KanbanHeader({ - onCreateTask, workspaceId, currentPage = "kanban", searchQuery = "", @@ -346,7 +302,6 @@ export function KanbanHeader({ onOpenHealthDialog: healthIndicator.openDialog, }; const sharedSearch = { searchQuery, onSearchChange, isSearchLoading }; - const sharedActions = { onCreateTask, workspaceId }; const renderHeader = () => { if (isMobile) { @@ -365,7 +320,6 @@ export function KanbanHeader({ return ( <> | undefined; + title: string; + subtitle?: string; + icon?: ReactNode; +}; + +function TopbarLeading({ + variant, + backHref, + backLabel, + parents, + title, + subtitle, + icon, +}: TopbarLeadingProps) { + if (variant === "root") { + if (!backLabel) return null; + return ( +
+ {backLabel} +
+ ); + } + return ( + + ); +} + export const PageTopbar = forwardRef(function PageTopbar( { title, @@ -144,20 +183,15 @@ export const PageTopbar = forwardRef(function Page className={cn("relative flex h-10 shrink-0 items-center gap-3 border-b px-3 py-1", className)} > {leading} - {variant === "root" ? ( -
- {backLabel} -
- ) : ( - - )} + {leftActions && (
{leftActions} From 83f0359ca06ff6cc48d9c81bedc2922676a6c9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 01:56:03 +0100 Subject: [PATCH 10/20] feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new sentinel for "no binding"); matchesShortcut already returns false on an empty key, so the keybind hook needs no change. Keyboard shortcuts settings show "Unbound", expose a Clear (X) button when a custom binding is set, and the reset icon falls back to unbound for entries whose default is unbound. PageTopbar's breadcrumb variant no longer prepends the Home back-link when backHref is "/" (the AppSidebar provides Home navigation). --- apps/web/components/page-topbar.tsx | 20 ++++-- .../settings/keyboard-shortcuts-card.tsx | 68 +++++++++++++++---- .../tests/settings/keyboard-shortcuts.spec.ts | 16 +++-- .../lib/keyboard/shortcut-overrides.test.ts | 20 +++++- apps/web/lib/keyboard/shortcut-overrides.ts | 13 +++- 5 files changed, 105 insertions(+), 32 deletions(-) diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx index e2b840dea..313ca4790 100644 --- a/apps/web/components/page-topbar.tsx +++ b/apps/web/components/page-topbar.tsx @@ -78,15 +78,23 @@ function TopbarBreadcrumb({ subtitle?: string; icon?: ReactNode; }) { + // The Home prefix is redundant now that the AppSidebar always shows a Home + // nav item. Only render the back link when a page sets a non-root backHref + // (e.g. a true ancestor route within a section). + const showBackLink = backHref !== "/" && !!backLabel; return ( - - - - - - + {showBackLink && ( + <> + + + + + + + + )} {parents?.flatMap((p) => [ diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx index 7b1c363be..848a1ba19 100644 --- a/apps/web/components/settings/keyboard-shortcuts-card.tsx +++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { IconRotate } from "@tabler/icons-react"; +import { IconRotate, IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Kbd } from "@kandev/ui/kbd"; @@ -9,6 +9,8 @@ import type { Key, KeyboardShortcut } from "@/lib/keyboard/constants"; import { formatShortcut } from "@/lib/keyboard/utils"; import { CONFIGURABLE_SHORTCUTS, + UNBOUND_SHORTCUT, + isUnboundShortcut, resolveAllShortcuts, type ConfigurableShortcutId, type StoredShortcutOverrides, @@ -17,20 +19,26 @@ import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; +type ShortcutRecorderProps = { + shortcutId: ConfigurableShortcutId; + current: KeyboardShortcut; + onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void; + onReset: (id: ConfigurableShortcutId) => void; + onClear: (id: ConfigurableShortcutId) => void; +}; + function ShortcutRecorder({ shortcutId, current, onChange, onReset, -}: { - shortcutId: ConfigurableShortcutId; - current: KeyboardShortcut; - onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void; - onReset: (id: ConfigurableShortcutId) => void; -}) { + onClear, +}: ShortcutRecorderProps) { const [recording, setRecording] = useState(false); - const isDefault = - JSON.stringify(current) === JSON.stringify(CONFIGURABLE_SHORTCUTS[shortcutId].default); + const defaultShortcut = CONFIGURABLE_SHORTCUTS[shortcutId].default; + const isDefault = JSON.stringify(current) === JSON.stringify(defaultShortcut); + const isUnbound = isUnboundShortcut(current); + const defaultIsUnbound = isUnboundShortcut(defaultShortcut); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -85,19 +93,26 @@ function ShortcutRecorder({ : "border-border bg-background hover:bg-accent" }`} > - {recording ? ( - Press a key combo... - ) : ( - {formatShortcut(current)} - )} + {renderRecorderLabel({ recording, current, isUnbound })} + {!isUnbound && !defaultIsUnbound && ( + + )} {!isDefault && ( @@ -107,6 +122,20 @@ function ShortcutRecorder({ ); } +function renderRecorderLabel({ + recording, + current, + isUnbound, +}: { + recording: boolean; + current: KeyboardShortcut; + isUnbound: boolean; +}) { + if (recording) return Press a key combo...; + if (isUnbound) return Unbound; + return {formatShortcut(current)}; +} + export function KeyboardShortcutsCard() { const storeOverrides = useAppStore((s) => s.userSettings.keyboardShortcuts); const setUserSettings = useAppStore((s) => s.setUserSettings); @@ -143,6 +172,14 @@ export function KeyboardShortcutsCard() { [storeOverrides, persistOverrides], ); + const handleClear = useCallback( + (id: ConfigurableShortcutId) => { + const next = { ...storeOverrides, [id]: UNBOUND_SHORTCUT }; + persistOverrides(next); + }, + [storeOverrides, persistOverrides], + ); + const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[]; return ( @@ -159,6 +196,7 @@ export function KeyboardShortcutsCard() { current={shortcuts[id]} onChange={handleChange} onReset={handleReset} + onClear={handleClear} /> ))}
diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts index 80551bb75..237d21293 100644 --- a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts +++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts @@ -53,7 +53,11 @@ test.describe("Keyboard Shortcuts Settings", () => { await expect(recorderAfterReload).toContainText("T"); }); - test("can reset a customized shortcut to default", async ({ testPage, apiClient, seedData }) => { + test("can reset a customized shortcut back to unbound", async ({ + testPage, + apiClient, + seedData, + }) => { // Set a custom shortcut via API await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, @@ -70,16 +74,14 @@ test.describe("Keyboard Shortcuts Settings", () => { // Should show the custom shortcut (X) await expect(recorder).toContainText("X", { timeout: 3_000 }); - // Should have a reset button since it's customized + // Click reset (TOGGLE_SIDEBAR has no default binding, so reset clears it) const row = recorder.locator(".."); - const resetButton = row.getByTitle("Reset to default"); + const resetButton = row.getByTitle(/Reset/); await expect(resetButton).toBeVisible(); - - // Click reset await resetButton.click(); - // Should now show the default (B for Cmd/Ctrl+B) - await expect(recorder).toContainText("B", { timeout: 3_000 }); + // Should now show "Unbound" + await expect(recorder).toContainText("Unbound", { timeout: 3_000 }); }); test("customized command panel shortcut opens the panel", async ({ diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts index 6453bc902..131d82798 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.test.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest"; import { SHORTCUTS } from "./constants"; import { CONFIGURABLE_SHORTCUTS, + UNBOUND_SHORTCUT, getShortcut, + isUnboundShortcut, resolveAllShortcuts, type ConfigurableShortcutId, } from "./shortcut-overrides"; @@ -28,7 +30,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => { expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.default).toBe(SHORTCUTS.BOTTOM_TERMINAL); expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.label).toBe("Toggle Sidebar"); - expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.default).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.default).toBe(UNBOUND_SHORTCUT); expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.label).toBe("Command Panel (Alt)"); expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.default).toBe(SHORTCUTS.COMMAND_PANEL); @@ -50,7 +52,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => { describe("getShortcut", () => { it("returns default when no overrides provided", () => { expect(getShortcut("BOTTOM_TERMINAL")).toBe(SHORTCUTS.BOTTOM_TERMINAL); - expect(getShortcut("TOGGLE_SIDEBAR")).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + expect(getShortcut("TOGGLE_SIDEBAR")).toBe(UNBOUND_SHORTCUT); }); it("returns default when override does not contain the ID", () => { @@ -65,7 +67,19 @@ describe("getShortcut", () => { it("does not affect other shortcuts when one is overridden", () => { const overrides = { BOTTOM_TERMINAL: { key: "x", modifiers: { ctrlOrCmd: true } } }; - expect(getShortcut("TOGGLE_SIDEBAR", overrides)).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + expect(getShortcut("TOGGLE_SIDEBAR", overrides)).toBe(UNBOUND_SHORTCUT); + }); +}); + +describe("isUnboundShortcut", () => { + it("returns true for the sentinel and null/undefined", () => { + expect(isUnboundShortcut(UNBOUND_SHORTCUT)).toBe(true); + expect(isUnboundShortcut(null)).toBe(true); + expect(isUnboundShortcut(undefined)).toBe(true); + }); + + it("returns false for a real shortcut", () => { + expect(isUnboundShortcut(SHORTCUTS.BOTTOM_TERMINAL)).toBe(false); }); }); diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts index 8ac1b7a37..dd984ba22 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.ts @@ -17,6 +17,17 @@ export type StoredShortcutOverrides = Record< { key: string; modifiers?: Record } >; +/** + * Sentinel "no shortcut" value. `matchesShortcut` never matches a real key + * event against an empty key, so using this as a default makes a shortcut + * unbound until the user records one. + */ +export const UNBOUND_SHORTCUT: KeyboardShortcut = { key: "" as KeyboardShortcut["key"] }; + +export function isUnboundShortcut(shortcut: KeyboardShortcut | undefined | null): boolean { + return !shortcut || (shortcut.key as string) === ""; +} + export const CONFIGURABLE_SHORTCUTS: Record< ConfigurableShortcutId, { label: string; default: KeyboardShortcut } @@ -25,7 +36,7 @@ export const CONFIGURABLE_SHORTCUTS: Record< FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH }, QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT }, BOTTOM_TERMINAL: { label: "Toggle Bottom Terminal", default: SHORTCUTS.BOTTOM_TERMINAL }, - TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: SHORTCUTS.TOGGLE_SIDEBAR }, + TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: UNBOUND_SHORTCUT }, COMMAND_PANEL: { label: "Command Panel (Alt)", default: SHORTCUTS.COMMAND_PANEL }, NEW_TASK: { label: "New Task", default: SHORTCUTS.NEW_TASK }, FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT }, From b456756e18b8120b73c196fdf24fdef0b44b3dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 28 May 2026 23:41:30 +0100 Subject: [PATCH 11/20] feat(web): move New Task into the sidebar, retire the workspace selector - Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests - Drop the Workspace selector from the kanban display dropdown and mobile menu sheet; the sidebar workspace picker is now the sole switcher - Factor SectionHeader out of AppSidebarSection; animate fixed sections via Collapsible while the grow (Tasks) section stays flex-driven - Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move task group chevrons to the right, drop the task-item selected fill - Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column so pre-AppSidebar default layouts no longer render a broken grid - Point the cross-workspace isolation e2e at the sidebar picker --- apps/web/app/globals.css | 41 ++++++- .../app-sidebar/app-sidebar-constants.ts | 10 ++ .../app-sidebar/app-sidebar-header.tsx | 8 +- .../app-sidebar/app-sidebar-nav-item.tsx | 6 +- .../app-sidebar-new-task-item.test.tsx | 59 ++++++++++ .../app-sidebar/app-sidebar-new-task-item.tsx | 62 +++++++++++ .../app-sidebar/app-sidebar-primary-nav.tsx | 15 +-- .../app-sidebar/app-sidebar-section.tsx | 104 +++++++++++------- .../app-sidebar-workspace-picker.tsx | 3 + .../app-sidebar/sections/agents-section.tsx | 8 +- .../sections/integrations-section.tsx | 8 +- .../settings/settings-nav-primitives.tsx | 14 ++- .../app-sidebar/sections/tasks-section.tsx | 2 +- .../components/kanban-display-dropdown.tsx | 48 +------- .../components/kanban/mobile-menu-sheet.tsx | 35 +----- .../task/dockview-desktop-layout.tsx | 8 +- .../task/dockview-layout-restore.ts | 2 +- .../components/task/dockview-layout-setup.ts | 6 +- apps/web/components/task/task-item.tsx | 1 - apps/web/components/task/task-switcher.tsx | 14 +-- .../tests/layout/pane-resize-right.spec.ts | 2 +- .../tests/layout/plan-panel-indicator.spec.ts | 2 +- ...workspace-switch-sidebar-isolation.spec.ts | 18 +-- apps/web/lib/local-storage.ts | 5 +- 24 files changed, 301 insertions(+), 180 deletions(-) create mode 100644 apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 1e8a9d814..a8842778f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1460,9 +1460,8 @@ animation: queue-panel-close 180ms cubic-bezier(0.32, 0.72, 0, 1); } -/* Unified AppSidebar — staggered fade-in on expand. `sidebar-fade-in` - * is applied to header/text content (faster); `sidebar-fade-in-2` is - * applied to section bodies so labels appear slightly before lists. */ +/* 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; @@ -1476,6 +1475,38 @@ .sidebar-fade-in { animation: app-sidebar-fade-in 200ms ease-out; } -.sidebar-fade-in-2 { - animation: app-sidebar-fade-in 280ms ease-out 80ms backwards; + +/* 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/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts index 0b75a96fc..6f904b241 100644 --- a/apps/web/components/app-sidebar/app-sidebar-constants.ts +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -12,3 +12,13 @@ export type AppSidebarSectionId = 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-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index c1dbb0483..6599ca9f4 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -25,7 +25,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead 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" > - K + K Kandev @@ -55,13 +55,11 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead - - Kandev - + Kandev diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx index f1bc596aa..f563ac798 100644 --- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -6,6 +6,7 @@ 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; @@ -86,10 +87,7 @@ export function AppSidebarNavItem({ 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 ? "bg-accent text-foreground" : "text-foreground/80 hover:bg-muted/60", - ), + : cn("cursor-pointer", active ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE), ); const inner = ( 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..87349a1d2 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +const state = { + workspaces: { activeId: "ws-1" as string | null }, + kanban: { workflowId: "wf-1" as string | null, steps: [{ id: "s1", title: "Todo" }] }, +}; +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: () =>
, +})); + +import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; + +describe("AppSidebarNewTaskItem", () => { + beforeEach(() => { + state.workspaces.activeId = "ws-1"; + state.kanban.workflowId = "wf-1"; + state.kanban.steps = [{ id: "s1", title: "Todo" }]; + officeEnabled = false; + }); + + afterEach(() => cleanup()); + + it("uses the regular task-create dialog when office is disabled", () => { + officeEnabled = false; + render(); + expect(screen.getByTestId("regular-task-create-dialog")).toBeTruthy(); + expect(screen.queryByTestId("office-new-task-dialog")).toBeNull(); + }); + + it("uses the office new-issue dialog when office is enabled", () => { + officeEnabled = true; + render(); + expect(screen.getByTestId("office-new-task-dialog")).toBeTruthy(); + expect(screen.queryByTestId("regular-task-create-dialog")).toBeNull(); + }); + + it("renders no dialog when there is no active workspace", () => { + state.workspaces.activeId = null; + render(); + expect(screen.queryByTestId("regular-task-create-dialog")).toBeNull(); + expect(screen.queryByTestId("office-new-task-dialog")).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..506c34909 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { IconSquarePlus } from "@tabler/icons-react"; +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 { linkToTask } from "@/lib/links"; +import type { Task } from "@/lib/types/http"; +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. + */ +export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) { + const router = useRouter(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workflowId = useAppStore((s) => s.kanban.workflowId); + const steps = useAppStore((s) => s.kanban.steps); + const officeEnabled = useFeature("office"); + const [open, setOpen] = useState(false); + + const handleCreated = (task: Task) => { + router.push(linkToTask(task.id)); + }; + + return ( + <> + setOpen(true)} + collapsed={collapsed} + disabled={!workspaceId} + /> + {workspaceId && + (officeEnabled ? ( + + ) : ( + + ))} + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index a25bf9d7d..329855b64 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -1,12 +1,11 @@ "use client"; -import { useState } from "react"; -import { IconHome, IconInbox, IconMessageCircle, IconSquarePlus } from "@tabler/icons-react"; +import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; -import { NewTaskDialog } from "@/app/office/components/new-task-dialog"; import { AppSidebarNavItem } from "./app-sidebar-nav-item"; +import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; type AppSidebarPrimaryNavProps = { collapsed: boolean; @@ -17,7 +16,6 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { const inboxCount = useAppStore((s) => s.office.inboxCount); const officeEnabled = useFeature("office"); const handleOpenQuickChat = useQuickChatLauncher(workspaceId); - const [newTaskOpen, setNewTaskOpen] = useState(false); return (
@@ -39,14 +37,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { collapsed={collapsed} /> )} - setNewTaskOpen(true)} - collapsed={collapsed} - disabled={!workspaceId} - /> - {workspaceId && } +
); } diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index 10a1da4f6..965e59512 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -2,6 +2,7 @@ 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"; @@ -18,6 +19,42 @@ type AppSidebarSectionProps = { 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, @@ -52,47 +89,36 @@ export function AppSidebarSection({ ); } - const growExpanded = grow && expanded; const handleToggle = () => toggleSection(id); - return ( -
-
- - {expanded && headerAction && ( -
{headerAction}
- )} - + // 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}
}
- {expanded && ( -
- {children} -
- )} -
+ ); + } + + return ( + + + +
{children}
+
+
); } diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index af74b7ace..2a27b7eed 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -29,6 +29,7 @@ function OfficeTrigger({ collapsed, activeId, activeName }: TriggerProps) { return (
- {expanded &&
{children}
} -
+ +
{children}
+
+ ); } diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx index 06da59ade..a501dc348 100644 --- a/apps/web/components/app-sidebar/sections/tasks-section.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -24,7 +24,7 @@ export function TasksSection({ collapsed }: TasksSectionProps) { headerAction={} grow > -
+
diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index 0007737b7..c2a49e6f6 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -13,9 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { IconAdjustmentsHorizontal } from "@tabler/icons-react"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; 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"; import type { ComponentProps } from "react"; type KanbanDisplayDropdownProps = { @@ -31,41 +29,6 @@ function getRepositoryPlaceholder( return "Select repository"; } -function WorkspaceSection({ - activeWorkspaceId, - workspaces, - onWorkspaceChange, -}: { - activeWorkspaceId: string | null; - workspaces: WorkspaceItem[]; - onWorkspaceChange: (id: string | null) => 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; activeWorkflowId: string | null; workflows: WorkflowsState["items"]; onWorkflowChange: (id: string | null) => void; @@ -55,9 +50,6 @@ type MobileDisplayOptionsProps = { }; function MobileDisplaySelects({ - activeWorkspaceId, - workspaces, - onWorkspaceChange, activeWorkflowId, workflows, onWorkflowChange, @@ -68,25 +60,6 @@ function MobileDisplaySelects({ }: Omit) { return ( <> -
- - -
-