Compare commits
9 Commits
50b69aadbf
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15051cfa7a | ||
|
|
c5e78311e6 | ||
|
|
60b76c6d97 | ||
|
|
d12681b79f | ||
|
|
6c498c5f40 | ||
|
|
310370ef66 | ||
|
|
f4e6238176 | ||
|
|
d8cf7814ab | ||
|
|
9c07d3195f |
139
.claude/CLAUDE.md
Normal file
139
.claude/CLAUDE.md
Normal 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
2
.gitignore
vendored
@@ -93,4 +93,4 @@ out/
|
||||
|
||||
# local config files
|
||||
.vscode/
|
||||
.claude/
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
|
||||
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
||||
| 3 | Create double-click hook | [x] 2026-02-27 |
|
||||
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
||||
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
||||
| 6 | Pass `uiContext` through to the AI | [ ] |
|
||||
| 7 | Implement morph animation (FLIP) | [ ] |
|
||||
| 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 |
|
||||
| 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 |
|
||||
| 7 | Implement morph animation (FLIP) | [x] 2026-02-28 |
|
||||
| 8a | Page interactions — Project Detail | [ ] |
|
||||
| 8b | Page interactions — Tasks 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
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Step 4 completed
|
||||
**Modifies**:
|
||||
- `src/preload/trpc.ts`
|
||||
@@ -740,7 +740,7 @@ currentSender = sender;
|
||||
|
||||
## Step 6: Pass `uiContext` Through to the AI
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Step 5 completed
|
||||
**Modifies**:
|
||||
- `src/main/router/index.ts` (line ~550-556)
|
||||
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
|
||||
|
||||
## Step 8a: Page Interactions — Project Detail
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
||||
|
||||
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
|
||||
|
||||
## Step 8b: Page Interactions — Tasks Page
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**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
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/routes/timeline.tsx`
|
||||
|
||||
@@ -1082,7 +1082,7 @@ Register 1 section:
|
||||
|
||||
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
||||
|
||||
**Status**: [ ]
|
||||
**Status**: [x] (2026-02-28)
|
||||
**Prerequisites**: Steps 1-4 completed
|
||||
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
|
||||
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
|
||||
let currentSender: Electron.WebContents | undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -31,7 +35,7 @@ const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
|
||||
export interface OrchestrateInput {
|
||||
message: string;
|
||||
context: { type: 'global' | 'project'; projectId?: string };
|
||||
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
||||
priority: input.priority ?? 'medium',
|
||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||
projectId,
|
||||
isAiSuggested: 1,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||
return `Task added: ${input.title}`;
|
||||
},
|
||||
{
|
||||
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return '[]';
|
||||
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return '[]';
|
||||
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
|
||||
priority: input.priority ?? 'medium',
|
||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||
projectId: input.projectId ?? null,
|
||||
isAiSuggested: 1,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||
return `Task added: ${input.title}`;
|
||||
},
|
||||
{
|
||||
@@ -435,7 +445,7 @@ function buildKnowledgeTools(): StructuredTool[] {
|
||||
// System prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeProjectAgentPrompt(contextData: string, withTools = true): string {
|
||||
function makeProjectAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||
const toolsSection = withTools ? `
|
||||
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.
|
||||
@@ -454,10 +464,10 @@ ${contextData}
|
||||
${toolsSection}
|
||||
Answer the user's question based on this project context. Be concise and helpful.
|
||||
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 ? `
|
||||
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.
|
||||
@@ -471,10 +481,10 @@ You have access to the following workspace data:
|
||||
${contextData}
|
||||
${toolsSection}
|
||||
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 ? `
|
||||
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.
|
||||
@@ -494,7 +504,7 @@ ${contextData}
|
||||
${toolsSection}
|
||||
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.
|
||||
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 */
|
||||
userMessage: Annotation<string>(),
|
||||
/** 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 */
|
||||
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
||||
/** 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
|
||||
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt);
|
||||
const uiContext = state.chatContext.uiContext;
|
||||
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||
|
||||
if (!supportsTools) {
|
||||
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 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}`);
|
||||
|
||||
@@ -739,7 +751,8 @@ async function generalAgent(state: State): Promise<Partial<State>> {
|
||||
|
||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||
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}`);
|
||||
|
||||
@@ -849,12 +862,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
|
||||
sender.send(AI_STREAM_CHANNEL, { token, done });
|
||||
}
|
||||
|
||||
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(AI_ACTION_CHANNEL, action);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate (public entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
||||
const { message, context, sender } = input;
|
||||
currentSender = sender;
|
||||
|
||||
// Quick check: is an LLM available?
|
||||
const llm = await getLLM();
|
||||
|
||||
@@ -552,6 +552,7 @@ const aiRouter = router({
|
||||
context: z.object({
|
||||
type: z.enum(['global', 'project']),
|
||||
projectId: z.string().optional(),
|
||||
uiContext: z.string().optional(),
|
||||
}),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
|
||||
});
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
||||
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
|
||||
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
|
||||
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
|
||||
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -19,19 +18,11 @@ const SUGGESTION_CHIPS = [
|
||||
|
||||
interface AIChatPanelProps {
|
||||
onOpenSettings?: () => void;
|
||||
contextType: 'global' | 'project';
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
curtainOpen: boolean;
|
||||
isHomePage?: boolean;
|
||||
}
|
||||
|
||||
export function AIChatPanel({
|
||||
onOpenSettings,
|
||||
contextType,
|
||||
projectId,
|
||||
projectName,
|
||||
curtainOpen,
|
||||
isHomePage,
|
||||
}: AIChatPanelProps) {
|
||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||
@@ -41,11 +32,8 @@ export function AIChatPanel({
|
||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
() => ({
|
||||
type: contextType,
|
||||
...(contextType === 'project' && projectId ? { projectId } : {}),
|
||||
}),
|
||||
[contextType, projectId],
|
||||
() => ({ type: 'global' as const }),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
messages,
|
||||
@@ -71,15 +59,6 @@ export function AIChatPanel({
|
||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||
}, []);
|
||||
|
||||
// Reset input when curtain closes; scroll to bottom when it reopens
|
||||
useEffect(() => {
|
||||
if (!curtainOpen) {
|
||||
setInput('');
|
||||
} else {
|
||||
setTimeout(scrollToBottom, 50);
|
||||
}
|
||||
}, [curtainOpen, scrollToBottom]);
|
||||
|
||||
// Auto-scroll when messages change or streaming content updates
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
@@ -130,60 +109,15 @@ export function AIChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Smart wheel handler: only stop propagation when there's content to scroll through
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
const el = messagesContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
|
||||
const atTop = el.scrollTop < 2;
|
||||
// Let event propagate to AppShell when at boundaries
|
||||
if ((e.deltaY > 0 && atBottom) || (e.deltaY < 0 && atTop)) return;
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// No token configured — show settings prompt
|
||||
if (hasTokenQuery.data === false && !isHomePage) {
|
||||
return (
|
||||
<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 contextLabel =
|
||||
contextType === 'project' && projectName
|
||||
? `Chatting about: ${projectName}`
|
||||
: 'Global workspace';
|
||||
|
||||
// Derived values for home page
|
||||
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||
const userName = userNameQuery.data ?? 'there';
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<ScrollArea
|
||||
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-end'
|
||||
}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Home page initial state: greeting + brief */}
|
||||
{isHomePage && !hasMessages && (
|
||||
@@ -246,7 +179,6 @@ export function AIChatPanel({
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
isHomePage={isHomePage}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
|
||||
{SUGGESTION_CHIPS.map((chip) => (
|
||||
@@ -336,79 +268,19 @@ export function AIChatPanel({
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Fixed input — pinned to the bottom (hidden on home initial state) */}
|
||||
{!(isHomePage && !hasMessages) && (
|
||||
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
|
||||
{hasMessages && (
|
||||
<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={`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
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
isHomePage={isHomePage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,7 +297,6 @@ interface ChatInputProps {
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
isHomePage?: boolean;
|
||||
}
|
||||
|
||||
function ChatInput({
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
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 { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
computeDualAnchor,
|
||||
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';
|
||||
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() {
|
||||
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 prevPathRef = useRef(routerState.location.pathname);
|
||||
|
||||
@@ -32,6 +47,32 @@ function FloatingChatInner() {
|
||||
[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 {
|
||||
messages,
|
||||
input,
|
||||
@@ -40,7 +81,7 @@ function FloatingChatInner() {
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
} = useAIChat(chatContext);
|
||||
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -58,15 +99,15 @@ function FloatingChatInner() {
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// ---- Close on route change ----
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, close]);
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
// ---- Clear messages on close ----
|
||||
|
||||
@@ -78,6 +119,31 @@ function FloatingChatInner() {
|
||||
prevOpenRef.current = state.isOpen;
|
||||
}, [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 ----
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,6 +163,51 @@ function FloatingChatInner() {
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, [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 ----
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -160,18 +271,18 @@ function FloatingChatInner() {
|
||||
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"
|
||||
className="rounded-2xl"
|
||||
>
|
||||
<ScrollArea
|
||||
className="max-h-[300px]"
|
||||
viewportRef={scrollRef}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-3">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-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">
|
||||
{msg.content}
|
||||
</p>
|
||||
@@ -194,7 +305,7 @@ function FloatingChatInner() {
|
||||
|
||||
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="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
||||
<div className="text-xs">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
@@ -206,7 +317,7 @@ function FloatingChatInner() {
|
||||
{/* 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">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
||||
{streamingContent ? (
|
||||
<div className="text-xs">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
@@ -221,13 +332,13 @@ function FloatingChatInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ---- Floating input bar ---- */}
|
||||
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.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 */}
|
||||
<button
|
||||
onClick={close}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { motion, useMotionValue, useSpring } from 'framer-motion';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
ClipboardCheck,
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
@@ -71,20 +69,6 @@ interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Walk up the DOM to find the nearest scrollable ancestor. */
|
||||
function findScrollableAncestor(el: Element | null): Element | null {
|
||||
if (!el || el === document.body) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
const overflowY = style.overflowY;
|
||||
if (
|
||||
(overflowY === 'auto' || overflowY === 'scroll') &&
|
||||
el.scrollHeight > el.clientHeight
|
||||
) {
|
||||
return el;
|
||||
}
|
||||
return findScrollableAncestor(el.parentElement);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
@@ -132,138 +116,23 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
|
||||
// Curtain is disabled on home page and on /projects without a selected project
|
||||
const searchObj = routerState.location.search as Record<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 (
|
||||
<>
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
onNavClick={closeCurtain}
|
||||
/>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{/* AI Chat layer: always mounted behind the content panel */}
|
||||
<SidebarInset>
|
||||
{isHomePage ? (
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
contextType={contextType}
|
||||
projectId={projectId}
|
||||
projectName={projectQuery.data?.name}
|
||||
curtainOpen={isHomePage || curtainOpen}
|
||||
isHomePage={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}
|
||||
|
||||
{/* 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 className="relative flex flex-col h-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
@@ -314,17 +183,16 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
onNavClick: () => void;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
@@ -377,7 +245,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
|
||||
isActive={isActive}
|
||||
tooltip={label}
|
||||
>
|
||||
<Link to={to} onClick={onNavClick}>
|
||||
<Link to={to}>
|
||||
<Icon />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
@@ -22,6 +23,7 @@ type KanbanBoardProps = {
|
||||
};
|
||||
|
||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||
const { state: floatingState } = useFloatingChat();
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
hideBreadcrumb
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 { format } from 'date-fns';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
@@ -16,6 +16,7 @@ import { KanbanBoard } from './KanbanBoard';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -26,6 +27,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||
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 { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||
@@ -161,7 +182,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pe-8 flex flex-col gap-6">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{/* Breadcrumb + Project Name */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{breadcrumbPath.length > 0 && (
|
||||
@@ -181,6 +202,8 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Project Summary Section */}
|
||||
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
@@ -226,9 +249,10 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold">Notes</h2>
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -57,6 +58,7 @@ export function TaskRow({
|
||||
onDelete,
|
||||
onClick,
|
||||
hideBreadcrumb,
|
||||
layoutId,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
onToggle: (id: string, status: string | null) => void;
|
||||
@@ -64,6 +66,7 @@ export function TaskRow({
|
||||
onDelete?: (id: string) => void;
|
||||
onClick?: (task: TaskItem) => void;
|
||||
hideBreadcrumb?: boolean;
|
||||
layoutId?: string;
|
||||
}) {
|
||||
const isDone = task.status === 'done';
|
||||
|
||||
@@ -84,10 +87,14 @@ export function TaskRow({
|
||||
breadcrumb.length > 0 ||
|
||||
task.assignee;
|
||||
|
||||
const Wrapper = layoutId ? motion.div : 'div';
|
||||
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
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'
|
||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||
@@ -146,7 +153,7 @@ export function TaskRow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface AISection {
|
||||
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
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 {
|
||||
@@ -24,6 +29,7 @@ interface FloatingChatState {
|
||||
position: { x: number; y: number; width: number };
|
||||
morphTargetId: string | null;
|
||||
projectId?: string;
|
||||
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||
}
|
||||
|
||||
interface FloatingChatContextValue {
|
||||
@@ -36,10 +42,12 @@ interface FloatingChatContextValue {
|
||||
unregisterSection: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
openAtSection: (sectionId: string) => void;
|
||||
moveToSection: (sectionId: string) => void;
|
||||
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
close: () => void;
|
||||
setMorphTarget: (id: string | null) => void;
|
||||
updatePosition: (pos: { x: number; y: number; width: number }) => void;
|
||||
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
|
||||
}
|
||||
|
||||
// ---------- Constants ----------
|
||||
@@ -50,25 +58,79 @@ export const PADDING = 16;
|
||||
|
||||
// ---------- 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(
|
||||
sectionRef: RefObject<HTMLElement | null>,
|
||||
section: AISection,
|
||||
opts?: SectionOpenOpts,
|
||||
): { 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 };
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mode = section.anchorMode ?? 'top-right';
|
||||
|
||||
// Anchor to top-right of section, offset inward
|
||||
let x = rect.right - CHAT_WIDTH - PADDING;
|
||||
let y = rect.top + PADDING;
|
||||
if (mode === 'right-margin') {
|
||||
// Position to the right of the section at the click Y-coordinate
|
||||
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
|
||||
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
||||
y = Math.max(
|
||||
PADDING,
|
||||
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
||||
// Default: top-right of section
|
||||
const rawX = rect.right - CHAT_WIDTH - PADDING;
|
||||
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,
|
||||
);
|
||||
return { x, y, width: CHAT_WIDTH };
|
||||
}
|
||||
|
||||
@@ -108,6 +170,23 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const registerSection = useCallback((section: AISection) => {
|
||||
sectionsRef.current.set(section.id, section);
|
||||
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) => {
|
||||
@@ -115,11 +194,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
setSections(new Map(sectionsRef.current));
|
||||
}, []);
|
||||
|
||||
const openAtSection = useCallback((sectionId: string) => {
|
||||
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState({
|
||||
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);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -157,6 +236,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
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 (
|
||||
<FloatingChatCtx.Provider
|
||||
value={{
|
||||
@@ -168,6 +255,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
moveToSection,
|
||||
close,
|
||||
setMorphTarget,
|
||||
updatePosition,
|
||||
setPendingSection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -50,73 +50,113 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--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);
|
||||
|
||||
/* #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);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
--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 */
|
||||
--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: 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);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
/* #0c0c0c - Deepest black for the main canvas */
|
||||
--background: oklch(0.15 0 0);
|
||||
/* #fbfbfb - Crisp white for primary text */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
|
||||
/* Cards use the main background but are defined by borders */
|
||||
--card: oklch(0.15 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover: oklch(0.15 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--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);
|
||||
|
||||
/* #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 */
|
||||
--secondary-foreground: oklch(0.985 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);
|
||||
|
||||
/* #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);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216); /* Kept original dark red */
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
/* #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 */
|
||||
--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: oklch(0.21 0.006 285.885);
|
||||
|
||||
/* Sidebar mapped to the new sleek dark palette */
|
||||
--sidebar: oklch(0.15 0 0);
|
||||
--sidebar-foreground: oklch(0.985 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-primary: oklch(0.985 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.15 0 0);
|
||||
--sidebar-accent: oklch(0.335 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--sidebar-border: oklch(0.335 0 0);
|
||||
--sidebar-ring: oklch(0.765 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
|
||||
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 [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, 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) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function useDoubleClickAI(): void {
|
||||
const { openAtSection, state } = useFloatingChat();
|
||||
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
|
||||
// If popup is already open at THIS section, do nothing
|
||||
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);
|
||||
return () => document.removeEventListener('dblclick', handler);
|
||||
}, [openAtSection, state.isOpen, state.activeSectionId]);
|
||||
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ElectronTRPC {
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
export const Route = createFileRoute('/notes/$noteId')({
|
||||
component: NoteDetailPage,
|
||||
@@ -29,6 +30,21 @@ function NoteDetailPage() {
|
||||
const utils = trpc.useUtils();
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -139,7 +155,7 @@ function NoteDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 pe-8 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Button
|
||||
@@ -188,7 +204,7 @@ function NoteDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<MilkdownEditor
|
||||
key={noteId}
|
||||
|
||||
@@ -41,12 +41,17 @@ 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();
|
||||
// AI section refs
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
||||
return () => unregisterSection('test');
|
||||
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
|
||||
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
|
||||
return () => {
|
||||
unregisterSection('tasks-overview');
|
||||
unregisterSection('tasks-list');
|
||||
};
|
||||
}, [registerSection, unregisterSection]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -119,9 +124,9 @@ function TasksPage() {
|
||||
);
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
<ItemMedia variant="icon">
|
||||
<ClipboardCheck />
|
||||
@@ -160,6 +165,8 @@ function TasksPage() {
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* Task List Section */}
|
||||
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
@@ -225,10 +232,16 @@ function TasksPage() {
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<EditTaskDialog
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
@@ -15,6 +16,14 @@ function TimelinePage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -70,7 +79,7 @@ function TimelinePage() {
|
||||
}, [ganttCheckpoints]);
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||
|
||||
Reference in New Issue
Block a user