2 Commits

Author SHA1 Message Date
Roberto Musso
78b4df1028 chore: remove unused Claude configuration files and update .gitignore 2026-02-27 22:35:18 +01:00
Roberto Musso
96101e4310 feat(floating-ai): step 1 — extract shared useAIChat hook
Refactor AIChatPanel to consume the existing useAIChat hook instead of
managing chat state inline. Removes duplicate ChatMessage interface,
inline state (messages, input, isStreaming, streamingContent), and the
65-line handleSend callback, replacing them with a single useAIChat()
call and a thin briefLoading guard wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:27:11 +01:00
6 changed files with 24 additions and 211 deletions

View File

@@ -1,110 +0,0 @@
# 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<AppRouter>()` 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/<feature>/`, 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`. `<tool_call>` 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)

View File

@@ -1,6 +0,0 @@
{
"enabledMcpjsonServers": [
"shadcn"
],
"enableAllProjectMcpServers": true
}

2
.gitignore vendored
View File

@@ -92,5 +92,5 @@ typings/
out/
# local config files
.claude/
.vscode/
.claude/

11
.vscode/mcp.json vendored
View File

@@ -1,11 +0,0 @@
{
"servers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

View File

@@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl
### Workflow Protocol for Each Step
1. **Start a new chat** and say: _"Read `docs/floating-ai-integration-guide.md` and implement Step X"_
1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_
2. **Before writing any code**, the agent MUST:
- Read ALL files listed in the step's "Files to Read First" section
- Read the project's `CLAUDE.md` for build/lint commands and conventions
@@ -31,7 +31,7 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| Step | Title | Status |
|------|-------|--------|
| 1 | Extract shared `useAIChat` hook | [ ] |
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
| 2 | Create section registry + `FloatingChatContext` | [ ] |
| 3 | Create double-click hook | [ ] |
| 4 | Build `FloatingChat` component | [ ] |
@@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists:
## Step 1: Extract Shared `useAIChat` Hook
**Status**: [ ]
**Status**: [x] 2026-02-27
**Prerequisites**: None
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
**Modifies**: `src/renderer/components/ai/AIChatPanel.tsx`

View File

@@ -1,21 +1,15 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
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';
import { ScrollArea } from '@/components/ui/scroll-area';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' },
@@ -46,10 +40,21 @@ export function AIChatPanel({
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const chatContext = useMemo<ChatContext>(
() => ({
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
}),
[contextType, projectId],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
@@ -59,8 +64,6 @@ export function AIChatPanel({
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => {
@@ -116,72 +119,9 @@ export function AIChatPanel({
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming || briefLoading) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]);
if (briefLoading) return;
chatHandleSend();
}, [briefLoading, chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {