Files
adiuvAI/src/renderer/components/layout/AppShell.tsx
Roberto 63fc3cfa43 refactor(contextual): delete FloatingChat, FloatingChatContext, useDoubleClickAI
Replaced by ContextualChatProvider + AdiuvaTriggerButton in M4.
Pre-1.0 clean removal — no deprecation period.
2026-05-15 18:36:51 +02:00

668 lines
24 KiB
TypeScript

import { useMemo, useRef, useState } from 'react';
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
import { LayoutGroup } from 'framer-motion';
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import type { PanelSize } from 'react-resizable-panels';
import {
House,
ChartGantt,
ClipboardCheck,
FolderKanban,
Settings,
LogOut,
Sun,
Moon,
Monitor,
ChevronsUpDown,
SquarePen,
Folder,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { useTheme } from '@/components/theme-provider';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
import { LoginForm } from '@/components/auth/LoginForm';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTranslation } from 'react-i18next';
const NAV_ITEMS = [
{ to: '/', icon: House, labelKey: 'nav.home' },
{ to: '/timeline', icon: ChartGantt, labelKey: 'nav.timeline' },
{ to: '/tasks', icon: ClipboardCheck, labelKey: 'nav.tasks' },
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
] as const;
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
const SIDEBAR_SIZE_MIN = 22;
const SIDEBAR_SIZE_MAX = 60;
const SIDEBAR_SIZE_DEFAULT = 38;
function readSidebarSize(): number {
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
if (!v) return SIDEBAR_SIZE_DEFAULT;
const n = Number(v);
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
}
function MainArea({ children }: { children: React.ReactNode }) {
const loc = useLocation();
const isHome = loc.pathname === '/';
const { open } = useContextualChat();
// Read once per mount of the open state. When the user reopens the sidebar
// we want the most recent persisted size, so we key the PanelGroup on
// `open` so it remounts each open/close cycle.
const initialSize = useMemo(() => readSidebarSize(), [open]);
if (isHome || !open) {
return <>{children}</>;
}
return (
<ResizablePanelGroup
key={`sidebar-open-${initialSize}`}
orientation="horizontal"
className="h-full w-full"
>
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
{children}
</ResizablePanel>
<ResizableHandle
withHandle
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
/>
<ResizablePanel
defaultSize={`${initialSize}%`}
minSize={`${SIDEBAR_SIZE_MIN}%`}
maxSize={`${SIDEBAR_SIZE_MAX}%`}
onResize={(panelSize: PanelSize) => {
const clamped = Math.max(
SIDEBAR_SIZE_MIN,
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
);
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
}}
>
<div className="h-full w-full">
<ContextualSidebar />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<ExpandedClientsProvider>
<TaskBriefingProvider>
<HeaderProvider>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</HeaderProvider>
</TaskBriefingProvider>
</ExpandedClientsProvider>
);
}
function AppShellInner({ children }: AppShellProps) {
const { t } = useTranslation();
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
retry: false,
});
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
});
const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation();
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
const [open, setOpen] = useState(() =>
collapsedQuery.data === undefined ? false : !collapsedQuery.data
);
const handleOpenChange = (value: boolean) => {
setOpen(value);
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
const taskBriefing = useTaskBriefing();
const chatActionsRef = useRef<{ clear: () => void } | null>(null);
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
const isHomePage = currentPath === '/';
const isSettingsPage = currentPath.startsWith('/settings');
// Derive the page label from the current path for the breadcrumb
const matchedItem = NAV_ITEMS.find(
(item) => item.to !== '/' && currentPath.startsWith(item.to),
);
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
// Dynamic label/extras published by child pages (e.g. ProjectDetail)
const { label: dynamicLabel, extras: headerExtras, leftExtras, rightExtras } = useHeader();
const pageLabel = dynamicLabel ?? routeLabel;
// All non-home, non-settings routes show the shared AppShell header.
// Projects and notes previously managed their own header; they now receive
// the shared header (with SidebarTrigger + AdiuvaTriggerButton) from here.
const showHeader = !isSettingsPage && !isHomePage;
if (authStatusQuery.data?.authenticated === false) {
return <LoginForm />;
}
if (
authStatusQuery.data?.profile &&
authStatusQuery.data.profile.onboardingCompletedAt == null
) {
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
}
return (
<LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange} className="h-full">
<AppSidebar
currentPath={currentPath}
profile={authStatusQuery.data?.profile ?? null}
/>
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
{isHomePage ? (
<div className="relative flex-1 min-h-0">
{!taskBriefing.isOpen && (
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-1 rounded-lg bg-background/60 backdrop-blur-md px-1 py-1">
<SidebarTrigger />
{homeChatHasMessages && (
<>
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px mx-1" />
<button
onClick={() => chatActionsRef.current?.clear()}
aria-label="New conversation"
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
>
<SquarePen size={16} />
</button>
</>
)}
</div>
)}
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
</div>
) : (
<ContextualChatProvider>
{/* MainArea wraps EVERYTHING (header + content) so the contextual
sidebar, when open, spans the full SidebarInset height. The
left ResizablePanel contains the header + scrollable body;
the right panel is the sidebar.
The inner overflow-hidden div scopes sticky elements (e.g.
ProjectTabBar) below the header without sliding behind it. */}
<MainArea>
<div className="flex flex-col h-full min-w-0">
{showHeader && (
<header className="flex h-14 shrink-0 items-center gap-2">
<div className="flex flex-1 items-center gap-2 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className={`data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:h-4${leftExtras ? '' : ' mr-2'}`} />
{leftExtras ?? (
<h4 className="text-sm font-medium text-foreground">{pageLabel}</h4>
)}
{headerExtras}
<div className="flex-1" />
{rightExtras}
<AdiuvaTriggerButton />
</div>
</header>
)}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{children}
</div>
</div>
</MainArea>
</ContextualChatProvider>
)}
</SidebarInset>
</SidebarProvider>
</LayoutGroup>
);
}
interface AppSidebarProps {
currentPath: string;
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
}
function AppSidebar({ currentPath, profile }: AppSidebarProps) {
const { t } = useTranslation();
return (
<Sidebar collapsible="icon">
{/* Logo */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<div className="cursor-default">
<div className="size-7 rounded-lg bg-primary flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="18" height="18">
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
</svg>
</div>
<span className="font-semibold text-sm text-foreground">
adiuv<span className="font-bold text-primary">AI</span>
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Nav */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => {
const isActive =
to === '/'
? currentPath === '/'
: currentPath.startsWith(to);
const label = t(labelKey);
return (
<SidebarMenuItem key={to}>
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={label}
>
<Link to={to}>
<Icon />
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavProjects />
</SidebarContent>
{/* User avatar + dropdown */}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<NavUser profile={profile} currentPath={currentPath} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
// ---------------------------------------------------------------------------
// NavProjects — clients + projects tree in the sidebar
// ---------------------------------------------------------------------------
const NO_CLIENT_KEY = '__no_client__';
function NavProjects() {
const { state } = useSidebar();
const { t } = useTranslation();
const navigate = useNavigate();
const routerState = useRouterState();
const currentPath = routerState.location.pathname;
const currentProjectId = useMemo(() => {
const params = new URLSearchParams(routerState.location.search);
return params.get('projectId') ?? undefined;
}, [routerState.location.search]);
const { expandedClients, toggleClient, expandClients } = useExpandedClients();
const { data: projectList = [] } = trpc.projects.list.useQuery({ includeArchived: false });
const { data: clientList = [] } = trpc.clients.list.useQuery();
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
const subClientsByParent = useMemo(() => {
const m = new Map<string, typeof clientList>();
for (const c of clientList) {
if (c.parentId) {
const arr = m.get(c.parentId);
if (arr) arr.push(c);
else m.set(c.parentId, [c]);
}
}
return m;
}, [clientList]);
const projectsByClient = useMemo(() => {
const m = new Map<string, typeof projectList>();
for (const p of projectList) {
const key = p.clientId ?? NO_CLIENT_KEY;
const arr = m.get(key);
if (arr) arr.push(p);
else m.set(key, [p]);
}
return m;
}, [projectList]);
function handleSelectProject(projectId: string) {
void navigate({ to: '/projects', search: { projectId } });
}
if (state === 'collapsed') return null;
if (currentPath.startsWith('/projects')) return null;
if (projectList.length === 0 && clientList.length === 0) return null;
const isProjectsActive = currentPath.startsWith('/projects');
const unassignedProjects = projectsByClient.get(NO_CLIENT_KEY) ?? [];
return (
<>
<SidebarGroup>
<SidebarGroupLabel>{t('projects.projects')}</SidebarGroupLabel>
<SidebarMenu>
{topLevelClients.map((client) => {
const isExpanded = expandedClients.has(client.id);
const directProjects = projectsByClient.get(client.id) ?? [];
const subClients = subClientsByParent.get(client.id) ?? [];
const hasChildren = directProjects.length > 0 || subClients.length > 0;
return (
<Collapsible
key={client.id}
open={isExpanded}
onOpenChange={() => toggleClient(client.id)}
asChild
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={client.name}>
<Folder />
<span>{client.name}</span>
{hasChildren && (
<ChevronRight
className={cn(
'ml-auto transition-transform duration-200',
isExpanded && 'rotate-90',
)}
/>
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{subClients.map((subClient) => {
const subIsExpanded = expandedClients.has(subClient.id);
const subProjects = projectsByClient.get(subClient.id) ?? [];
return (
<Collapsible
key={subClient.id}
open={subIsExpanded}
onOpenChange={() => toggleClient(subClient.id)}
asChild
>
<SidebarMenuSubItem>
<CollapsibleTrigger asChild>
<SidebarMenuSubButton>
<Folder />
<span>{subClient.name}</span>
{subProjects.length > 0 && (
<ChevronRight
className={cn(
'ml-auto size-3 transition-transform duration-200',
subIsExpanded && 'rotate-90',
)}
/>
)}
</SidebarMenuSubButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{subProjects.map((p) => (
<SidebarMenuSubItem key={p.id}>
<SidebarMenuSubButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
>
<span>{p.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuSubItem>
</Collapsible>
);
})}
{directProjects.map((p) => (
<SidebarMenuSubItem key={p.id}>
<SidebarMenuSubButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
>
<span>{p.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
})}
{unassignedProjects.map((p) => (
<SidebarMenuItem key={p.id}>
<SidebarMenuButton
isActive={isProjectsActive && currentProjectId === p.id}
onClick={() => handleSelectProject(p.id)}
tooltip={p.name}
>
<span>{p.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</>
);
}
// ---------------------------------------------------------------------------
// NavUser — avatar with dropdown (inspired by shadcn sidebar-07)
// ---------------------------------------------------------------------------
function NavUser({
profile,
currentPath,
}: {
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
currentPath: string;
}) {
const { isMobile } = useSidebar();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const logoutMutation = trpc.auth.logout.useMutation();
const { notify } = useNotify();
const utils = trpc.useUtils();
const email = profile?.email ?? 'User';
const displayName = [profile?.name, profile?.surname].filter(Boolean).join(' ') || email?.split('@')[0];
const initials = profile?.name && profile?.surname
? `${profile.name[0]}${profile.surname[0]}`.toUpperCase()
: (email?.split('@')[0] ?? 'US').slice(0, 2).toUpperCase();
function handleLogout() {
logoutMutation.mutate(undefined, {
onSuccess: () => {
notify('info', 'toast.auth.loggedOut');
void utils.auth.status.invalidate();
},
});
}
const themeOptions = [
{ value: 'light' as const, label: 'Light', icon: Sun },
{ value: 'dark' as const, label: 'Dark', icon: Moon },
{ value: 'system' as const, label: 'System', icon: Monitor },
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
<AvatarFallback className="rounded-lg text-xs">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
<AvatarFallback className="rounded-lg text-xs">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to="/settings">
<Settings className="mr-2 size-4" />
{t('nav.settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{theme === 'dark' ? (
<Moon className="mr-2 size-4" />
) : theme === 'light' ? (
<Sun className="mr-2 size-4" />
) : (
<Monitor className="mr-2 size-4" />
)}
Theme
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{themeOptions.map(({ value, label, icon: Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setTheme(value)}
>
<Icon className="mr-2 size-4" />
{label}
{theme === value && (
<span className="ml-auto text-xs text-muted-foreground">
Active
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} disabled={logoutMutation.isPending}>
<LogOut className="mr-2 size-4" />
{t('settings.signOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}