7 Commits

Author SHA1 Message Date
Roberto Musso
15051cfa7a feat(floating-ai): step 8 — page interactions (all variants)
Register AI sections across all content pages with dual-anchor scroll
tracking, cross-page navigation via [SECTION:xxx] tags, and right-margin
positioning for the notes editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:15:27 +01:00
Roberto Musso
c5e78311e6 feat: add CLAUDE.md for development guidance and update .gitignore to include .claude directory; refactor AIChatPanel and AppShell components for improved context handling; simplify layout in ProjectDetail, NoteDetailPage, TasksPage, and TimelinePage components 2026-02-28 13:42:52 +01:00
Roberto Musso
60b76c6d97 feat(floating-ai): step 7 — implement morph animation (FLIP)
Add FLIP animation so the floating chat visually morphs into a newly-created
TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId
across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:27:23 +01:00
Roberto Musso
d12681b79f feat(floating-ai): step 6 — pass uiContext through to the AI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:40:00 +01:00
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
19 changed files with 643 additions and 436 deletions

139
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,139 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload
# Build & Package
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
source ~/.nvm/nvm.sh && npm run package # Package without making installers
# Lint
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
# Database migrations (Drizzle)
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
```
There is no test suite currently.
## Architecture Overview
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
### Process Boundaries
```
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
```
1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic
- `index.ts` — Window creation, app lifecycle
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC`
- `lib/trpc.ts``createTRPCReact<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)
## Design Context
### Users
Freelancers and solo professionals managing their own client work — projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload.
### Brand Personality
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified.
### Aesthetic Direction
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`)
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs`
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl`
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional
### Design Principles
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped.
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker.
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.

2
.gitignore vendored
View File

@@ -93,4 +93,4 @@ out/
# local config files # local config files
.vscode/ .vscode/
.claude/

View File

@@ -35,9 +35,9 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 | | 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
| 3 | Create double-click hook | [x] 2026-02-27 | | 3 | Create double-click hook | [x] 2026-02-27 |
| 4 | Build `FloatingChat` component | [x] 2026-02-27 | | 4 | Build `FloatingChat` component | [x] 2026-02-27 |
| 5 | Add `ai:action` IPC side-channel | [ ] | | 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 |
| 6 | Pass `uiContext` through to the AI | [ ] | | 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 |
| 7 | Implement morph animation (FLIP) | [ ] | | 7 | Implement morph animation (FLIP) | [x] 2026-02-28 |
| 8a | Page interactions — Project Detail | [ ] | | 8a | Page interactions — Project Detail | [ ] |
| 8b | Page interactions — Tasks page | [ ] | | 8b | Page interactions — Tasks page | [ ] |
| 8c | Page interactions — Timeline page | [ ] | | 8c | Page interactions — Timeline page | [ ] |
@@ -642,7 +642,7 @@ Use these existing patterns from the codebase:
## Step 5: Add `ai:action` IPC Side-Channel ## Step 5: Add `ai:action` IPC Side-Channel
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Step 4 completed **Prerequisites**: Step 4 completed
**Modifies**: **Modifies**:
- `src/preload/trpc.ts` - `src/preload/trpc.ts`
@@ -740,7 +740,7 @@ currentSender = sender;
## Step 6: Pass `uiContext` Through to the AI ## Step 6: Pass `uiContext` Through to the AI
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Step 5 completed **Prerequisites**: Step 5 completed
**Modifies**: **Modifies**:
- `src/main/router/index.ts` (line ~550-556) - `src/main/router/index.ts` (line ~550-556)
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
## Step 8a: Page Interactions — Project Detail ## Step 8a: Page Interactions — Project Detail
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx` **Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
## Step 8b: Page Interactions — Tasks Page ## Step 8b: Page Interactions — Tasks Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/tasks.tsx` **Modifies**: `src/renderer/routes/tasks.tsx`
@@ -1055,7 +1055,7 @@ Same pattern as 8a — create refs, add `data-ai-section` attributes, register i
## Step 8c: Page Interactions — Timeline Page ## Step 8c: Page Interactions — Timeline Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/timeline.tsx` **Modifies**: `src/renderer/routes/timeline.tsx`
@@ -1082,7 +1082,7 @@ Register 1 section:
## Step 8d: Page Interactions — Notes Page (Milkdown) ## Step 8d: Page Interactions — Notes Page (Milkdown)
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/notes.$noteId.tsx` **Modifies**: `src/renderer/routes/notes.$noteId.tsx`

View File

@@ -24,6 +24,10 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']); const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
const AI_STREAM_CHANNEL = 'ai:stream'; 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 // Types
@@ -31,7 +35,7 @@ const AI_STREAM_CHANNEL = 'ai:stream';
export interface OrchestrateInput { export interface OrchestrateInput {
message: string; message: string;
context: { type: 'global' | 'project'; projectId?: string }; context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
sender?: Electron.WebContents; sender?: Electron.WebContents;
} }
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId, projectId,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
createdAt: Date.now(), createdAt: Date.now(),
}).run(); }).run();
} }
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
return jsonStr; return jsonStr;
} catch { } catch {
return '[]'; return '[]';
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
priority: input.priority ?? 'medium', priority: input.priority ?? 'medium',
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null, dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
projectId: input.projectId ?? null, projectId: input.projectId ?? null,
isAiSuggested: 1,
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
sendAction(currentSender, { type: 'task_created', taskId: id });
return `Task added: ${input.title}`; return `Task added: ${input.title}`;
}, },
{ {
@@ -435,7 +445,7 @@ function buildKnowledgeTools(): StructuredTool[] {
// System prompts // System prompts
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function makeProjectAgentPrompt(contextData: string, withTools = true): string { function makeProjectAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
const toolsSection = withTools ? ` const toolsSection = withTools ? `
You also have access to the following tools — use them proactively when appropriate: You also have access to the following tools — use them proactively when appropriate:
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions. - read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
@@ -454,10 +464,10 @@ ${contextData}
${toolsSection} ${toolsSection}
Answer the user's question based on this project context. Be concise and helpful. Answer the user's question based on this project context. Be concise and helpful.
When referencing tasks, notes, or checkpoints, mention them by name. When referencing tasks, notes, or checkpoints, mention them by name.
If you don't have enough information, say so.`; If you don't have enough information, say so.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
} }
function makeGeneralAgentPrompt(contextData: string, withTools = true): string { function makeGeneralAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
const toolsSection = withTools ? ` const toolsSection = withTools ? `
You also have access to the following tools — use them proactively when appropriate: You also have access to the following tools — use them proactively when appropriate:
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task. - add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
@@ -471,10 +481,10 @@ You have access to the following workspace data:
${contextData} ${contextData}
${toolsSection} ${toolsSection}
Help the user with their question based on this workspace context. Provide concise, actionable answers. Help the user with their question based on this workspace context. Provide concise, actionable answers.
When discussing tasks or projects, reference them by name.`; When discussing tasks or projects, reference them by name.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
} }
function makeKnowledgeAgentPrompt(contextData: string, withTools = true): string { function makeKnowledgeAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
const toolsSection = withTools ? ` const toolsSection = withTools ? `
You have access to the following tools — use them proactively: You have access to the following tools — use them proactively:
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query. - vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
@@ -494,7 +504,7 @@ ${contextData}
${toolsSection} ${toolsSection}
Your primary job is to find and synthesize information from notes across all projects. Your primary job is to find and synthesize information from notes across all projects.
Always use the vector_search_all tool to search for relevant notes before answering. Always use the vector_search_all tool to search for relevant notes before answering.
If no results are found, say so clearly.`; If no results are found, say so clearly.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -505,7 +515,7 @@ const OrchestratorState = Annotation.Root({
/** The user's original message */ /** The user's original message */
userMessage: Annotation<string>(), userMessage: Annotation<string>(),
/** Chat context (global vs project-scoped) */ /** Chat context (global vs project-scoped) */
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string }>(), chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(),
/** The route chosen by the orchestrator */ /** The route chosen by the orchestrator */
route: Annotation<'project' | 'knowledge' | 'general'>(), route: Annotation<'project' | 'knowledge' | 'general'>(),
/** Messages for the specialist agent */ /** Messages for the specialist agent */
@@ -573,7 +583,8 @@ async function projectAgent(state: State): Promise<Partial<State>> {
// Including text tool descriptions in the system prompt causes the model to output // Including text tool descriptions in the system prompt causes the model to output
// XML <tool_call> blocks instead of using the SDK's API-level mechanism. // XML <tool_call> blocks instead of using the SDK's API-level mechanism.
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot'; const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt); const uiContext = state.chatContext.uiContext;
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext);
if (!supportsTools) { if (!supportsTools) {
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)'); console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
@@ -661,7 +672,8 @@ async function knowledgeAgent(state: State): Promise<Partial<State>> {
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName()); const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot'; const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt); const uiContext = state.chatContext.uiContext;
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt, uiContext);
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`); console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
@@ -739,7 +751,8 @@ async function generalAgent(state: State): Promise<Partial<State>> {
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName()); const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot'; const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt); const uiContext = state.chatContext.uiContext;
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt, uiContext);
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`); console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
@@ -849,12 +862,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
sender.send(AI_STREAM_CHANNEL, { token, done }); 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) // Orchestrate (public entry point)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> { export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
const { message, context, sender } = input; const { message, context, sender } = input;
currentSender = sender;
// Quick check: is an LLM available? // Quick check: is an LLM available?
const llm = await getLLM(); const llm = await getLLM();

View File

@@ -552,6 +552,7 @@ const aiRouter = router({
context: z.object({ context: z.object({
type: z.enum(['global', 'project']), type: z.enum(['global', 'project']),
projectId: z.string().optional(), projectId: z.string().optional(),
uiContext: z.string().optional(),
}), }),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
}); });
const AI_STREAM_CHANNEL = 'ai:stream'; const AI_STREAM_CHANNEL = 'ai:stream';
const AI_ACTION_CHANNEL = 'ai:action';
contextBridge.exposeInMainWorld('electronAI', { contextBridge.exposeInMainWorld('electronAI', {
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */ /** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler); 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

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

View File

@@ -1,21 +1,36 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { useRouterState } from '@tanstack/react-router'; import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react'; import { X, ArrowUp } from 'lucide-react';
import { import {
useFloatingChat, useFloatingChat,
computeDualAnchor,
CHAT_WIDTH, CHAT_WIDTH,
CHAT_HEIGHT, CHAT_HEIGHT,
PADDING, PADDING,
} from '@/context/FloatingChatContext'; } from '@/context/FloatingChatContext';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel'; import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { trpc } from '@/lib/trpc';
/** Map section IDs to their routes for cross-page navigation */
const SECTION_ROUTES: Record<string, string> = {
'project-summary': 'project',
'project-timeline': 'project',
'project-tasks': 'project',
'project-notes': 'project',
'tasks-overview': '/tasks',
'tasks-list': '/tasks',
'timeline-chart': '/timeline',
'note-editor': 'note',
};
function FloatingChatInner() { function FloatingChatInner() {
const { state, sections, close } = useFloatingChat(); const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
const utils = trpc.useUtils();
const navigate = useNavigate();
const routerState = useRouterState(); const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname); const prevPathRef = useRef(routerState.location.pathname);
@@ -32,6 +47,32 @@ function FloatingChatInner() {
[activeSection?.projectId, activeSection?.label], [activeSection?.projectId, activeSection?.label],
); );
// Handle [SECTION:xxx] tags from AI responses
const handleSectionTag = useCallback((sectionId: string) => {
// Same-page: section is already registered
const targetSection = sections.get(sectionId);
if (targetSection) {
moveToSection(sectionId);
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
// Cross-page: section not registered, navigate to its route
const route = SECTION_ROUTES[sectionId];
if (!route) return;
setPendingSection({ sectionId });
if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } });
} else if (route.startsWith('/')) {
void navigate({ to: route });
}
// 'note' type requires noteId — skip cross-page for now
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
const { const {
messages, messages,
input, input,
@@ -40,7 +81,7 @@ function FloatingChatInner() {
streamingContent, streamingContent,
handleSend, handleSend,
clearMessages, clearMessages,
} = useAIChat(chatContext); } = useAIChat(chatContext, { onSectionTag: handleSectionTag });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -58,15 +99,15 @@ function FloatingChatInner() {
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]); }, [state.isOpen, close]);
// ---- Close on route change ---- // ---- Close on route change (unless cross-page navigation pending) ----
useEffect(() => { useEffect(() => {
const currentPath = routerState.location.pathname; const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) { if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close(); close();
} }
prevPathRef.current = currentPath; prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, close]); }, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ---- // ---- Clear messages on close ----
@@ -78,6 +119,31 @@ function FloatingChatInner() {
prevOpenRef.current = state.isOpen; prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]); }, [state.isOpen, clearMessages]);
// ---- AI action: morph into newly-created task ----
useEffect(() => {
if (!state.isOpen) return;
const unsubscribe = window.electronAI.onAction((action) => {
if (action.type === 'task_created' && action.taskId) {
// Invalidate task queries so the new TaskRow renders
void utils.tasks.list.invalidate();
// Set the morph target layoutId
setMorphTarget(`task-morph-${action.taskId}`);
// Wait for the TaskRow to render, then close (triggering FLIP)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
close();
});
});
}
});
return unsubscribe;
}, [state.isOpen, utils, setMorphTarget, close]);
// ---- Window resize: keep within bounds ---- // ---- Window resize: keep within bounds ----
useEffect(() => { useEffect(() => {
@@ -97,6 +163,51 @@ function FloatingChatInner() {
return () => window.removeEventListener('resize', handler); return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]); }, [state.isOpen, state.position.x, state.position.y]);
// ---- Scroll tracking: dual-anchor repositioning ----
useEffect(() => {
if (!state.isOpen || !state.activeSectionId) return;
const section = sections.get(state.activeSectionId);
if (!section || section.anchorMode === 'right-margin') return;
const el = section.ref.current;
if (!el) return;
// Find scrollable ancestor
let scrollParent: HTMLElement | null = el.parentElement;
while (scrollParent) {
const style = getComputedStyle(scrollParent);
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
break;
}
// Also check for Radix ScrollArea viewport
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
let rafId: number | null = null;
const handleScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const newPos = computeDualAnchor(section);
if (newPos) {
updatePosition(newPos);
}
// null = fully off-screen → freeze (do nothing)
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
// ---- Auto-scroll messages ---- // ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -160,18 +271,18 @@ function FloatingChatInner() {
animate={{ opacity: 1, height: 'auto', scale: 1 }} animate={{ opacity: 1, height: 'auto', scale: 1 }}
exit={{ opacity: 0, height: 0, scale: 0.97 }} exit={{ opacity: 0, height: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="rounded-2xl overflow-hidden" className="rounded-2xl"
> >
<ScrollArea <div
className="max-h-[300px]" ref={scrollRef}
viewportRef={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"> <div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <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"> <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"> <p className="text-xs whitespace-pre-wrap leading-relaxed">
{msg.content} {msg.content}
</p> </p>
@@ -194,7 +305,7 @@ function FloatingChatInner() {
return ( return (
<div key={msg.id} className="flex justify-start"> <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="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
<div className="text-xs"> <div className="text-xs">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} />
</div> </div>
@@ -206,7 +317,7 @@ function FloatingChatInner() {
{/* Streaming */} {/* Streaming */}
{isStreaming && ( {isStreaming && (
<div className="flex justify-start"> <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"> <div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
{streamingContent ? ( {streamingContent ? (
<div className="text-xs"> <div className="text-xs">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} />
@@ -221,13 +332,13 @@ function FloatingChatInner() {
</div> </div>
)} )}
</div> </div>
</ScrollArea> </div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* ---- Floating input bar ---- */} {/* ---- 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"> <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 */} {/* Close button */}
<button <button
onClick={close} onClick={close}

View File

@@ -1,14 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState } from 'react';
import { Link, useRouterState } from '@tanstack/react-router'; import { Link, useRouterState } from '@tanstack/react-router';
import { motion, useMotionValue, useSpring } from 'framer-motion'; import { LayoutGroup } from 'framer-motion';
import { import {
House, House,
ChartGantt, ChartGantt,
ClipboardCheck, ClipboardCheck,
FolderKanban, FolderKanban,
PanelLeft, PanelLeft,
ChevronUp,
ChevronDown,
Settings, Settings,
Sparkles, Sparkles,
Check, Check,
@@ -71,20 +69,6 @@ interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
} }
/** Walk up the DOM to find the nearest scrollable ancestor. */
function findScrollableAncestor(el: Element | null): Element | null {
if (!el || el === document.body) return null;
const style = window.getComputedStyle(el);
const overflowY = style.overflowY;
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight
) {
return el;
}
return findScrollableAncestor(el.parentElement);
}
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
return ( return (
<FloatingChatProvider> <FloatingChatProvider>
@@ -132,138 +116,23 @@ function AppShellInner({ children }: AppShellProps) {
const isHomePage = currentPath === '/'; const isHomePage = currentPath === '/';
// Curtain is disabled on home page and on /projects without a selected project
const searchObj = routerState.location.search as Record<string, unknown>;
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
const curtainEnabled =
currentPath !== '/' &&
!(currentPath === '/projects' && !projectId);
const curtainEnabledRef = useRef(curtainEnabled);
curtainEnabledRef.current = curtainEnabled;
// Derive AI chat context from current route
const isProjectView = currentPath === '/projects' && !!projectId;
const contextType = isProjectView ? 'project' as const : 'global' as const;
const projectQuery = trpc.projects.get.useQuery(
{ id: projectId ?? '' },
{ enabled: !!projectId },
);
// --- Curtain animation state ---
const [curtainOpen, setCurtainOpen] = useState(false);
const curtainOpenRef = useRef(false);
const y = useMotionValue(0);
const springY = useSpring(y, { stiffness: 300, damping: 30 });
const openCurtain = useCallback(() => {
curtainOpenRef.current = true;
setCurtainOpen(true);
y.set(window.innerHeight);
}, [y]);
const closeCurtain = useCallback(() => {
curtainOpenRef.current = false;
setCurtainOpen(false);
y.set(0);
}, [y]);
const toggleCurtain = useCallback(() => {
if (curtainOpenRef.current) closeCurtain();
else openCurtain();
}, [openCurtain, closeCurtain]);
// Keep curtain position in sync with window height on resize
useEffect(() => {
const handleResize = () => {
if (curtainOpenRef.current) {
y.set(window.innerHeight);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [y]);
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (!curtainEnabledRef.current) return;
toggleCurtain();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [toggleCurtain]);
// Wheel event: overscroll detection
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (!curtainOpenRef.current) {
if (!curtainEnabledRef.current) return;
// Opening: overscroll UP (deltaY < 0) when content is at top
if (e.deltaY < 0) {
const scrollable = findScrollableAncestor(e.target as Element);
const atTop = !scrollable || scrollable.scrollTop === 0;
if (atTop) openCurtain();
}
} else {
// Closing: scroll DOWN (deltaY > 0) while curtain is open
if (e.deltaY > 0) {
closeCurtain();
}
}
};
document.addEventListener('wheel', handleWheel, { passive: true });
return () => document.removeEventListener('wheel', handleWheel);
}, [openCurtain, closeCurtain]);
return ( return (
<> <LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange}> <SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar <AppSidebar
currentPath={currentPath} currentPath={currentPath}
setTokenDialogOpen={setTokenDialogOpen} setTokenDialogOpen={setTokenDialogOpen}
onNavClick={closeCurtain}
/> />
<SidebarInset className="overflow-hidden"> <SidebarInset>
{/* AI Chat layer: always mounted behind the content panel */} {isHomePage ? (
<AIChatPanel <AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)} onOpenSettings={() => setTokenDialogOpen(true)}
contextType={contextType} isHomePage
projectId={projectId} />
projectName={projectQuery.data?.name} ) : (
curtainOpen={isHomePage || curtainOpen} <div className="relative flex flex-col h-full">
isHomePage={isHomePage}
/>
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
{!isHomePage && (
<motion.div
style={{ y: springY }}
className="absolute inset-0 z-10 flex flex-col bg-background"
>
{children} {children}
</div>
{/* Right-edge vertical affordance (non-interactive) */}
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
<div className="flex flex-col items-center gap-1.5 pr-2">
{curtainOpen ? (
<ChevronDown size={10} />
) : (
<ChevronUp size={10} />
)}
<span
className="text-[9px] tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
</span>
</div>
</div>
</motion.div>
)} )}
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
@@ -314,17 +183,16 @@ function AppShellInner({ children }: AppShellProps) {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </LayoutGroup>
); );
} }
interface AppSidebarProps { interface AppSidebarProps {
currentPath: string; currentPath: string;
setTokenDialogOpen: (open: boolean) => void; setTokenDialogOpen: (open: boolean) => void;
onNavClick: () => void;
} }
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) { function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -377,7 +245,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
isActive={isActive} isActive={isActive}
tooltip={label} tooltip={label}
> >
<Link to={to} onClick={onNavClick}> <Link to={to}>
<Icon /> <Icon />
<span>{label}</span> <span>{label}</span>
</Link> </Link>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow'; import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog'; import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
@@ -22,6 +23,7 @@ type KanbanBoardProps = {
}; };
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) { export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
const { state: floatingState } = useFloatingChat();
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId }); const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
onDelete={(id) => deleteTask.mutate({ id })} onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask} onClick={setViewTask}
hideBreadcrumb hideBreadcrumb
layoutId={
floatingState.morphTargetId === `task-morph-${task.id}`
? floatingState.morphTargetId
: undefined
}
/> />
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react'; import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
@@ -16,6 +16,7 @@ import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog'; import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
type ProjectDetailProps = { type ProjectDetailProps = {
projectId: string; projectId: string;
@@ -26,6 +27,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false); const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
// AI section refs
const summaryRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const tasksRef = useRef<HTMLDivElement>(null);
const notesRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
return () => {
unregisterSection('project-summary');
unregisterSection('project-timeline');
unregisterSection('project-tasks');
unregisterSection('project-notes');
};
}, [projectId, registerSection, unregisterSection]);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId }); const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery(); const { data: clientsList } = trpc.clients.list.useQuery();
@@ -161,7 +182,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
} }
return ( return (
<div className="p-6 pe-8 flex flex-col gap-6"> <div className="p-6 flex flex-col gap-6">
{/* Breadcrumb + Project Name */} {/* Breadcrumb + Project Name */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{breadcrumbPath.length > 0 && ( {breadcrumbPath.length > 0 && (
@@ -181,54 +202,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1> <h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div> </div>
{/* Stat Cards */} {/* Project Summary Section */}
<div className="grid grid-cols-3 gap-4"> <div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
<Item variant="muted"> {/* Stat Cards */}
<ItemMedia variant="icon"> <div className="grid grid-cols-3 gap-4">
<FileText /> <Item variant="muted">
</ItemMedia> <ItemMedia variant="icon">
<ItemContent> <FileText />
<ItemTitle>{notesCount}</ItemTitle> </ItemMedia>
<ItemDescription>Notes</ItemDescription> <ItemContent>
</ItemContent> <ItemTitle>{notesCount}</ItemTitle>
</Item> <ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CheckCircle2 /> <CheckCircle2 />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle> <ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription> <ItemDescription>Tasks Complete</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon">
<Milestone />
</ItemMedia>
<ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription>
</ItemContent>
</Item>
</div>
{/* AI Project Summary */}
<Item variant="outline">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<Milestone /> <Sparkles />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle> <ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription> <ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div> </div>
{/* AI Project Summary */}
<Item variant="outline">
<ItemMedia variant="icon">
<Sparkles />
</ItemMedia>
<ItemContent>
<ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent>
</Item>
{/* Project Timeline */} {/* Project Timeline */}
<div className="flex flex-col gap-3"> <div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2> <h2 className="text-lg font-semibold">Project Timeline</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Tasks Kanban */} {/* Tasks Kanban */}
<div className="flex flex-col gap-3"> <div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2> <h2 className="text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Notes */} {/* Notes */}
<div className="flex flex-col gap-3"> <div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Notes</h2> <h2 className="text-lg font-semibold">Notes</h2>
<Button <Button

View File

@@ -1,3 +1,4 @@
import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2 } from 'lucide-react'; import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -57,6 +58,7 @@ export function TaskRow({
onDelete, onDelete,
onClick, onClick,
hideBreadcrumb, hideBreadcrumb,
layoutId,
}: { }: {
task: TaskItem; task: TaskItem;
onToggle: (id: string, status: string | null) => void; onToggle: (id: string, status: string | null) => void;
@@ -64,6 +66,7 @@ export function TaskRow({
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void; onClick?: (task: TaskItem) => void;
hideBreadcrumb?: boolean; hideBreadcrumb?: boolean;
layoutId?: string;
}) { }) {
const isDone = task.status === 'done'; const isDone = task.status === 'done';
@@ -84,10 +87,14 @@ export function TaskRow({
breadcrumb.length > 0 || breadcrumb.length > 0 ||
task.assignee; task.assignee;
const Wrapper = layoutId ? motion.div : 'div';
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <Wrapper
{...wrapperProps}
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${ className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border' isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`} } ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
@@ -146,7 +153,7 @@ export function TaskRow({
)} )}
</div> </div>
)} )}
</div> </Wrapper>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>

View File

@@ -16,6 +16,11 @@ export interface AISection {
label: string; // Human-readable, e.g. "Tasks", "Project Timeline" label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>; ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped projectId?: string; // If section is project-scoped
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
}
export interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
} }
interface FloatingChatState { interface FloatingChatState {
@@ -24,6 +29,7 @@ interface FloatingChatState {
position: { x: number; y: number; width: number }; position: { x: number; y: number; width: number };
morphTargetId: string | null; morphTargetId: string | null;
projectId?: string; projectId?: string;
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
} }
interface FloatingChatContextValue { interface FloatingChatContextValue {
@@ -36,10 +42,12 @@ interface FloatingChatContextValue {
unregisterSection: (id: string) => void; unregisterSection: (id: string) => void;
// Actions // Actions
openAtSection: (sectionId: string) => void; openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
moveToSection: (sectionId: string) => void; moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
close: () => void; close: () => void;
setMorphTarget: (id: string | null) => void; setMorphTarget: (id: string | null) => void;
updatePosition: (pos: { x: number; y: number; width: number }) => void;
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
} }
// ---------- Constants ---------- // ---------- Constants ----------
@@ -50,25 +58,79 @@ export const PADDING = 16;
// ---------- Position computation ---------- // ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } {
return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
};
}
function computeAnchorPosition( function computeAnchorPosition(
sectionRef: RefObject<HTMLElement | null>, section: AISection,
opts?: SectionOpenOpts,
): { x: number; y: number; width: number } { ): { x: number; y: number; width: number } {
const el = sectionRef.current; const el = section.ref.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right';
// Anchor to top-right of section, offset inward if (mode === 'right-margin') {
let x = rect.right - CHAT_WIDTH - PADDING; // Position to the right of the section at the click Y-coordinate
let y = rect.top + PADDING; const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH };
}
// Edge-collision clamping // Default: top-right of section
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)); const rawX = rect.right - CHAT_WIDTH - PADDING;
y = Math.max( const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH };
}
/**
* Dual-anchor recomputation for scroll tracking.
* Returns null when the section is fully off-screen (freeze at last position).
*/
export function computeDualAnchor(
section: AISection,
): { x: number; y: number; width: number } | null {
const el = section.ref.current;
if (!el) return null;
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect();
// Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
// Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) {
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
rect.top + PADDING,
);
return { x, y, width: CHAT_WIDTH };
}
// Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING,
);
return { x, y, width: CHAT_WIDTH };
}
// Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
PADDING, PADDING,
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: CHAT_WIDTH };
} }
@@ -108,6 +170,23 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
const registerSection = useCallback((section: AISection) => { const registerSection = useCallback((section: AISection) => {
sectionsRef.current.set(section.id, section); sectionsRef.current.set(section.id, section);
setSections(new Map(sectionsRef.current)); setSections(new Map(sectionsRef.current));
// Check if there's a pending section to open after cross-page navigation
setState((prev) => {
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
return {
...prev,
isOpen: true,
activeSectionId: section.id,
position,
morphTargetId: null,
projectId: section.projectId,
pendingSection: undefined,
};
}
return prev;
});
}, []); }, []);
const unregisterSection = useCallback((id: string) => { const unregisterSection = useCallback((id: string) => {
@@ -115,11 +194,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
setSections(new Map(sectionsRef.current)); setSections(new Map(sectionsRef.current));
}, []); }, []);
const openAtSection = useCallback((sectionId: string) => { const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId); const section = sectionsRef.current.get(sectionId);
if (!section) return; if (!section) return;
const position = computeAnchorPosition(section.ref); const position = computeAnchorPosition(section, opts);
setState({ setState({
isOpen: true, isOpen: true,
@@ -130,11 +209,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const moveToSection = useCallback((sectionId: string) => { const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId); const section = sectionsRef.current.get(sectionId);
if (!section) return; if (!section) return;
const position = computeAnchorPosition(section.ref); const position = computeAnchorPosition(section, opts);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -157,6 +236,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
setState((prev) => ({ ...prev, morphTargetId: id })); setState((prev) => ({ ...prev, morphTargetId: id }));
}, []); }, []);
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
setState((prev) => ({ ...prev, position: pos }));
}, []);
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
setState((prev) => ({ ...prev, pendingSection: pending }));
}, []);
return ( return (
<FloatingChatCtx.Provider <FloatingChatCtx.Provider
value={{ value={{
@@ -168,6 +255,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
moveToSection, moveToSection,
close, close,
setMorphTarget, setMorphTarget,
updatePosition,
setPendingSection,
}} }}
> >
{children} {children}

View File

@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
clearMessages: () => void; clearMessages: () => void;
} }
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn { export interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void;
}
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => { const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) { if (done) {
const finalContent = streamingContentRef.current; let finalContent = streamingContentRef.current;
// Parse and strip [SECTION:xxx] tag from AI response
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]);
}
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent }, { id: crypto.randomUUID(), role: 'assistant', content: finalContent },

View File

@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useDoubleClickAI(): void { export function useDoubleClickAI(): void {
const { openAtSection, state } = useFloatingChat(); const { openAtSection, moveToSection, sections, state } = useFloatingChat();
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
// If popup is already open at THIS section, do nothing // If popup is already open at THIS section, do nothing
if (state.isOpen && state.activeSectionId === sectionId) return; if (state.isOpen && state.activeSectionId === sectionId) return;
openAtSection(sectionId); // Build opts for right-margin sections
const section = sections.get(sectionId);
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
// If chat is already open at a different section, move (keep conversation)
if (state.isOpen) {
moveToSection(sectionId, opts);
return;
}
openAtSection(sectionId, opts);
}; };
document.addEventListener('dblclick', handler); document.addEventListener('dblclick', handler);
return () => document.removeEventListener('dblclick', handler); return () => document.removeEventListener('dblclick', handler);
}, [openAtSection, state.isOpen, state.activeSectionId]); }, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
} }

View File

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

View File

@@ -19,6 +19,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor'; import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
import { useFloatingChat } from '@/context/FloatingChatContext';
export const Route = createFileRoute('/notes/$noteId')({ export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage, component: NoteDetailPage,
@@ -29,6 +30,21 @@ function NoteDetailPage() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId }); const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
// AI section — register with right-margin anchor mode
const editorRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
const noteProjectId = note?.projectId ?? undefined;
useEffect(() => {
registerSection({
id: 'note-editor',
label: 'Note Editor',
ref: editorRef,
projectId: noteProjectId,
anchorMode: 'right-margin',
});
return () => unregisterSection('note-editor');
}, [noteId, noteProjectId, registerSection, unregisterSection]);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -139,7 +155,7 @@ function NoteDetailPage() {
} }
return ( return (
<div className="flex h-full min-h-0 pe-8 flex-col"> <div className="flex h-full min-h-0 flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3"> <div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Button <Button
@@ -188,7 +204,7 @@ function NoteDetailPage() {
</div> </div>
{/* Editor */} {/* Editor */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
<div className="px-4 py-4"> <div className="px-4 py-4">
<MilkdownEditor <MilkdownEditor
key={noteId} key={noteId}

View File

@@ -41,12 +41,17 @@ const ORDER_LABELS: Record<OrderBy, string> = {
}; };
function TasksPage() { function TasksPage() {
// Temporary test: register section for floating AI chat // AI section refs
const testRef = useRef<HTMLDivElement>(null); const overviewRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat(); const listRef = useRef<HTMLDivElement>(null);
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
useEffect(() => { useEffect(() => {
registerSection({ id: 'test', label: 'Tasks', ref: testRef }); registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
return () => unregisterSection('test'); registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
return () => {
unregisterSection('tasks-overview');
unregisterSection('tasks-list');
};
}, [registerSection, unregisterSection]); }, [registerSection, unregisterSection]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -119,9 +124,9 @@ function TasksPage() {
); );
return ( return (
<div ref={testRef} data-ai-section="test" className="flex flex-col gap-6 p-6 pe-8 w-full"> <div className="flex flex-col gap-6 p-6 w-full">
{/* Stat Cards */} {/* Stat Cards */}
<div className="grid grid-cols-4 gap-4"> <div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<ClipboardCheck /> <ClipboardCheck />
@@ -160,50 +165,52 @@ function TasksPage() {
</Item> </Item>
</div> </div>
{/* Search + Order By */} {/* Task List Section */}
<div className="flex items-center gap-3"> <div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
<InputGroup className="flex-1"> {/* Search + Order By */}
<InputGroupAddon> <div className="flex items-center gap-3">
<Search /> <InputGroup className="flex-1">
</InputGroupAddon> <InputGroupAddon>
<InputGroupInput <Search />
placeholder="Search tasks or projects..." </InputGroupAddon>
value={search} <InputGroupInput
onChange={(e) => handleSearchChange(e.target.value)} placeholder="Search tasks or projects..."
/> value={search}
</InputGroup> onChange={(e) => handleSearchChange(e.target.value)}
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}> />
<SelectTrigger className="w-[180px]"> </InputGroup>
<SelectValue placeholder="Order by" /> <Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
</SelectTrigger> <SelectTrigger className="w-[180px]">
<SelectContent> <SelectValue placeholder="Order by" />
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => ( </SelectTrigger>
<SelectItem key={key} value={key}> <SelectContent>
{label} {(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
</SelectItem> <SelectItem key={key} value={key}>
))} {label}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
</Select>
</div>
{/* Status Filter Tabs + New Task Button */} {/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}> <Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList> <TabsList>
<TabsTrigger value="all">All</TabsTrigger> <TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger> <TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger> <TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger> <TabsTrigger value="done">Completed</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button size="sm" onClick={() => setDialogOpen(true)}> <Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
New Task New Task
</Button> </Button>
</div> </div>
{/* Task List */} {/* Task List */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{tasksList.length === 0 ? ( {tasksList.length === 0 ? (
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
@@ -225,9 +232,15 @@ function TasksPage() {
onEdit={setEditTask} onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })} onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask} onClick={setViewTask}
layoutId={
floatingState.morphTargetId === `task-morph-${task.id}`
? floatingState.morphTargetId
: undefined
}
/> />
)) ))
)} )}
</div>
</div> </div>
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />

View File

@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
@@ -15,6 +16,14 @@ function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
// AI section
const timelineRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef });
return () => unregisterSection('timeline-chart');
}, [registerSection, unregisterSection]);
const { data: checkpoints } = trpc.checkpoints.list.useQuery({}); const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery(); const { data: projectsList } = trpc.projects.listAll.useQuery();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -70,7 +79,7 @@ function TimelinePage() {
}, [ganttCheckpoints]); }, [ganttCheckpoints]);
return ( return (
<div className="flex flex-col gap-6 p-6 pe-8 w-full"> <div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Timeline</h1> <h1 className="text-xl font-semibold">Timeline</h1>