feat: add CLAUDE.md for development guidance and update .gitignore to include .claude directory; refactor AIChatPanel and AppShell components for improved context handling; simplify layout in ProjectDetail, NoteDetailPage, TasksPage, and TimelinePage components
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { LayoutGroup, motion, useMotionValue, useSpring } from 'framer-motion';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
ClipboardCheck,
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
@@ -71,20 +69,6 @@ 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) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
@@ -132,138 +116,23 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
|
||||
// Curtain is disabled on home page and on /projects without a selected project
|
||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
||||
const curtainEnabled =
|
||||
currentPath !== '/' &&
|
||||
!(currentPath === '/projects' && !projectId);
|
||||
const curtainEnabledRef = useRef(curtainEnabled);
|
||||
curtainEnabledRef.current = curtainEnabled;
|
||||
|
||||
// Derive AI chat context from current route
|
||||
const isProjectView = currentPath === '/projects' && !!projectId;
|
||||
const contextType = isProjectView ? 'project' as const : 'global' as const;
|
||||
const projectQuery = trpc.projects.get.useQuery(
|
||||
{ id: projectId ?? '' },
|
||||
{ enabled: !!projectId },
|
||||
);
|
||||
|
||||
// --- 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]);
|
||||
|
||||
// Keep curtain position in sync with window height on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (curtainOpenRef.current) {
|
||||
y.set(window.innerHeight);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [y]);
|
||||
|
||||
// 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 (
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
onNavClick={closeCurtain}
|
||||
/>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{/* AI Chat layer: always mounted behind the content panel */}
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
contextType={contextType}
|
||||
projectId={projectId}
|
||||
projectName={projectQuery.data?.name}
|
||||
curtainOpen={isHomePage || curtainOpen}
|
||||
isHomePage={isHomePage}
|
||||
/>
|
||||
|
||||
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
|
||||
{!isHomePage && (
|
||||
<motion.div
|
||||
style={{ y: springY }}
|
||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
||||
>
|
||||
<SidebarInset>
|
||||
{isHomePage ? (
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
isHomePage
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col h-full">
|
||||
{children}
|
||||
|
||||
{/* Right-edge vertical affordance (non-interactive) */}
|
||||
<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} />
|
||||
) : (
|
||||
<ChevronUp size={10} />
|
||||
)}
|
||||
<span
|
||||
className="text-[9px] tracking-widest uppercase font-medium"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
@@ -321,10 +190,9 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
onNavClick: () => void;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
@@ -377,7 +245,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
|
||||
isActive={isActive}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link to={to} onClick={onNavClick}>
|
||||
<Link to={to}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user