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:
Roberto Musso
2026-02-28 13:42:52 +01:00
parent 60b76c6d97
commit c5e78311e6
8 changed files with 162 additions and 284 deletions

View File

@@ -4,7 +4,6 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { trpc } from '@/lib/trpc';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -19,19 +18,11 @@ const SUGGESTION_CHIPS = [
interface AIChatPanelProps {
onOpenSettings?: () => void;
contextType: 'global' | 'project';
projectId?: string;
projectName?: string;
curtainOpen: boolean;
isHomePage?: boolean;
}
export function AIChatPanel({
onOpenSettings,
contextType,
projectId,
projectName,
curtainOpen,
isHomePage,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
@@ -41,11 +32,8 @@ export function AIChatPanel({
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const chatContext = useMemo<ChatContext>(
() => ({
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
}),
[contextType, projectId],
() => ({ type: 'global' as const }),
[],
);
const {
messages,
@@ -71,15 +59,6 @@ export function AIChatPanel({
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Reset input when curtain closes; scroll to bottom when it reopens
useEffect(() => {
if (!curtainOpen) {
setInput('');
} else {
setTimeout(scrollToBottom, 50);
}
}, [curtainOpen, scrollToBottom]);
// Auto-scroll when messages change or streaming content updates
useEffect(() => {
scrollToBottom();
@@ -130,60 +109,15 @@ export function AIChatPanel({
}
};
// Smart wheel handler: only stop propagation when there's content to scroll through
const handleWheel = useCallback((e: React.WheelEvent) => {
const el = messagesContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
const atTop = el.scrollTop < 2;
// Let event propagate to AppShell when at boundaries
if ((e.deltaY > 0 && atBottom) || (e.deltaY < 0 && atTop)) return;
e.stopPropagation();
}, []);
// No token configured — show settings prompt
if (hasTokenQuery.data === false && !isHomePage) {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Card className="max-w-sm">
<CardContent className="flex flex-col items-center gap-4 pt-6">
<KeyRound size={32} className="text-muted-foreground" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">AI provider not configured</p>
<p className="text-xs text-muted-foreground">
Connect your GitHub Copilot token to enable AI-powered features
like chat, summaries, and suggestions.
</p>
</div>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
</Button>
</CardContent>
</Card>
</div>
);
}
const hasMessages = messages.length > 0 || isStreaming;
const contextLabel =
contextType === 'project' && projectName
? `Chatting about: ${projectName}`
: 'Global workspace';
// Derived values for home page
const dueCount = dueTodayQuery.data?.length ?? 0;
const userName = userNameQuery.data ?? 'there';
return (
<div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Context header (non-home) */}
{!isHomePage && (
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
<Badge variant="outline">{contextLabel}</Badge>
</div>
)}
{/* Scrollable messages area */}
<ScrollArea
className="flex-1 min-h-0"
@@ -193,7 +127,6 @@ export function AIChatPanel({
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
}
onWheel={handleWheel}
>
{/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && (
@@ -246,7 +179,6 @@ export function AIChatPanel({
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
isHomePage={isHomePage}
/>
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
{SUGGESTION_CHIPS.map((chip) => (
@@ -336,79 +268,19 @@ export function AIChatPanel({
)}
{/* Non-home messages */}
{!isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-32">
<div className="flex flex-col gap-4">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
);
}
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span>
</div>
<div className="pl-[22px]">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
})}
{/* Streaming AI response */}
{isStreaming && (
<div className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<Sparkles size={16} className="text-foreground" />
<span className="text-sm font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
)}
</div>
)}
</div>
</div>
)}
</ScrollArea>
{/* Fixed input — pinned to the bottom (hidden on home initial state) */}
{!(isHomePage && !hasMessages) && (
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
{hasMessages && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-5 pt-16 pointer-events-none">
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/60 to-background/90" />
<div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
<div className="relative pointer-events-auto mx-auto max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
isHomePage={isHomePage}
/>
</div>
</div>
@@ -425,7 +297,6 @@ interface ChatInputProps {
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
isHomePage?: boolean;
}
function ChatInput({

View File

@@ -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>

View File

@@ -161,7 +161,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
}
return (
<div className="p-6 pe-8 flex flex-col gap-6">
<div className="p-6 flex flex-col gap-6">
{/* Breadcrumb + Project Name */}
<div className="flex flex-col gap-1">
{breadcrumbPath.length > 0 && (