US-017 completed

This commit is contained in:
Roberto Musso
2026-02-22 23:21:37 +01:00
parent 2308158976
commit 98acf6220e
5 changed files with 158 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useRouterState } from '@tanstack/react-router';
import { motion, useMotionValue, useSpring } from 'framer-motion';
import {
House,
ChartGantt,
@@ -7,6 +8,7 @@ import {
FolderKanban,
PanelLeft,
ChevronUp,
ChevronDown,
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import {
@@ -23,6 +25,7 @@ import {
SidebarProvider,
useSidebar,
} from '@/components/ui/sidebar';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
@@ -35,6 +38,20 @@ interface AppShellProps {
children: React.ReactNode;
}
/** Walk up the DOM to find the nearest scrollable ancestor. */
function findScrollableAncestor(el: Element | null): Element | null {
if (!el || el === document.body) return null;
const style = window.getComputedStyle(el);
const overflowY = style.overflowY;
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight
) {
return el;
}
return findScrollableAncestor(el.parentElement);
}
export function AppShell({ children }: AppShellProps) {
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
@@ -55,24 +72,105 @@ export function AppShell({ children }: AppShellProps) {
setSidebarCollapsedMutation.mutate({ collapsed: !value });
};
// Curtain is disabled on home page and on /projects without a selected project
const searchObj = routerState.location.search as Record<string, unknown>;
const curtainEnabled =
currentPath !== '/' &&
!(currentPath === '/projects' && !searchObj['projectId']);
const curtainEnabledRef = useRef(curtainEnabled);
curtainEnabledRef.current = curtainEnabled;
// --- Curtain animation state ---
const [curtainOpen, setCurtainOpen] = useState(false);
const curtainOpenRef = useRef(false);
const y = useMotionValue(0);
const springY = useSpring(y, { stiffness: 300, damping: 30 });
const openCurtain = useCallback(() => {
curtainOpenRef.current = true;
setCurtainOpen(true);
y.set(window.innerHeight);
}, [y]);
const closeCurtain = useCallback(() => {
curtainOpenRef.current = false;
setCurtainOpen(false);
y.set(0);
}, [y]);
const toggleCurtain = useCallback(() => {
if (curtainOpenRef.current) closeCurtain();
else openCurtain();
}, [openCurtain, closeCurtain]);
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!curtainEnabledRef.current) return;
toggleCurtain();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [toggleCurtain]);
// Wheel event: overscroll detection
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (!curtainOpenRef.current) {
if (!curtainEnabledRef.current) return;
// Opening: overscroll UP (deltaY < 0) when content is at top
if (e.deltaY < 0) {
const scrollable = findScrollableAncestor(e.target as Element);
const atTop = !scrollable || scrollable.scrollTop === 0;
if (atTop) openCurtain();
}
} else {
// Closing: scroll DOWN (deltaY > 0) while curtain is open
if (e.deltaY > 0) {
closeCurtain();
}
}
};
document.addEventListener('wheel', handleWheel, { passive: true });
return () => document.removeEventListener('wheel', handleWheel);
}, [openCurtain, closeCurtain]);
return (
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar currentPath={currentPath} />
<SidebarInset>
{children}
<SidebarInset className="overflow-hidden">
{/* AI Chat layer: always mounted behind the content panel */}
<AIChatPanel />
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
<div className="absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none">
<div className="flex flex-col items-center gap-1.5 pr-2">
<ChevronUp size={10} className="text-muted-foreground/30" />
<span
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
scrolling up for Adiuva
</span>
{/* Content panel: slides down to reveal chat */}
<motion.div
style={{ y: springY }}
className="absolute inset-0 z-10 flex flex-col bg-background"
>
{children}
{/* Right-edge vertical affordance (non-interactive, hidden on home) */}
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
<div className="flex flex-col items-center gap-1.5 pr-2">
{curtainOpen ? (
<ChevronDown size={10} className="text-muted-foreground/30" />
) : (
<ChevronUp size={10} className="text-muted-foreground/30" />
)}
<span
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
</span>
</div>
</div>
</div>
</motion.div>
</SidebarInset>
</SidebarProvider>
);