From c5e78311e63863c117ce340adbaa3f4d38a59e21 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Sat, 28 Feb 2026 13:42:52 +0100 Subject: [PATCH] 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 --- .claude/CLAUDE.md | 139 +++++++++++++++ .gitignore | 2 +- src/renderer/components/ai/AIChatPanel.tsx | 139 +-------------- src/renderer/components/layout/AppShell.tsx | 158 ++---------------- .../components/projects/ProjectDetail.tsx | 2 +- src/renderer/routes/notes.$noteId.tsx | 2 +- src/renderer/routes/tasks.tsx | 2 +- src/renderer/routes/timeline.tsx | 2 +- 8 files changed, 162 insertions(+), 284 deletions(-) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..aea9e9d --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,139 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development +source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload + +# Build & Package +source ~/.nvm/nvm.sh && npm run make # Build distributable packages +source ~/.nvm/nvm.sh && npm run package # Package without making installers + +# Lint +source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files + +# Database migrations (Drizzle) +source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes +source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only) +``` + +There is no test suite currently. + +## Architecture Overview + +Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used). + +### Process Boundaries + +``` +Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite) +``` + +1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic + - `index.ts` — Window creation, app lifecycle + - `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures + - `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai) + - `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()` + - `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes) + - `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`) + +2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()` + +3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly + - `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC` + - `lib/trpc.ts` — `createTRPCReact()` typed client + - `index.tsx` — QueryClient + tRPC + Router providers + - All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()` + +### Routing + +File-based routing via TanStack Router. Add a file to `src/renderer/routes/` and the route tree (`src/renderer/routeTree.gen.ts`) is auto-regenerated by the Vite plugin on next `npm start`. Routes: +- `__root.tsx` — Root layout wrapping everything in `AppShell` +- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx` + +### Database + +Schema lives in `src/main/db/schema.ts`. Migrations are in `src/main/db/migrations/`. The DB is created in Electron's `userData` directory as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations (CREATE TABLE IF NOT EXISTS). + +To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file. + +### Adding a New Feature (end-to-end pattern) + +1. **Schema** — Add table/columns to `src/main/db/schema.ts` +2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter` +3. **Types** — `AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically +4. **UI** — Create components under `src/renderer/components//`, use `trpc.*.*useQuery()` for data + +### AI Subsystem (`src/main/ai/`) + +LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot). + +**Orchestrator** (`orchestrator.ts`): Classifies user intent → routes to one of three specialist agents: +- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` +- **Knowledge agent** — cross-project semantic search via `vector_search_all` +- **General agent** — workspace-wide `add_task` + +Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally). + +**Streaming**: Orchestrator calls `sendStreamChunk(sender, token, done)` over IPC channel `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `` blocks are filtered before sending to renderer. + +**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled. + +**Token storage** (`token.ts`) — three-tier fallback: +1. keytar (OS keychain) — preferred, encrypted per-user +2. electron-store + `safeStorage` — encrypted at rest +3. Plain electron-store — WSL fallback + +Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session. + +**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records. + +### Vector Embeddings (`src/main/db/vectordb.ts`) + +LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content, vector }`. Vectors are 1536-dimensional (`text-embedding-3-small`). Embeddings use a priority chain: Copilot CLI token → OpenAI token. + +- Note create/update fires `upsertNoteEmbedding()` (fire-and-forget, errors swallowed) +- `migrateNotesIfNeeded()` backfills existing notes on first startup +- `searchNotes(query, limit=5)` is called by the Knowledge agent tool + +### Key Config Notes + +- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin +- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias) +- shadcn/ui style: **new-york**, base color: **neutral** +- Icons: **lucide-react** throughout — do not introduce other icon libraries +- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js` +- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`) +- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param) + +## Design Context + +### Users +Freelancers and solo professionals managing their own client work — projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload. + +### Brand Personality +**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified. + +### Aesthetic Direction +- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent +- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`) +- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white +- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs` +- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl` +- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions +- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional + +### Design Principles + +1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped. + +2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker. + +3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe. + +4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships. + +5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises. \ No newline at end of file diff --git a/.gitignore b/.gitignore index c17f069..bf9f95a 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ out/ # local config files .vscode/ -.claude/ + diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index 3443a62..2e3f4de 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -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( - () => ({ - 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 ( -
- - - -
-

AI provider not configured

-

- Connect your GitHub Copilot token to enable AI-powered features - like chat, summaries, and suggestions. -

-
- -
-
-
- ); - } 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 (
- {/* Context header (non-home) */} - {!isHomePage && ( -
- {contextLabel} -
- )} - {/* Scrollable messages area */} 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} />
{SUGGESTION_CHIPS.map((chip) => ( @@ -336,79 +268,19 @@ export function AIChatPanel({ )} {/* Non-home messages */} - {!isHomePage && hasMessages && ( -
-
- {messages.map((msg) => { - if (msg.role === 'user') { - return ( -
-
- -
-
- ); - } - - if (msg.error) { - return ( -
-

- {msg.content} -

-
- ); - } - - return ( -
-
- - Adiuva -
-
- -
-
- ); - })} - - {/* Streaming AI response */} - {isStreaming && ( -
-
- - Adiuva -
- {streamingContent ? ( -
- -
- ) : ( -
- - -
- )} -
- )} -
-
- )} - {/* Fixed input — pinned to the bottom (hidden on home initial state) */} - {!(isHomePage && !hasMessages) && ( + {/* Fixed input — pinned to the bottom (hidden on initial state) */} + {hasMessages && (
-
+
@@ -425,7 +297,6 @@ interface ChatInputProps { onInputChange: (value: string) => void; onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; - isHomePage?: boolean; } function ChatInput({ diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx index f0714dc..d1929a7 100644 --- a/src/renderer/components/layout/AppShell.tsx +++ b/src/renderer/components/layout/AppShell.tsx @@ -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 ( @@ -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; - 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 ( - - {/* AI Chat layer: always mounted behind the content panel */} - 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 && ( - + + {isHomePage ? ( + setTokenDialogOpen(true)} + isHomePage + /> + ) : ( +
{children} - - {/* Right-edge vertical affordance (non-interactive) */} -
-
- {curtainOpen ? ( - - ) : ( - - )} - - {curtainOpen ? 'back to app' : 'scrolling up for Adiuva'} - -
-
- +
)}
@@ -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} > - + {label} diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index 3bc8c15..3ad110f 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -161,7 +161,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) { } return ( -
+
{/* Breadcrumb + Project Name */}
{breadcrumbPath.length > 0 && ( diff --git a/src/renderer/routes/notes.$noteId.tsx b/src/renderer/routes/notes.$noteId.tsx index 7d18c48..a219e3e 100644 --- a/src/renderer/routes/notes.$noteId.tsx +++ b/src/renderer/routes/notes.$noteId.tsx @@ -139,7 +139,7 @@ function NoteDetailPage() { } return ( -
+
{/* Header */}