12 Commits

Author SHA1 Message Date
Roberto Musso
6c498c5f40 feat(floating-ai): step 5 — add ai:action IPC side-channel
Add a new ai:action IPC channel so the renderer can react to AI tool
side-effects (task creation, checkpoint/task suggestions). Also mark
AI-created tasks with isAiSuggested: 1 in both project and global
add_task tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:23:04 +01:00
Roberto Musso
310370ef66 fix(floating-ai): replace ScrollArea with div for message container in FloatingChat 2026-02-28 07:59:24 +01:00
Roberto Musso
f4e6238176 fix(tasks): adjust Floating AI chat section registration and styling 2026-02-28 00:16:54 +01:00
Roberto Musso
d8cf7814ab Merge branch 'feature/color' into feature/popup-ai 2026-02-27 23:56:19 +01:00
Roberto Musso
50b69aadbf feat(tasks): register section for floating AI chat in TasksPage 2026-02-27 23:56:08 +01:00
Roberto Musso
6cd121fa80 feat(floating-ai): step 4 — build FloatingChat component
Create the floating AI chat popup rendered via portal to document.body.
Uses useAIChat for chat logic, useFloatingChat for position/state,
Framer Motion for enter/exit animations, and pointer-event dragging.

Includes: close on Escape, close on route change, auto-scroll,
auto-focus, window resize clamping, and compact message rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:05:25 +01:00
Roberto Musso
28a5d65f1a feat(floating-ai): step 3 — create double-click hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:49:16 +01:00
Roberto Musso
b4e97e14f3 feat(floating-ai): step 2 — create section registry + FloatingChatContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:43:05 +01:00
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
Roberto Musso
4b2162505c Merge branch 'develop' into feature/popup-ai 2026-02-26 23:46:14 +01:00
Roberto Musso
a16e1cc42a feat: add custom hook useAIChat for managing AI chat interactions
- Implemented useAIChat hook to handle chat messages, input state, and streaming responses.
- Added types for ChatMessage and ChatContext to ensure type safety.
- Integrated TRPC mutation for sending messages and handling responses.
- Included functionality for clearing messages and managing streaming content.
2026-02-26 23:31:41 +01:00
15 changed files with 1922 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"
]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,10 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
let currentSender: Electron.WebContents | undefined;
// ---------------------------------------------------------------------------
// Types
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId,
isAiSuggested: 1,
createdAt: Date.now(),
})
.run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`;
},
{
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(),
}).run();
}
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
return jsonStr;
} catch {
return '[]';
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(),
}).run();
}
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
return jsonStr;
} catch {
return '[]';
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId: input.projectId ?? null,
isAiSuggested: 1,
createdAt: Date.now(),
})
.run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`;
},
{
@@ -849,12 +859,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
sender.send(AI_STREAM_CHANNEL, { token, done });
}
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
if (!sender || sender.isDestroyed()) return;
sender.send(AI_ACTION_CHANNEL, action);
}
// ---------------------------------------------------------------------------
// Orchestrate (public entry point)
// ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input;
currentSender = sender;
// Quick check: is an LLM available?
const llm = await getLLM();

View File

@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
});
const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
};
},
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
};
},
});

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) {
@@ -521,7 +461,7 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
function ChatMarkdown({ content }: { content: string }) {
export function ChatMarkdown({ content }: { content: string }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown

View File

@@ -0,0 +1,266 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react';
import {
useFloatingChat,
CHAT_WIDTH,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Skeleton } from '@/components/ui/skeleton';
function FloatingChatInner() {
const { state, sections, close } = useFloatingChat();
const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname);
// Active section lookup
const activeSection = sections.get(state.activeSectionId ?? '');
// Chat context derived from active section
const chatContext = useMemo<ChatContext>(
() => ({
type: activeSection?.projectId ? 'project' : 'global',
projectId: activeSection?.projectId,
uiContext: activeSection?.label,
}),
[activeSection?.projectId, activeSection?.label],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext);
const containerRef = useRef<HTMLDivElement>(null);
// ---- Close on Escape ----
useEffect(() => {
if (!state.isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]);
// ---- Close on route change ----
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) {
close();
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, close]);
// ---- Clear messages on close ----
const prevOpenRef = useRef(state.isOpen);
useEffect(() => {
if (prevOpenRef.current && !state.isOpen) {
clearMessages();
}
prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]);
// ---- Window resize: keep within bounds ----
useEffect(() => {
if (!state.isOpen) return;
const handler = () => {
// Re-anchor if the container would go offscreen
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`;
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
}
}
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]);
// ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
// ---- Auto-focus input on open ----
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
// ---- Input handling ----
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const hasMessages = messages.length > 0 || isStreaming;
return (
<AnimatePresence>
{state.isOpen && (
<motion.div
ref={containerRef}
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: state.position.x,
top: state.position.y,
width: state.position.width,
zIndex: 9999,
}}
className="flex flex-col gap-2"
>
{/* ---- Messages panel (appears when chat has content) ---- */}
<AnimatePresence>
{hasMessages && (
<motion.div
key="messages-panel"
initial={{ opacity: 0, height: 0, scale: 0.97 }}
animate={{ opacity: 1, height: 'auto', scale: 1 }}
exit={{ opacity: 0, height: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="rounded-2xl"
>
<div
ref={scrollRef}
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border"
>
<div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-accent text-primary-foreground px-3.5 py-2 shadow-sm">
<p className="text-xs whitespace-pre-wrap leading-relaxed">
{msg.content}
</p>
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2">
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content}
</p>
</div>
</div>
);
}
return (
<div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
<div className="text-xs">
<ChatMarkdown content={msg.content} />
</div>
</div>
</div>
);
})}
{/* Streaming */}
{isStreaming && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
{streamingContent ? (
<div className="text-xs">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-1.5 py-0.5">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ---- Floating input bar ---- */}
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.5)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.7)] focus-within:ring-ring/20">
{/* Close button */}
<button
onClick={close}
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
>
<X size={10} />
</button>
<div className="flex items-center gap-2 px-3 py-2.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowUp size={14} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}

View File

@@ -18,6 +18,7 @@ import {
Palette
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import {
Sidebar,
SidebarContent,
@@ -55,7 +56,9 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
@@ -83,6 +86,16 @@ function findScrollableAncestor(el: Element | null): Element | null {
}
export function AppShell({ children }: AppShellProps) {
return (
<FloatingChatProvider>
<AppShellInner>{children}</AppShellInner>
</FloatingChatProvider>
);
}
function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
});
@@ -255,6 +268,9 @@ export function AppShell({ children }: AppShellProps) {
</SidebarInset>
</SidebarProvider>
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open);

View File

@@ -0,0 +1,176 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useState,
useRef,
type ReactNode,
type RefObject,
} from 'react';
// ---------- Types ----------
export interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped
}
interface FloatingChatState {
isOpen: boolean;
activeSectionId: string | null;
position: { x: number; y: number; width: number };
morphTargetId: string | null;
projectId?: string;
}
interface FloatingChatContextValue {
// State
state: FloatingChatState;
sections: Map<string, AISection>;
// Section registry
registerSection: (section: AISection) => void;
unregisterSection: (id: string) => void;
// Actions
openAtSection: (sectionId: string) => void;
moveToSection: (sectionId: string) => void;
close: () => void;
setMorphTarget: (id: string | null) => void;
}
// ---------- Constants ----------
export const CHAT_WIDTH = 380;
export const CHAT_HEIGHT = 420;
export const PADDING = 16;
// ---------- Position computation ----------
function computeAnchorPosition(
sectionRef: RefObject<HTMLElement | null>,
): { x: number; y: number; width: number } {
const el = sectionRef.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
const rect = el.getBoundingClientRect();
// Anchor to top-right of section, offset inward
let x = rect.right - CHAT_WIDTH - PADDING;
let y = rect.top + PADDING;
// Edge-collision clamping
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
y = Math.max(
PADDING,
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
);
return { x, y, width: CHAT_WIDTH };
}
// ---------- Context ----------
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
export function useFloatingChat(): FloatingChatContextValue {
const ctx = useContext(FloatingChatCtx);
if (!ctx)
throw new Error('useFloatingChat must be used within FloatingChatProvider');
return ctx;
}
// Convenience hook for pages to register a section
export function useAISection(section: AISection): void {
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection(section);
return () => unregisterSection(section.id);
}, [section.id, registerSection, unregisterSection]);
}
// ---------- Provider ----------
export function FloatingChatProvider({ children }: { children: ReactNode }) {
const sectionsRef = useRef<Map<string, AISection>>(new Map());
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
const [state, setState] = useState<FloatingChatState>({
isOpen: false,
activeSectionId: null,
position: { x: 0, y: 0, width: CHAT_WIDTH },
morphTargetId: null,
});
const registerSection = useCallback((section: AISection) => {
sectionsRef.current.set(section.id, section);
setSections(new Map(sectionsRef.current));
}, []);
const unregisterSection = useCallback((id: string) => {
sectionsRef.current.delete(id);
setSections(new Map(sectionsRef.current));
}, []);
const openAtSection = useCallback((sectionId: string) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section.ref);
setState({
isOpen: true,
activeSectionId: sectionId,
position,
morphTargetId: null,
projectId: section.projectId,
});
}, []);
const moveToSection = useCallback((sectionId: string) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section.ref);
setState((prev) => ({
...prev,
activeSectionId: sectionId,
position,
projectId: section.projectId,
}));
}, []);
const close = useCallback(() => {
setState((prev) => ({
...prev,
isOpen: false,
activeSectionId: null,
morphTargetId: null,
}));
}, []);
const setMorphTarget = useCallback((id: string | null) => {
setState((prev) => ({ ...prev, morphTargetId: id }));
}, []);
return (
<FloatingChatCtx.Provider
value={{
state,
sections,
registerSection,
unregisterSection,
openAtSection,
moveToSection,
close,
setMorphTarget,
}}
>
{children}
</FloatingChatCtx.Provider>
);
}

View File

@@ -0,0 +1,130 @@
import { useState, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
export interface ChatContext {
type: 'global' | 'project';
projectId?: string;
uiContext?: string;
}
export interface UseAIChatReturn {
messages: ChatMessage[];
input: string;
setInput: (v: string) => void;
isStreaming: boolean;
streamingContent: string;
handleSend: (overrideMessage?: string, overrideContext?: ChatContext) => void;
clearMessages: () => void;
}
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const clearMessages = useCallback(() => {
setMessages([]);
setStreamingContent('');
streamingContentRef.current = '';
}, []);
const handleSend = useCallback(
(overrideMessage?: string, overrideContext?: ChatContext) => {
const trimmed = (overrideMessage ?? input).trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
if (!overrideMessage) 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);
});
const ctx = overrideContext ?? defaultContext;
chatMutation.mutate(
{
message: trimmed,
context: {
type: ctx.type,
...(ctx.type === 'project' && ctx.projectId ? { projectId: ctx.projectId } : {}),
...(ctx.uiContext ? { uiContext: ctx.uiContext } : {}),
},
},
{
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, defaultContext, chatMutation],
);
return {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
};
}

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useFloatingChat } from '@/context/FloatingChatContext';
// Elements where double-click should NOT trigger the AI popup
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useDoubleClickAI(): void {
const { openAtSection, state } = useFloatingChat();
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Skip interactive elements (preserve text selection behavior)
if (INTERACTIVE_TAGS.has(target.tagName)) return;
// Skip contenteditable elements UNLESS they're inside Milkdown
if (target.isContentEditable) {
const inMilkdown =
target.closest('.milkdown-container') ||
target.closest('.crepe-editor');
if (!inMilkdown) return;
// For Milkdown: only trigger if no text was selected by the double-click
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
}
// Walk up DOM to find nearest [data-ai-section]
const sectionEl = (target as Element).closest('[data-ai-section]');
if (!sectionEl) return;
const sectionId = sectionEl.getAttribute('data-ai-section');
if (!sectionId) return;
// If popup is already open at THIS section, do nothing
if (state.isOpen && state.activeSectionId === sectionId) return;
openAtSection(sectionId);
};
document.addEventListener('dblclick', handler);
return () => document.removeEventListener('dblclick', handler);
}, [openAtSection, state.isOpen, state.activeSectionId]);
}

View File

@@ -15,6 +15,7 @@ interface ElectronTRPC {
interface ElectronAI {
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
}
declare global {

View File

@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import {
ClipboardCheck,
ListTodo,
@@ -40,6 +41,14 @@ const ORDER_LABELS: Record<OrderBy, string> = {
};
function TasksPage() {
// Temporary test: register section for floating AI chat
const testRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
return () => unregisterSection('test');
}, [registerSection, unregisterSection]);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@@ -178,7 +187,7 @@ function TasksPage() {
</div>
{/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between">
<div ref={testRef} data-ai-section="test" className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>