Compare commits
8 Commits
feature/co
...
50b69aadbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b69aadbf | ||
|
|
6cd121fa80 | ||
|
|
28a5d65f1a | ||
|
|
b4e97e14f3 | ||
|
|
78b4df1028 | ||
|
|
96101e4310 | ||
|
|
4b2162505c | ||
|
|
a16e1cc42a |
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"shadcn"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -92,5 +92,5 @@ typings/
|
||||
out/
|
||||
|
||||
# local config files
|
||||
.claude/
|
||||
.vscode/
|
||||
.claude/
|
||||
|
||||
11
.vscode/mcp.json
vendored
11
.vscode/mcp.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"servers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1230
docs/floating-ai-integration-guide.md
Normal file
1230
docs/floating-ai-integration-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
267
src/renderer/components/ai/FloatingChat.tsx
Normal file
267
src/renderer/components/ai/FloatingChat.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area';
|
||||
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 overflow-hidden"
|
||||
>
|
||||
<ScrollArea
|
||||
className="max-h-[300px]"
|
||||
viewportRef={scrollRef}
|
||||
>
|
||||
<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-primary 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 shadow-xl/30">
|
||||
<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 shadow-xl/30">
|
||||
{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>
|
||||
</ScrollArea>
|
||||
</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.25)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.3)] 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
176
src/renderer/context/FloatingChatContext.tsx
Normal file
176
src/renderer/context/FloatingChatContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -50,113 +50,73 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* #f4edf3 - Light Pinkish-White Canvas */
|
||||
--background: oklch(0.945 0.012 328.5);
|
||||
/* #040404 - Almost Black Text */
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
--card: oklch(0.945 0.012 328.5);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(0.945 0.012 328.5);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* #fbc881 - Golden Yellow Accent */
|
||||
--primary: oklch(0.838 0.117 76.8);
|
||||
--primary-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* #8a8ea9 - Slate Blue/Gray */
|
||||
--secondary: oklch(0.627 0.041 274.5);
|
||||
--secondary-foreground: oklch(0.945 0.012 328.5);
|
||||
|
||||
/* #c8c3cd - Light Gray/Purple */
|
||||
--muted: oklch(0.811 0.014 300.2);
|
||||
--muted-foreground: oklch(0.627 0.041 274.5);
|
||||
|
||||
--accent: oklch(0.811 0.014 300.2);
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
--border: oklch(0.811 0.014 300.2);
|
||||
--input: oklch(0.811 0.014 300.2);
|
||||
--ring: oklch(0.838 0.117 76.8);
|
||||
|
||||
/* Kept your original chart colors */
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
/* Sidebar uses the custom palette */
|
||||
--sidebar: oklch(0.945 0.012 328.5);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.838 0.117 76.8);
|
||||
--sidebar-primary-foreground: oklch(0.145 0 0);
|
||||
--sidebar-accent: oklch(0.811 0.014 300.2);
|
||||
--sidebar-accent-foreground: oklch(0.145 0 0);
|
||||
--sidebar-border: oklch(0.811 0.014 300.2);
|
||||
--sidebar-ring: oklch(0.838 0.117 76.8);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* #0c0c0c - Deepest black for the main canvas */
|
||||
--background: oklch(0.15 0 0);
|
||||
/* #fbfbfb - Crisp white for primary text */
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
||||
/* Cards use the main background but are defined by borders */
|
||||
--card: oklch(0.15 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.15 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #fbfbfb - Primary actions (like the active white circle menu item) */
|
||||
--primary: oklch(0.985 0 0);
|
||||
/* #0c0c0c - Dark text/icons inside primary buttons */
|
||||
--primary-foreground: oklch(0.15 0 0);
|
||||
|
||||
/* #323232 - Dark gray for secondary surfaces and button backgrounds */
|
||||
--secondary: oklch(0.335 0 0);
|
||||
/* #fbfbfb - White text on secondary surfaces */
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #323232 - Dark gray for muted backgrounds */
|
||||
--muted: oklch(0.335 0 0);
|
||||
/* #77797b - Mid gray for muted/secondary text (like "ELEVATE YOUR...") */
|
||||
--muted-foreground: oklch(0.555 0 0);
|
||||
|
||||
/* #323232 - Hover states */
|
||||
--accent: oklch(0.335 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216); /* Kept original dark red */
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #323232 - Distinct dark gray borders for the cards/panels */
|
||||
--border: oklch(0.335 0 0);
|
||||
--input: oklch(0.335 0 0);
|
||||
/* #bab7ba - Lighter gray for focus rings to stand out against dark borders */
|
||||
--ring: oklch(0.765 0 0);
|
||||
|
||||
/* Kept your original chart colors */
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
|
||||
/* Sidebar mapped to the new sleek dark palette */
|
||||
--sidebar: oklch(0.15 0 0);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.985 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.15 0 0);
|
||||
--sidebar-accent: oklch(0.335 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.335 0 0);
|
||||
--sidebar-ring: oklch(0.765 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
130
src/renderer/hooks/useAIChat.ts
Normal file
130
src/renderer/hooks/useAIChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
44
src/renderer/hooks/useDoubleClickAI.ts
Normal file
44
src/renderer/hooks/useDoubleClickAI.ts
Normal 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]);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -110,7 +119,7 @@ function TasksPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 pe-8 w-full">
|
||||
<div ref={testRef} data-ai-section="test" className="flex flex-col gap-6 p-6 pe-8 w-full">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Item variant="muted">
|
||||
|
||||
Reference in New Issue
Block a user