Files
kandev/apps/web/components/app-sidebar/app-sidebar.tsx
T
2026-09-20 00:09:02 +01:00

322 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { usePathname } from "@/lib/routing/client-router";
import { isSettingsRoute } from "./app-sidebar-route";
import { useAppStore } from "@/components/state-provider";
import { useEnsureWorkspaceWorkflows } from "@/hooks/use-workflows";
import { useOfficeModeState } from "@/hooks/use-in-office";
import type { WorkspaceMode } from "@/components/workspace-scope-provider";
import { useOfficeWorkspaceData } from "@/hooks/use-office-workspace-data";
import { useSidebarHoverReveal } from "@/hooks/domains/sidebar/use-sidebar-hover-reveal";
import { cn } from "@/lib/utils";
import {
APP_SIDEBAR_COLLAPSED_WIDTH,
APP_SIDEBAR_EXPANDED_WIDTH,
APP_SIDEBAR_SECTION_IDS,
} from "./app-sidebar-constants";
import { PluginNavItems } from "@/components/plugins/plugin-nav-items";
import { AppSidebarFooter } from "./app-sidebar-footer";
import { AppSidebarHeader } from "./app-sidebar-header";
import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav";
import { AppSidebarResizeHandle } from "./app-sidebar-resize-handle";
import { AppSidebarSettingsMode } from "./app-sidebar-settings-mode";
import { AgentsSection } from "./sections/agents-section";
import { AutomationsSection } from "./sections/automations-section";
import { CanvasesSection } from "./sections/canvases-section";
import { IntegrationsSection } from "./sections/integrations-section";
import { OfficeNavigationSection } from "./sections/office-navigation-section";
import { ProjectsSection } from "./sections/projects-section";
import { TasksSection } from "./sections/tasks-section";
import { SidebarLayoutNavigation } from "./sidebar-layout-navigation";
import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation";
const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean }> = [
{
id: APP_SIDEBAR_SECTION_IDS.tasks,
matches: (p) => p.startsWith("/t/"),
},
{
id: APP_SIDEBAR_SECTION_IDS.officeWork,
matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/office/routines"),
},
{
id: APP_SIDEBAR_SECTION_IDS.officeWorkspace,
matches: (p) => p.startsWith("/office/workspace"),
},
{ id: APP_SIDEBAR_SECTION_IDS.projects, matches: (p) => p.startsWith("/office/projects") },
{ id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") },
];
type AppSidebarNavigationProps = {
collapsed: boolean;
mode: WorkspaceMode;
settingsMode: boolean;
};
/**
* Mode comes from the active workspace, which is not known on the first frame
* of a boot whose payload ships no workspace list. Rendering only the
* mode-independent rows — rather than assuming kanban — is what stops the
* sidebar painting one mode's sections and swapping to the other's a tick
* later.
*/
function AppSidebarUnresolvedNav({ collapsed }: { collapsed: boolean }) {
return (
<div className="flex flex-col gap-1" data-testid="app-sidebar-scroll">
<AppSidebarPrimaryNav collapsed={collapsed} />
<PluginNavItems collapsed={collapsed} />
</div>
);
}
function AppSidebarModeNav({ collapsed, inOffice }: { collapsed: boolean; inOffice: boolean }) {
const hasSavedSidebarLayout = useHasSavedSidebarLayout();
return (
<>
<div
className={cn(
"flex flex-col gap-1 overflow-y-auto",
inOffice ? "flex-1 min-h-0 pb-8 scroll-pb-8" : "shrink-0",
)}
data-testid="app-sidebar-scroll"
>
{hasSavedSidebarLayout ? (
<SidebarLayoutNavigation collapsed={collapsed} inOffice={inOffice} />
) : (
<>
<AppSidebarPrimaryNav collapsed={collapsed} />
{/* Directly under New Task: an automation is a thing you keep, the
same weight as a project, and the list IS the nav — picking one
opens its history rather than a settings form. */}
{!inOffice && <AutomationsSection collapsed={collapsed} />}
{!inOffice && <CanvasesSection collapsed={collapsed} />}
<PluginNavItems collapsed={collapsed} />
</>
)}
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="work" />}
<ProjectsSection collapsed={collapsed} />
<AgentsSection collapsed={collapsed} />
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="office" />}
{!inOffice && !hasSavedSidebarLayout && <IntegrationsSection collapsed={collapsed} />}
</div>
{/* In regular kanban mode, Tasks is the flex-grow middle section so
it absorbs remaining vertical space and scrolls internally.
Office has a dedicated /office/tasks page, so the sidebar only
renders a lightweight Tasks nav row above. */}
{!inOffice && <TasksSection collapsed={collapsed} />}
{inOffice && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
data-testid="app-sidebar-bottom-fade"
/>
)}
</>
);
}
function AppSidebarNavigation({ collapsed, mode, settingsMode }: AppSidebarNavigationProps) {
function content() {
if (settingsMode && !collapsed) return <AppSidebarSettingsMode />;
if (mode === "unknown") return <AppSidebarUnresolvedNav collapsed={collapsed} />;
return <AppSidebarModeNav collapsed={collapsed} inOffice={mode === "office"} />;
}
return (
<nav className="relative flex-1 min-h-0 flex flex-col gap-2 px-2 py-2 overflow-hidden">
{content()}
</nav>
);
}
function AppSidebarFooterSlot({
collapsed,
onToggleSettingsMode,
}: {
collapsed: boolean;
onToggleSettingsMode: () => void;
}) {
const layoutManaged = useHasSavedSidebarLayout();
return (
<AppSidebarFooter
collapsed={collapsed}
onToggleSettingsMode={onToggleSettingsMode}
layoutManaged={layoutManaged}
/>
);
}
/**
* Unified app sidebar mounted at the root layout. Replaces the legacy
* WorkspaceRail + OfficeSidebar + dockview-embedded sidebar surfaces.
*
* Width: at least 320px expanded (user-resizable) / 56px collapsed. The flex
* reservation snaps between states so the workbench performs one layout pass,
* while the absolutely-positioned visual panel keeps the 300ms width animation.
* Desktop-only (`hidden md:block`) — mobile surfaces carry their own nav (mobile
* headers and menu sheets), so the global rail never overlays mobile content.
*/
/**
* The transient settings takeover, kept aligned with route ownership. It is
* intentionally not persisted, so a direct reload on `/settings/...` has to
* enter it from the current pathname.
*
* Keyed on actual pathname changes, and only forced when *entering* the settings
* surface: moving within it must not reopen a takeover the user closed. Leaving
* it closes the takeover unless the user's own toggle was the thing that
* navigated (`togglePathnameRef`), because history updates before React renders
* the new pathname.
*/
function useSettingsTakeover(pathname: string | null) {
const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
const setSettingsMode = useAppStore((s) => s.setAppSidebarSettingsMode);
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
const togglePathnameRef = useRef<string | null>(null);
const prevPathnameRef = useRef<string | null>(null);
const toggle = useCallback(() => {
togglePathnameRef.current = window.location.pathname;
toggleSettingsMode();
}, [toggleSettingsMode]);
useEffect(() => {
if (!pathname || prevPathnameRef.current === pathname) return;
const cameFromSettings = isSettingsRoute(prevPathnameRef.current);
prevPathnameRef.current = pathname;
if (isSettingsRoute(pathname)) {
togglePathnameRef.current = null;
// `/settings` handing off to the last page visited (see `SettingsIndex`)
// is a settings-to-settings navigation, and so is every tree click.
if (!settingsMode && !cameFromSettings) setSettingsMode(true);
return;
}
if (settingsMode && togglePathnameRef.current !== pathname) {
setSettingsMode(false);
}
}, [pathname, settingsMode, setSettingsMode]);
return { settingsMode, toggleSettingsMode: toggle };
}
export function AppSidebar() {
const collapsed = useAppStore((s) => s.appSidebar.collapsed);
const sectionExpanded = useAppStore((s) => s.appSidebar.sectionExpanded);
const storedWidth = useAppStore((s) => s.appSidebar.width);
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar);
const setWidth = useAppStore((s) => s.setAppSidebarWidth);
const pathname = usePathname();
const enabled = useAppStore((s) => s.userSettings.sidebarHoverEnabled);
const delayMs = useAppStore((s) => s.userSettings.sidebarHoverDelayMs);
const hover = useSidebarHoverReveal({ collapsed, pathname, enabled, delayMs });
const visuallyCollapsed = collapsed && !hover.revealed;
const handleToggleCollapse = () => {
hover.dismiss();
toggleCollapsed();
};
const mode = useOfficeModeState();
const [isResizing, setIsResizing] = useState(false);
// Keep `state.workflows.items` in sync with the active workspace at the top
// of the always-mounted sidebar. Downstream consumers (the workspace picker,
// Tasks section, kanban board) all assume this state exists for the current
// workspace — hoisting it above the collapsible Tasks section is required so
// a user with the section collapsed still gets fresh workflows on a switch.
useEnsureWorkspaceWorkflows();
// The office collections the sidebar renders (Projects, Agents, the inbox
// badge) load here so they follow the active workspace rather than the
// `/office` route family. No-ops for a kanban workspace.
useOfficeWorkspaceData();
const handleResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = storedWidth;
const maxWidth = Math.floor(window.innerWidth * 0.3);
const onMove = (moveEvent: MouseEvent) => {
const next = Math.min(
maxWidth,
Math.max(APP_SIDEBAR_EXPANDED_WIDTH, startWidth + (moveEvent.clientX - startX)),
);
setWidth(next);
};
const onUp = () => {
setIsResizing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[storedWidth, setWidth],
);
const expandedWidth = Math.max(APP_SIDEBAR_EXPANDED_WIDTH, storedWidth);
const targetWidth = collapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth;
const { settingsMode, toggleSettingsMode: handleToggleSettingsMode } =
useSettingsTakeover(pathname);
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 (
<div
data-testid="app-sidebar-layout"
className="relative z-30 hidden h-full min-h-0 shrink-0 overflow-visible md:block"
style={{ width: targetWidth }}
>
<aside
ref={hover.ref}
{...hover.handlers}
data-hover-revealed={hover.revealed ? "true" : "false"}
data-testid="app-sidebar"
data-collapsed={collapsed ? "true" : "false"}
className={cn(
"absolute inset-y-0 left-0 flex min-h-0 flex-col border-r border-border bg-background",
// The panel is outside root flex flow, so its transition cannot make
// the workbench or Dockview reflow on intermediate animation frames.
// On collapse it briefly overdraws the snapped layout slot and stays
// interactive so the new rail can be expanded again immediately.
isResizing
? "transition-none"
: "transition-[width] duration-300 ease-out motion-reduce:transition-none",
)}
style={{ width: visuallyCollapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth }}
>
<AppSidebarHeader
collapsed={visuallyCollapsed}
hoverRevealed={hover.revealed}
onToggleCollapse={handleToggleCollapse}
/>
<div
data-testid="app-sidebar-content"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<AppSidebarNavigation
collapsed={visuallyCollapsed}
mode={mode}
settingsMode={settingsMode}
/>
<AppSidebarFooterSlot
collapsed={visuallyCollapsed}
onToggleSettingsMode={handleToggleSettingsMode}
/>
</div>
{!collapsed && <AppSidebarResizeHandle onMouseDown={handleResize} />}
</aside>
</div>
);
}