31 Commits

Author SHA1 Message Date
b77c6d1195 updated plan 2026-03-02 17:57:02 +01:00
489e8e3bc9 update refactor plan 2026-03-02 14:06:38 +01:00
1ba9c9eee2 remove unused file 2026-03-02 00:07:32 +01:00
Roberto Musso
0f3c63c4de feat(settings): add permissions for git commands in settings.json 2026-03-01 23:31:57 +01:00
Roberto Musso
aa089975df docs: split plan into Electron app + separate backend repo
- AI_REFACTOR_PLAN.md: Electron-only, 7 phases, 18 steps
- BACKEND_PLAN.md: standalone FastAPI backend guide for separate repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:28:42 +01:00
Roberto Musso
a6c04e52af docs: create AI refactoring plan
Comprehensive step-by-step plan for transforming Adiuva into a
local-first multi-agent platform with cloud backend orchestration,
plugin-based batch agents, E2E encrypted backup, granular permissions,
and multi-provider LLM support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:20:18 +01:00
Roberto Musso
d82738e7ea feat(AppShell): add SidebarTrigger component for improved sidebar accessibility 2026-03-01 14:37:08 +01:00
Roberto Musso
e005872ba0 feat(AIChatPanel): add aria-labels for accessibility; clean up unused lines
feat(AppShell): improve token storage message styling for better visibility
feat(ProjectDetail): implement skeleton loading state for project details
fix(ProjectSidebar): refactor variable declaration for clarity
style(PriorityBadge): enhance priority badge colors for better contrast
refactor(TaskRow): simplify className handling with utility function
fix(TasksPage): replace loader icon with clock icon for in-progress tasks
feat(TimelinePage): enhance empty state with descriptive messaging and icon
2026-03-01 10:40:22 +01:00
Roberto Musso
d3e82a3ebb feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior 2026-03-01 00:21:57 +01:00
Roberto Musso
af8cbc1c96 fix: update default userName from 'Roberto' to 'there' in store and AIChatPanel 2026-02-28 23:53:25 +01:00
Roberto Musso
ee6467a7ac feat: add knip configuration file and integrate knip for linting; update package.json and package-lock.json for new dependencies; refactor various interfaces to remove export modifiers; delete unused hover-card component 2026-02-28 23:44:10 +01:00
Roberto Musso
cdf9a8bf18 feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects 2026-02-28 23:30:47 +01:00
Roberto Musso
f767bb5175 feat(AIChatPanel): update GradualBlur component to enhance blur effect; adjust ScrollArea scrollbar class handling 2026-02-28 22:47:09 +01:00
Roberto Musso
444aa37be2 feat(AIChatPanel): enhance daily brief with animation and expand/collapse functionality; add GradualBlur component for improved UI 2026-02-28 16:19:15 +01:00
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
Roberto Musso
d8cf7814ab Merge branch 'feature/color' into feature/popup-ai 2026-02-27 23:56:19 +01:00
Roberto Musso
50b69aadbf feat(tasks): register section for floating AI chat in TasksPage 2026-02-27 23:56:08 +01:00
Roberto Musso
6cd121fa80 feat(floating-ai): step 4 — build FloatingChat component
Create the floating AI chat popup rendered via portal to document.body.
Uses useAIChat for chat logic, useFloatingChat for position/state,
Framer Motion for enter/exit animations, and pointer-event dragging.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:05:25 +01:00
Roberto Musso
28a5d65f1a feat(floating-ai): step 3 — create double-click hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:49:16 +01:00
Roberto Musso
b4e97e14f3 feat(floating-ai): step 2 — create section registry + FloatingChatContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:43:05 +01:00
Roberto Musso
78b4df1028 chore: remove unused Claude configuration files and update .gitignore 2026-02-27 22:35:18 +01:00
Roberto Musso
96101e4310 feat(floating-ai): step 1 — extract shared useAIChat hook
Refactor AIChatPanel to consume the existing useAIChat hook instead of
managing chat state inline. Removes duplicate ChatMessage interface,
inline state (messages, input, isStreaming, streamingContent), and the
65-line handleSend callback, replacing them with a single useAIChat()
call and a thin briefLoading guard wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:27:11 +01:00
Roberto Musso
9c07d3195f first color revision 2026-02-27 00:15:45 +01:00
Roberto Musso
4b2162505c Merge branch 'develop' into feature/popup-ai 2026-02-26 23:46:14 +01:00
Roberto Musso
8f1ba08e54 delete unused file 2026-02-26 23:41:25 +01:00
37 changed files with 2719 additions and 978 deletions

View File

@@ -1,110 +1,164 @@
# 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)
source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
source ~/.nvm/nvm.sh && npm run package # Package without installers
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
```
There is no test suite currently.
No test suite currently.
## Architecture Overview
## Architecture
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPCIPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
### Process Boundaries
Adiuva is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
```
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
Renderer (React 19) ──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`)
### Main Process (`src/main/`)
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
Owns the database and all business logic.
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()`
| File | Purpose |
|---|---|
| `index.ts` | Window creation, app lifecycle |
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
| `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, taskComments |
| `db/vectordb.ts` | LanceDB vector store for note embeddings |
| `store.ts` | electron-store for persistent UI settings |
### Preload (`src/preload/trpc.ts`)
Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`.
### Renderer (`src/renderer/`)
React 19 — never accesses Node APIs directly. All data through `trpc.*.useQuery()` / `trpc.*.useMutation()`.
| File | Purpose |
|---|---|
| `lib/ipcLink.ts` | Custom TRPCLink routing through `window.electronTRPC` |
| `lib/trpc.ts` | `createTRPCReact<AppRouter>()` typed client |
| `index.tsx` | QueryClient + tRPC + Router providers |
### 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`
File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
### tRPC Routers
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
### 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).
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations.
To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file.
To add a table/column: edit `schema.ts` `drizzle-kit generate` `drizzle-kit push` (dev) or commit the migration.
### Adding a New Feature (end-to-end pattern)
### Adding a Feature (end-to-end)
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
1. **Schema**`src/main/db/schema.ts`
2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
3. **Types**Flow automatically via `AppRouter` export
4. **UI** — Components in `src/renderer/components/<feature>/`, data via `trpc.*.useQuery()`
### AI Subsystem (`src/main/ai/`)
## AI Subsystem (`src/main/ai/`)
LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot).
LangGraph-based agentic system with pluggable LLM providers.
**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`
### Orchestrator (`orchestrator.ts`)
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally).
Classifies user intent → routes to a specialist agent:
**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.
| Agent | Scope | Tools |
|---|---|---|
| Project | Project-scoped Q&A | `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` |
| Knowledge | Cross-project search | `vector_search_all` |
| General | Workspace-wide | `add_task` |
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
**Token storage** (`token.ts`) — three-tier fallback:
1. keytar (OS keychain) — preferred, encrypted per-user
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
### Streaming
`sendStreamChunk(sender, token, done)` over IPC `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before display.
### Providers (`llm.ts`)
| Provider | Model | Notes |
|---|---|---|
| OpenAI | `gpt-4o-mini` | Via LangChain |
| Anthropic | `claude-sonnet-4-20250514` | Via LangChain |
| Copilot | `ChatCopilot` wrapper | `copilot.ts` / `chat-copilot.ts` |
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
### Token Storage (`token.ts`)
Three-tier fallback (keytar service name: `'adiuva'`):
1. keytar (OS keychain) — preferred; `keytarFailed` flag skips after first failure
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.
### Vector Embeddings (`db/vectordb.ts`)
**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.
LanceDB in `{userData}/vectors/`. Schema: `{ id, projectId, content, vector }` (1536-dim, `text-embedding-3-small` via `embeddings.ts`). Embedding priority: Copilot CLI token → OpenAI token.
### Vector Embeddings (`src/main/db/vectordb.ts`)
- `upsertNoteEmbedding()` on note create/update (fire-and-forget)
- `migrateNotesIfNeeded()` backfills on first startup
- `searchNotes(query, limit=5)` used by Knowledge agent
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.
### AI Approval Pattern
- 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
Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestions appear pending user approval (dashed borders in UI).
### Key Config Notes
## 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)
- Vite configs use `.mts` (not `.ts`) avoids ESM/CJS conflicts with electron-forge
- `@/*` path alias `src/renderer/*` (TypeScript + Vite + shadcn/ui)
- **shadcn/ui**: new-york style, neutral base color
- **Icons**: lucide-react only — do not introduce other icon libraries
- **Tailwind 4** — CSS variable theming in `globals.css`, no `tailwind.config.js`
- **Notes editor**: Milkdown (`@milkdown/crepe`) at `src/renderer/components/notes/MilkdownEditor.tsx`
## Design Context
### Target User
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
### Brand
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
### Palette
| | Canvas | Primary | Secondary | Borders |
|---|---|---|---|---|
| **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
| **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
### Typography
Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`.
### Visual Language
- 10px border-radius, `rounded-2xl` for chat elements
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency)
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
- No gamification (badges, streaks, confetti). Mature and professional
### Design Principles
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as AI marker
3. **Warmth in restraint** — Warm palette feels approachable without being playful. Dark mode trades warmth for focus
4. **Motion with purpose** — Animations reinforce spatial relationships, never decorative
5. **Confidence through consistency** — CSS variable tokens, shadcn/ui primitives, Geist font. Predictable, keyboard-first

8
.claude/settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add AI_REFACTOR_PLAN.md)",
"Bash(git commit:*)"
]
}
}

View File

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

4
.gitignore vendored
View File

@@ -90,3 +90,7 @@ typings/
# Electron-Forge
out/
# local config files
.vscode/

11
.vscode/mcp.json vendored
View File

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

526
AI_REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,526 @@
# AI Refactor Plan — Adiuva Electron App
> **Objective:** Transform the Electron app into a hybrid-first multi-agent client. The user controls where data is stored (local / cloud / sync), which AI provider to use (BYOK multi-provider), and which automations to run — either custom batch agents built with the LLM-powered Batch Builder, or pre-built plugins from the marketplace. All data access is opt-in, transparent, and auditable.
>
> **Backend:** Lives in a separate repository. See `../adiuva-api/BACKEND_PLAN.md` for the API contract and backend implementation guide.
>
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
---
## Phase 0 — API Contracts & Types
### Step 0.1 — Define backend API contract types
- [ ] Create `src/shared/api-types.ts` with all interfaces the Electron app needs to communicate with the backend:
- `ExecutionPlan`, `PlanStep`, `PlanAction` (action types: `create_record`, `update_record`, `delete_record`, `index_document`, `send_notification`, `call_agent`)
- `ChatRequest` (message, context, execution_mode: `'direct'` | `'plan'`)
- `ChatResponse` (response, actions)
- `ChatContext` (user_profile, relevant_documents, recent_tasks, conversation_history)
- `AgentManifest` (name, description, permissions, schedule)
- `PermissionGrant` (plugin, permission type, resource path, granted_at)
- `BackupMetadata` (version, timestamp, checksum, chunk_count)
- `BillingTier` enum (`free`, `pro`, `power`, `team`)
- `AuthTokens` (access_token, refresh_token, expires_at)
- `UserProfile` (id, email, tier)
- [ ] Create `src/shared/batch-types.ts` with all types for the batch builder and storage layer:
- `StorageTarget``'local'` | `'cloud'` | `'sync'` | `'none'`
- `ConnectorType``'imap'` | `'filesystem'` | `'calendar'` | `'api'` | `'gmail'` | `'gdrive'` | `'outlook'`
- `BatchActionType``'create_record'` | `'update_record'` | `'delete_record'` | `'index_document'` | `'send_notification'` | `'call_agent'`
- `BatchSource``{ connector: ConnectorType, config: Record<string, unknown> }`
- `BatchTrigger``{ type: 'cron' | 'event', schedule?: string, timezone?: string }`
- `BatchAnalysis``{ prompt: string, model_override?: string, output_schema?: object }`
- `BatchAction``{ type: BatchActionType, table?: string, mapping?: Record<string, string> }`
- `BatchStorage``{ records: StorageTarget, vectors: StorageTarget, raw_data: StorageTarget }`
- `BatchConfig` — full config object: `id`, `name`, `description`, `enabled`, `source`, `trigger`, `analysis`, `actions`, `storage`, `permissions`
- `BatchStatus``'idle'` | `'running'` | `'error'` | `'disabled'`
- `BatchRunResult``{ batchId, runAt, status, itemsProcessed, errors }`
- `PluginListing``{ id, name, description, author, version, rating, installs, category, permissions, price }`
- `InstalledPlugin``{ listing: PluginListing, installedAt, enabled, storageConfig: BatchStorage }`
- `DataSourceInfo``{ type: ConnectorType, label, recordCount, sizeBytes, storageTarget: StorageTarget }`
- `StorageStats``{ localUsedBytes, cloudUsedBytes, cloudLimitBytes, sources: DataSourceInfo[] }`
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
- **Files:** `src/shared/api-types.ts`, `src/shared/batch-types.ts`, `tsconfig.json`
- **Outcome:** Type-safe contracts for all backend communication and the batch/storage subsystem. Backend repo mirrors these as Pydantic schemas.
---
## Phase 1 — LiteLLM Multi-Provider Client
### Step 1.1 — Create unified LLM client wrapper
- [ ] Create `src/main/llm/litellm-client.ts`:
- `LiteLLMClient` class with unified interface:
- `complete(messages: Message[], options?: CompletionOptions): Promise<CompletionResponse>`
- `stream(messages: Message[], options?: CompletionOptions): AsyncGenerator<string>`
- `embed(text: string): Promise<number[]>`
- `CompletionOptions`: model override, temperature, max_tokens, tools
- Provider-agnostic: internally maps to the correct provider SDK
- Fallback chain: tries primary provider, on failure tries secondary, logs each attempt
- Timeout handling: per-provider configurable timeouts
- [ ] Create `src/main/llm/providers.ts`:
- `ProviderConfig` interface: name, apiKey, model, endpoint (for Ollama), timeout, isLocal
- `ProviderRegistry`: manages configured providers, persists to electron-store
- `getActiveProvider()`, `setActiveProvider(name)`, `addProvider(config)`, `removeProvider(name)`
- `getFallbackChain(): ProviderConfig[]`
- Supported providers: OpenAI, Anthropic, Google (Gemini), Mistral, Groq, Ollama (local)
- [ ] Create `src/main/llm/embeddings.ts` (refactored):
- Support multiple embedding providers (OpenAI text-embedding-3-small, local ONNX with all-MiniLM-L6-v2)
- Auto-select: use local ONNX if available, fall back to API
- Same `embedText(text): Promise<number[]>` interface
- **Files:** `src/main/llm/litellm-client.ts`, `src/main/llm/providers.ts`, `src/main/llm/embeddings.ts`
- **Outcome:** Single LLM interface that all local components use. Supports 6+ providers with fallback.
### Step 1.2 — Migrate existing AI code to use new LLM client
- [ ] Update `src/main/ai/orchestrator.ts`:
- Replace direct `getLLM()` calls with `LiteLLMClient.complete()` / `LiteLLMClient.stream()`
- Keep local orchestration working with the new client (backend delegation comes in Phase 3)
- [ ] Update `src/main/ai/llm.ts`:
- Deprecate. Redirect `getLLM()` to instantiate via `LiteLLMClient` as a thin compatibility shim
- [ ] Update `src/main/ai/embeddings.ts` to delegate to `src/main/llm/embeddings.ts`
- [ ] Update `src/main/ai/token.ts`:
- Add `listStoredProviders(): Promise<string[]>` to enumerate which providers have tokens
- [ ] Ensure all existing AI features (chat, daily brief, tool calling) continue to work
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/ai/llm.ts`, `src/main/ai/embeddings.ts`, `src/main/ai/token.ts`
- **Outcome:** Existing AI features work identically but go through the new unified LLM client.
---
## Phase 2 — Local Plugin System & Batch Agents
### Step 2.1 — Create plugin manifest system and permission manager
- [ ] Create `src/main/permissions/manifest-validator.ts`:
- `PluginManifest` interface: `name`, `description`, `version`, `permissions: PermissionRequest[]`, `schedule?: string` (cron), `entryPoint: string`
- `PermissionRequest`: `type` (read_folder, read_email, read_calendar, read_browser_history), `resource?: string` (path, account), `reason: string`
- `validateManifest(manifest): ValidationResult` — validates structure, checks for dangerous permissions
- [ ] Create `src/main/permissions/permission-manager.ts`:
- `PermissionManager` class (singleton):
- `grantPermission(pluginName, permission): void` — persists to SQLite
- `revokePermission(pluginName, permission): void`
- `checkPermission(pluginName, permission): boolean`
- `getPluginPermissions(pluginName): PermissionGrant[]`
- `getAllGrants(): PermissionGrant[]`
- `logAccess(pluginName, permission, resource, timestamp): void` — activity log
- `getActivityLog(pluginName?, limit?): ActivityLogEntry[]`
- Permission grants stored in a new `plugin_permissions` SQLite table
- Activity log stored in a new `plugin_activity_log` SQLite table
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to `src/main/db/schema.ts`
- [ ] Generate and apply migration
- **Files:** `src/main/permissions/manifest-validator.ts`, `src/main/permissions/permission-manager.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
- **Outcome:** Granular, opt-in permission system for plugins. Every access is logged.
### Step 2.2 — Create worker pool and batch runner
- [ ] Create `src/main/workers/worker-pool.ts`:
- `WorkerPool` class:
- Manages a pool of Node.js `worker_threads`
- `runPlugin(manifest, context): Promise<PluginResult>` — spawns or reuses a worker, sends manifest + context, receives result
- Worker lifecycle: create, send message, receive result, terminate on timeout
- Max concurrent workers: configurable (default 4)
- Error isolation: worker crash doesn't affect main process
- [ ] Create `src/main/workers/batch-runner.ts`:
- `BatchRunner` class:
- `registerPlugin(manifest): void` — validates manifest, stores in registry
- `startScheduler(): void` — cron-based scheduler using `node-cron` or simple setInterval
- `runPlugin(name, triggerContext?): Promise<PluginResult>` — manual trigger
- `stopAll(): void` — graceful shutdown of all scheduled plugins
- Scheduler checks permissions before each run; skips if revoked
- Results logged to activity log
- [ ] Create `src/main/workers/plugin-worker.ts`:
- Worker thread entry point
- Receives plugin config + context via `parentPort.on('message')`
- Dynamically imports the plugin entry point
- Executes `run(context)` with sandboxed access (only permitted resources)
- Posts result back via `parentPort.postMessage()`
- **Files:** `src/main/workers/worker-pool.ts`, `src/main/workers/batch-runner.ts`, `src/main/workers/plugin-worker.ts`
- **Outcome:** Isolated plugin execution environment with scheduling, permissions enforcement, and error isolation.
### Step 2.3 — Implement batch agent plugins
- [ ] Create `src/plugins/email-scanner.ts`:
- Manifest: requires `read_email` permission
- Connects to IMAP via `imapflow` (account configured in settings)
- Scans for new emails since last run
- Uses `LiteLLMClient` to classify each email (has actionable task? extract title, priority, description)
- Returns extracted task metadata (never raw email content) for execution via backend or local playbook
- [ ] Create `src/plugins/file-watcher.ts`:
- Manifest: requires `read_folder` permission for each watched path
- Uses `chokidar` to watch approved directories
- On new/modified file: reads content, generates embedding, upserts into vector store
- Supports: .txt, .md, .pdf (text extraction), .docx (basic extraction)
- [ ] Create `src/plugins/calendar-sync.ts`:
- Manifest: requires `read_calendar` permission
- Parses ICS files or connects to CalDAV endpoint
- Detects scheduling conflicts
- Suggests reorganizations via LLM analysis
- Returns calendar events + conflict reports
- [ ] Create `src/plugins/browser-agent.ts`:
- Manifest: requires `read_browser_history` permission (explicit opt-in)
- Reads browser bookmarks and history from known browser paths (Chrome, Firefox, Edge)
- Indexes relevant entries into vector store
- Privacy-first: only indexes URLs and titles, not page content
- **Files:** `src/plugins/email-scanner.ts`, `src/plugins/file-watcher.ts`, `src/plugins/calendar-sync.ts`, `src/plugins/browser-agent.ts`
- **Outcome:** Four local batch agents running as isolated worker threads, using LiteLLM for analysis.
---
## Phase 3 — Backend Integration
### Step 3.1 — Create backend HTTP/WebSocket client
- [ ] Create `src/main/api/backend-client.ts`:
- `BackendClient` class:
- `baseUrl` configurable (default: production cloud URL, overridable for dev)
- `setAuthToken(jwt: string): void`
- `chat(request: ChatRequest): Promise<ChatResponse>` — POST /api/v1/chat
- `chatStream(request: ChatRequest): AsyncGenerator<string>` — WebSocket /api/v1/chat/stream
- `getPlaybooks(): Promise<ExecutionPlan[]>` — GET /api/v1/plans/playbook
- `uploadBackup(blob: Buffer, metadata: BackupMetadata): Promise<void>` — PUT /api/v1/backup
- `downloadBackup(): Promise<{ blob: Buffer, metadata: BackupMetadata }>` — GET /api/v1/backup
- Automatic retry with exponential backoff (max 3 attempts)
- Offline detection: returns cached playbook responses when offline
- `isOnline(): boolean` — connectivity check
- [ ] Create `src/main/api/plan-runner.ts`:
- `PlanRunner` class:
- `execute(plan: ExecutionPlan): Promise<PlanResult>` — executes plan steps locally
- Step handlers: `create_record` (inserts into SQLite), `update_record`, `delete_record`, `index_document` (upserts into vector store), `send_notification` (Electron notification API)
- Each step logs to activity log
- Supports `data_from_step` references (pipeline execution)
- Validates plan structure before execution
- **Files:** `src/main/api/backend-client.ts`, `src/main/api/plan-runner.ts`
- **Outcome:** Electron can communicate with the cloud backend and execute returned plans locally.
### Step 3.2 — Refactor orchestrator to delegate to backend
- [ ] Update `src/main/ai/orchestrator.ts`:
- When online: forward chat requests to backend via `BackendClient.chatStream()`
- Build `ChatRequest` from local context: query SQLite for user profile, relevant documents (from vector store), recent tasks, conversation history
- Stream backend response tokens to renderer via existing `ai:stream` IPC channel
- Execute any returned actions via `PlanRunner`
- When offline: fall back to local orchestration (existing LangGraph pipeline) with degraded capabilities
- Remove direct agent logic (project agent, knowledge agent, general agent tool definitions) — these now live on the backend
- Keep `buildProjectContext()` and `buildGlobalContext()` as context builders for the request payload
- [ ] Update `src/main/router/index.ts` `ai` sub-router:
- `chat` mutation: call refactored orchestrator (which now delegates to backend)
- Add `getPlaybooks` query: fetches cached playbooks
- Keep `dailyBrief` mutation: sends daily brief request to backend
- [ ] Add IPC handler for plan execution results
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/main/ipc.ts`
- **Outcome:** Chat intelligence lives on the backend; Electron is the execution layer.
### Step 3.3 — Implement Shared Memory (three-tier local memory)
- [ ] Create `src/main/database/shared-memory.ts`:
- **Short-term memory**: In-memory conversation buffer
- `ConversationBuffer` class: stores last N messages per session
- `addMessage(sessionId, role, content)`, `getHistory(sessionId, limit?) -> Message[]`
- Cleared on session end
- **Long-term KV store**: SQLite-backed key-value store
- New `agent_memory` table: `id`, `namespace` (agent name), `key`, `value` (JSON text), `updated_at`
- `AgentMemoryStore` class: `get(namespace, key)`, `set(namespace, key, value)`, `delete(namespace, key)`, `listKeys(namespace)`
- Used by agents to persist learned facts, user preferences
- **Vector store**: Already exists (LanceDB). Enhance with:
- Multi-collection support: separate tables for notes, emails, files, calendar
- `searchByCollection(collection, query, limit) -> SearchResult[]`
- [ ] Add `agent_memory` table to `src/main/db/schema.ts`
- [ ] Generate migration
- **Files:** `src/main/database/shared-memory.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
- **Outcome:** Three-tier memory system supporting short-term conversation, long-term agent facts, and semantic search.
---
## Phase 4 — Security: E2E Backup & Offline Mode
### Step 4.1 — Implement E2E encrypted backup
- [ ] Create `src/main/backup/e2e-crypto.ts`:
- `generatePassphrase(): string` — BIP39-compatible 12-word recovery phrase
- `deriveKey(passphrase: string, salt: Buffer): Promise<Buffer>` — Argon2id key derivation (time cost 3, memory 64MB, parallelism 1)
- `encrypt(data: Buffer, key: Buffer): { ciphertext: Buffer, iv: Buffer, authTag: Buffer }` — AES-256-GCM
- `decrypt(ciphertext: Buffer, key: Buffer, iv: Buffer, authTag: Buffer): Buffer`
- Uses `node:crypto` for AES and `argon2` npm package for key derivation
- [ ] Create `src/main/backup/backup-manager.ts`:
- `BackupManager` class:
- `createBackup(passphrase: string): Promise<BackupBlob>` — Exports SQLite DB, encrypts, returns blob + metadata
- `restoreBackup(blob: Buffer, passphrase: string): Promise<void>` — Decrypts blob, replaces local DB, re-initializes
- `uploadBackup(passphrase: string): Promise<void>` — Creates backup, uploads via `BackendClient`
- `downloadAndRestore(passphrase: string): Promise<void>` — Downloads from backend, decrypts, restores
- Incremental backup: chunks DB into segments, encrypts each separately, tracks content hashes to skip unchanged chunks
- Metadata header: version, timestamp, checksum (SHA-256 of plaintext), chunk count
- **Files:** `src/main/backup/e2e-crypto.ts`, `src/main/backup/backup-manager.ts`
- **Outcome:** User data never leaves the device unencrypted. Backend stores only opaque blobs.
### Step 4.2 — Implement offline sync queue
- [ ] Create `src/main/backup/sync-queue.ts`:
- `SyncQueue` class:
- `enqueue(action: QueuedAction): void` — Adds action to persistent queue (SQLite table `sync_queue`)
- `processQueue(): Promise<void>` — Processes queued actions in FIFO order when online
- `getQueueSize(): number`
- `clearQueue(): void`
- Conflict resolution: last-write-wins with timestamps
- New `sync_queue` table: `id`, `action_type`, `payload` (JSON), `created_at`, `status` (pending/processing/failed), `retry_count`, `last_error`
- Auto-drain: watches connectivity, starts processing when online
- Failed actions: retry up to 3 times with exponential backoff, then mark as `failed` for user review
- [ ] Add `sync_queue` table to schema
- [ ] Integrate with `BackendClient`: when offline, chat/backup calls enqueue instead of failing
- **Files:** `src/main/backup/sync-queue.ts`, `src/main/db/schema.ts`, `src/main/api/backend-client.ts`
- **Outcome:** App works offline; queued actions sync automatically when connectivity returns.
---
## Phase 5 — Auth Integration & Database Encryption
### Step 5.1 — Integrate auth into Electron app
- [ ] Create `src/main/auth/auth-manager.ts`:
- `AuthManager` class:
- `login(email, password): Promise<void>` — Calls backend POST /api/v1/auth/login, stores JWT in secure storage (via token.ts)
- `register(email, password): Promise<void>` — Calls POST /api/v1/auth/register
- `logout(): void` — Clears stored JWT
- `getToken(): string | null` — Returns current JWT
- `refreshToken(): Promise<void>` — Auto-refresh before expiry
- `isAuthenticated(): boolean`
- `getCurrentTier(): BillingTier`
- Auto-refresh: checks token expiry every 5 minutes, refreshes if < 10 minutes remaining
- [ ] Add tRPC procedures: `auth.login`, `auth.register`, `auth.logout`, `auth.status`, `auth.tier`
- [ ] Wire `BackendClient` to use `AuthManager.getToken()` for all requests
- **Files:** `src/main/auth/auth-manager.ts`, `src/main/router/index.ts`, `src/main/api/backend-client.ts`
- **Outcome:** Electron app has full auth flow; backend requests are authenticated.
### Step 5.2 — Migrate from better-sqlite3 to SQLCipher
- [ ] Add `@journeyapps/sqlcipher` to dependencies (replaces `better-sqlite3`)
- [ ] Update `src/main/db/index.ts`:
- Replace `better-sqlite3` import with `@journeyapps/sqlcipher`
- On first launch: derive DB key from OS keychain or prompt user
- `initDb(password)`: opens DB with `PRAGMA key = 'password'`
- Migration path for existing unencrypted DBs: detect export create encrypted import delete old
- WAL mode still enabled after keying
- [ ] Update `src/main/index.ts`: pass password to `initDb()`
- [ ] Test that all existing Drizzle operations work with SQLCipher
- **Files:** `package.json`, `src/main/db/index.ts`, `src/main/index.ts`
- **Outcome:** All local data encrypted at rest with SQLCipher.
---
## Phase 6 — Renderer UI Updates
> **Navigation model:** The app has a sidebar with top-level routes matching the pages below. Each page is a full-screen view. Shared hooks live in `src/renderer/hooks/`. All data access goes through tRPC procedures — no direct IPC calls from components.
### Step 6.1 — Restructure app shell and routing
- [ ] Update `src/renderer/App.tsx`:
- Define top-level routes: `/chat`, `/batch-builder`, `/plugins`, `/data-manager`, `/settings`, `/activity`
- Add sidebar navigation with icons and labels for each route
- Persist last active route in electron-store
- [ ] Create `src/renderer/hooks/useProvider.ts`:
- `useProvider()` returns active provider config, `setProvider()`, `testProvider()`, list of configured providers
- Backed by tRPC `provider.*` procedures (to be added in Phase 1)
- [ ] Create `src/renderer/hooks/useStorage.ts`:
- `useStorage()` returns `StorageStats`, `setStorageTarget(source, target)`, `migrateData(source, from, to)`
- Backed by tRPC `storage.*` procedures (to be added in Phase 2)
- **Files:** `src/renderer/App.tsx`, `src/renderer/hooks/useProvider.ts`, `src/renderer/hooks/useStorage.ts`
- **Outcome:** App shell with all top-level routes and shared data hooks.
### Step 6.2 — ChatPage with context panel
- [ ] Create `src/renderer/pages/ChatPage.tsx`:
- Two-column layout: chat area (left/main) + collapsible `ContextPanel` (right)
- Wraps `ChatWindow` and `ContextPanel` components
- Online/offline status bar at top
- [ ] Create `src/renderer/components/chat/ChatWindow.tsx`:
- Message list rendering `MessageBubble` for each entry
- Input bar with send button and attachment support
- Handles streaming tokens from `useChat` hook
- Plan approval UI inline: expandable plan steps with approve/reject per-step
- Error states: offline, auth expired, rate limited, server error (distinct UI for each)
- [ ] Create `src/renderer/components/chat/MessageBubble.tsx`:
- Renders user / assistant / system messages
- Supports markdown rendering for assistant messages
- Shows tool-call indicators when the agent uses a tool
- Timestamp and copy-to-clipboard action
- [ ] Create `src/renderer/components/chat/ContextPanel.tsx`:
- Shows what context the agent used for the last response: matched documents, recent tasks, memory entries
- Each context item links to its source (note, file, batch result)
- Collapsible, persists open/closed state
- [ ] Create `src/renderer/hooks/useChat.ts`:
- `useChat(sessionId)` message list, `sendMessage()`, streaming state, connection mode (`'backend'` | `'local'`)
- Automatically falls back to local orchestrator when offline
- Exposes `approveStep(stepId)` / `rejectStep(stepId)` for plan execution
- **Files:** `src/renderer/pages/ChatPage.tsx`, `src/renderer/components/chat/ChatWindow.tsx`, `src/renderer/components/chat/MessageBubble.tsx`, `src/renderer/components/chat/ContextPanel.tsx`, `src/renderer/hooks/useChat.ts`
- **Outcome:** Full chat UI with context transparency, plan approval, and seamless online/offline fallback.
### Step 6.3 — BatchBuilderPage
- [ ] Create `src/renderer/pages/BatchBuilderPage.tsx`:
- Two views: **Active Batches** list (default) and **Create New Batch** wizard
- Active list renders `BatchCard` for each active batch config
- "Create" button opens the wizard
- [ ] Create `src/renderer/components/batch-builder/NaturalLanguageInput.tsx`:
- Textarea where the user describes the batch in plain language
- "Generate" button calls `useBatchBuilder().generate(description)`
- Loading skeleton while the LLM generates the config
- [ ] Create `src/renderer/components/batch-builder/ConfigPreview.tsx`:
- Shows the generated `BatchConfig` as an editable form (not raw JSON)
- Sections: Source, Trigger, Analysis, Actions, Storage each collapsible
- Inline editing for every field (prompt textarea, cron expression with human-readable label, mapping table)
- "Edit raw JSON" toggle for power users
- [ ] Create `src/renderer/components/batch-builder/ConnectorPicker.tsx`:
- Dropdown of available connector types (IMAP, Filesystem, Gmail, GDrive, Outlook, Calendar, Generic API)
- When selected, shows connector-specific config fields (e.g. IMAP: host, folder, filter_from; Filesystem: path picker)
- OAuth connectors show "Connect account" button that opens the OAuth flow
- [ ] Create `src/renderer/components/batch-builder/StoragePicker.tsx`:
- Three-way toggle per storage dimension: **Local** / **Cloud** / **Sync** / **None**
- Dimensions: Records, Vectors, Raw data
- Shows storage impact estimate per option
- Disabled options grayed out with tier tooltip if current tier doesn't support cloud
- [ ] Create `src/renderer/components/batch-builder/SchedulePicker.tsx`:
- Mode toggle: **Cron** (with human-readable label, e.g. "Every day at 08:00") / **Event** (on new data from connector)
- Timezone selector (defaults to system timezone)
- Visual cron builder for non-technical users (with raw cron input fallback)
- [ ] Create `src/renderer/components/batch-builder/BatchCard.tsx`:
- Shows batch name, connector icon, last run time, next run time, status badge (`idle` / `running` / `error` / `disabled`)
- Actions: Run now, Edit, Disable/Enable, Delete
- Expandable to show last run summary (items processed, errors)
- [ ] Create `src/renderer/components/batch-builder/BatchTestRunner.tsx`:
- "Dry Run" panel: picks one real item from the source, runs the full analysis pipeline, shows output without saving
- Shows LLM output, action mapping preview, what would be stored and where
- Pass/Fail indicator with detailed error on failure
- [ ] Create `src/renderer/hooks/useBatchBuilder.ts`:
- `useBatchBuilder()` `generate(description): Promise<BatchConfig>`, `validate(config)`, `save(config)`, `activate(id)`, `deactivate(id)`, `runNow(id)`, `dryRun(id)`, `delete(id)`, list of saved configs with live status
- Backed by tRPC `batch.*` procedures
- **Files:** `src/renderer/pages/BatchBuilderPage.tsx`, `src/renderer/components/batch-builder/{NaturalLanguageInput,ConfigPreview,ConnectorPicker,StoragePicker,SchedulePicker,BatchCard,BatchTestRunner}.tsx`, `src/renderer/hooks/useBatchBuilder.ts`
- **Outcome:** Full Batch Builder UI users can describe a batch in natural language, review/edit the generated config, dry-run it, and activate it with a single flow.
### Step 6.4 — PluginStorePage
- [ ] Create `src/renderer/pages/PluginStorePage.tsx`:
- Two tabs: **Marketplace** (browse available plugins) and **Installed** (manage installed plugins)
- Marketplace: search bar, category filter chips, grid of plugin cards sorted by rating/installs
- Installed: list of `InstalledPlugin` entries with enable/disable toggles and settings links
- [ ] Create plugin card component (inline or shared `common/`):
- Shows name, author, description, rating (stars), install count, category badge, price/free badge
- "Install" button triggers permission request dialog installs plugin
- "Settings" button (installed) opens plugin-specific config drawer
- [ ] Plugin install flow:
- On install click: fetch plugin manifest from backend
- Show `PermissionDialog` with the permissions the plugin requires
- On approve: call tRPC `plugins.install(id)`, download and register the plugin worker
- Show `StoragePicker` for the plugin's data (what goes local/cloud/sync)
- **Files:** `src/renderer/pages/PluginStorePage.tsx`
- **Outcome:** Users can discover and install pre-built plugins from the marketplace with full permission visibility.
### Step 6.5 — DataManagerPage
- [ ] Create `src/renderer/pages/DataManagerPage.tsx`:
- Top section: `StorageOverview` dashboard
- Below: list of `DataSourceCard` for each active data source (one card per connector/plugin)
- "Migrate" button opens `MigrationWizard`
- [ ] Create `src/renderer/components/data-manager/StorageOverview.tsx`:
- Visual breakdown: local disk used vs. cloud used vs. cloud limit
- Per-category breakdown (emails, files, notes, calendar, vectors)
- Tier upgrade CTA if approaching cloud limit
- [ ] Create `src/renderer/components/data-manager/DataSourceCard.tsx`:
- Card per data source (e.g. "Gmail Scanner", "Documenti/Fatture watcher")
- Shows record count, size, last sync time
- Inline `StoragePicker` toggle for that source (where its data lives)
- "Clear local cache" / "Delete all data" actions with confirmation
- [ ] Create `src/renderer/components/data-manager/MigrationWizard.tsx`:
- Step wizard: select source select direction (local cloud or cloud local) confirm
- Shows estimated data size and time
- Progress indicator during migration
- Rolls back on error
- **Files:** `src/renderer/pages/DataManagerPage.tsx`, `src/renderer/components/data-manager/{StorageOverview,DataSourceCard,MigrationWizard}.tsx`
- **Outcome:** Users have full visibility and control over where every piece of their data lives.
### Step 6.6 — ActivityLogPage
- [ ] Create `src/renderer/pages/ActivityLogPage.tsx`:
- Full-page filterable table of all batch/plugin activity entries
- Columns: timestamp, source (batch name / plugin name), action type, data accessed, storage destination, status
- Filters: source, date range, action type, status (success/error)
- Row expand: shows full detail which records were created/updated, which files were read, LLM calls made
- Export as CSV button
- **Files:** `src/renderer/pages/ActivityLogPage.tsx`
- **Outcome:** Complete transparency log so users can audit exactly what each agent did and when.
### Step 6.7 — SettingsPage (multi-provider, auth, backup, embeddings)
- [ ] Create `src/renderer/pages/SettingsPage.tsx` with tabbed sections:
- **AI Providers** tab:
- List of configured providers with status badge (active / inactive / error)
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
- Set primary provider and fallback chain
- Test connection button per provider
- Separate "Embeddings provider" section: provider + model for embeddings (OpenAI, Cohere, Voyage, Mistral Embed)
- Info callout: "Text sent to the embeddings provider to generate vectors make sure you trust this provider with your data"
- **Account & Billing** tab:
- Login/register form (when not authenticated)
- Current tier display with feature list and upgrade CTA
- Usage indicators (batch count, cloud storage used)
- Logout button
- **Backup & Sync** tab:
- Recovery passphrase: generate new / view existing (masked, reveal on click)
- Manual backup trigger with last backup timestamp
- Auto-backup schedule toggle + interval picker
- Backup history table (timestamp, size, restore button)
- **Permissions** tab:
- Table of all active permission grants (plugin/batch, permission type, resource, granted date)
- Revoke button per grant
- Links to ActivityLogPage for per-source audit
- [ ] Create `src/renderer/components/common/ProviderSelector.tsx`:
- Reusable dropdown that lists configured LLM providers
- Used in BatchBuilder (model_override field) and Settings
- [ ] Create `src/renderer/components/common/PermissionDialog.tsx`:
- Modal triggered when a plugin/batch requests new permissions
- Lists each requested permission with its reason and resource path
- Per-permission approve/deny toggles (deny is default)
- Shows plugin/batch manifest info (name, description, version)
- "Approve selected" confirms; "Deny all" closes without granting
- **Files:** `src/renderer/pages/SettingsPage.tsx`, `src/renderer/components/common/PermissionDialog.tsx`, `src/renderer/components/common/ProviderSelector.tsx`
- **Outcome:** Centralised settings covering providers, embeddings, auth, backup, and permissions.
---
## Phase 7 — Cleanup & Hardening
### Step 7.1 — Remove deprecated AI code
- [ ] Delete `src/main/ai/copilot.ts` (Copilot SDK replaced by LiteLLM)
- [ ] Delete `src/main/ai/chat-copilot.ts` (LangChain adapter no longer needed)
- [ ] Delete or archive `src/main/ai/llm.ts` (replaced by `src/main/llm/litellm-client.ts`)
- [ ] Remove `@github/copilot-sdk`, `@langchain/langgraph` from dependencies (if unused)
- [ ] Clean up `src/main/ai/provider.ts`: simplify to delegate to `src/main/llm/providers.ts`
- [ ] Remove `currentSender` module-level mutable state from orchestrator (proper context passing)
- [ ] Update `src/main/index.ts` startup: remove `import './ai/copilot'`, add `BatchRunner.startScheduler()`, add `AuthManager` init
- **Files:** Multiple files under `src/main/ai/`, `package.json`, `src/main/index.ts`
- **Outcome:** No dead code; clean, maintainable codebase.
### Step 7.2 — Add error handling and logging
- [ ] Implement structured logging in main process:
- Log levels: debug, info, warn, error
- Log destinations: console (dev), file (production, rotated)
- Correlation IDs for request tracing across IPC backend response
- [ ] Add error boundaries in renderer:
- Per-route error boundaries
- AI chat error boundary (graceful degradation)
- Plugin error boundary (shows which plugin failed)
- **Files:** `src/main/utils/logger.ts` (new), `src/renderer/components/ErrorBoundary.tsx` (new)
- **Outcome:** Production-ready error handling and observability.
### Step 7.3 — Electron integration tests
- [ ] Test BackendClient with mocked HTTP responses
- [ ] Test PlanRunner with sample execution plans
- [ ] Test SyncQueue offline online transition
- [ ] Test BackupManager encrypt decrypt round-trip
- [ ] Test PermissionManager grant check revoke cycle
- **Files:** `src/main/__tests__/` (new test directory)
- **Outcome:** Confidence that all Electron-side components work correctly.
---
## New Dependencies (package.json)
| Package | Purpose |
|---|---|
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
| `argon2` | Key derivation for E2E backup |
| `node-cron` | Batch agent scheduling |
| `chokidar` | File watching (FileWatcher plugin) |
| `imapflow` | IMAP client (IMAP connector) |
| `googleapis` | Gmail + GDrive OAuth connectors |
| `lancedb` | Local vector store |
| `onnxruntime-node` | Local embeddings (optional, future) |
---
## Execution Notes
- **Each step is independently committable** and produces working code.
- **Phases 1-2** (LLM client + plugins) are independent of the backend can start immediately.
- **Phase 3** (backend integration) requires the backend repo to have the `/api/v1/chat` endpoint ready.
- **Phase 5.2** (SQLCipher) is intentionally late to avoid encryption overhead during active schema changes.
- **The existing app continues to work** throughout the migration. Local orchestration is preserved until backend is ready (Step 3.2).
- **One step at a time.** Implement one numbered step per session. When the step is fully done, mark all its checkboxes as `[x]` in this file and commit with message `step N complete: <outcome line>`.

View File

@@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl
### Workflow Protocol for Each Step
1. **Start a new chat** and say: _"Read `docs/floating-ai-integration-guide.md` and implement Step X"_
1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_
2. **Before writing any code**, the agent MUST:
- Read ALL files listed in the step's "Files to Read First" section
- Read the project's `CLAUDE.md` for build/lint commands and conventions
@@ -31,13 +31,13 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| Step | Title | Status |
|------|-------|--------|
| 1 | Extract shared `useAIChat` hook | [ ] |
| 2 | Create section registry + `FloatingChatContext` | [ ] |
| 3 | Create double-click hook | [ ] |
| 4 | Build `FloatingChat` component | [ ] |
| 5 | Add `ai:action` IPC side-channel | [ ] |
| 6 | Pass `uiContext` through to the AI | [ ] |
| 7 | Implement morph animation (FLIP) | [ ] |
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
| 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 | [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 | [ ] |
@@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists:
## Step 1: Extract Shared `useAIChat` Hook
**Status**: [ ]
**Status**: [x] 2026-02-27
**Prerequisites**: None
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
**Modifies**: `src/renderer/components/ai/AIChatPanel.tsx`
@@ -142,7 +142,7 @@ Refactor `AIChatPanel.tsx` to consume `useAIChat` instead of managing chat state
## Step 2: Create Section Registry + `FloatingChatContext`
**Status**: [ ]
**Status**: [x] 2026-02-27
**Prerequisites**: Step 1 completed
**Creates**: `src/renderer/context/FloatingChatContext.tsx`
**Modifies**: `src/renderer/components/layout/AppShell.tsx`
@@ -355,7 +355,7 @@ return (
## Step 3: Create Double-Click Hook
**Status**: [ ]
**Status**: [x] 2026-02-27
**Prerequisites**: Step 2 completed
**Creates**: `src/renderer/hooks/useDoubleClickAI.ts`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call)
@@ -452,7 +452,7 @@ function AppShellInner({ children }: AppShellProps) {
## Step 4: Build `FloatingChat` Component
**Status**: [ ]
**Status**: [x] 2026-02-27
**Prerequisites**: Steps 1-3 completed
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
@@ -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`

22
knip.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"tags": ["-lintignore"],
"entry": [
"src/main/index.ts",
"src/preload/index.ts",
"src/preload/trpc.ts",
"forge.config.ts",
"vite.main.config.mts",
"vite.preload.config.mts",
"vite.renderer.config.mts"
],
"ignoreDependencies": [
"postcss",
"@electron-forge/shared-types",
"@milkdown/plugin-upload",
"@milkdown/prose"
],
"ignore": [
"src/renderer/components/ui/**"
]
}

458
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",
@@ -59,6 +58,7 @@
"@tanstack/router-vite-plugin": "^1.161.1",
"@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -69,6 +69,7 @@
"eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"knip": "^5.85.0",
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4.2.0",
@@ -4930,6 +4931,306 @@
"dev": true,
"license": "MIT"
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz",
"integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-android-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz",
"integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-darwin-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz",
"integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-darwin-x64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz",
"integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-freebsd-x64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz",
"integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz",
"integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz",
"integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz",
"integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz",
"integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz",
"integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz",
"integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz",
"integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz",
"integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz",
"integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz",
"integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-openharmony-arm64": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz",
"integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz",
"integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz",
"integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-ia32-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz",
"integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz",
"integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -7934,9 +8235,9 @@
}
},
"node_modules/@types/node": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -13488,6 +13789,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"walk-up-path": "^4.0.0"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -13758,6 +14069,22 @@
"node": ">= 6"
}
},
"node_modules/formatly": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
"integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"fd-package-json": "^2.0.0"
},
"bin": {
"formatly": "bin/index.mjs"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -15711,6 +16038,74 @@
"node": ">=6"
}
},
"node_modules/knip": {
"version": "5.85.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.85.0.tgz",
"integrity": "sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/webpro"
},
{
"type": "opencollective",
"url": "https://opencollective.com/knip"
}
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"formatly": "^0.3.0",
"jiti": "^2.6.0",
"js-yaml": "^4.1.1",
"minimist": "^1.2.8",
"oxc-resolver": "^11.15.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"smol-toml": "^1.5.2",
"strip-json-comments": "5.0.3",
"zod": "^4.1.11"
},
"bin": {
"knip": "bin/knip.js",
"knip-bun": "bin/knip-bun.js"
},
"engines": {
"node": ">=18.18.0"
},
"peerDependencies": {
"@types/node": ">=18",
"typescript": ">=5.0.4 <7"
}
},
"node_modules/knip/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/knip/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/langsmith": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz",
@@ -18270,6 +18665,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oxc-resolver": {
"version": "11.19.1",
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz",
"integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-resolver/binding-android-arm-eabi": "11.19.1",
"@oxc-resolver/binding-android-arm64": "11.19.1",
"@oxc-resolver/binding-darwin-arm64": "11.19.1",
"@oxc-resolver/binding-darwin-x64": "11.19.1",
"@oxc-resolver/binding-freebsd-x64": "11.19.1",
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1",
"@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1",
"@oxc-resolver/binding-linux-arm64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-arm64-musl": "11.19.1",
"@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-riscv64-musl": "11.19.1",
"@oxc-resolver/binding-linux-s390x-gnu": "11.19.1",
"@oxc-resolver/binding-linux-x64-gnu": "11.19.1",
"@oxc-resolver/binding-linux-x64-musl": "11.19.1",
"@oxc-resolver/binding-openharmony-arm64": "11.19.1",
"@oxc-resolver/binding-wasm32-wasi": "11.19.1",
"@oxc-resolver/binding-win32-arm64-msvc": "11.19.1",
"@oxc-resolver/binding-win32-ia32-msvc": "11.19.1",
"@oxc-resolver/binding-win32-x64-msvc": "11.19.1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -21397,6 +21824,19 @@
"npm": ">= 3.0.0"
}
},
"node_modules/smol-toml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@@ -23646,6 +24086,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",

View File

@@ -10,7 +10,8 @@
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint --ext .ts,.tsx ."
"lint": "eslint --ext .ts,.tsx .",
"knip": "knip"
},
"keywords": [],
"author": "rmusso",
@@ -28,6 +29,7 @@
"@tanstack/router-vite-plugin": "^1.161.1",
"@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
@@ -38,6 +40,7 @@
"eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"knip": "^5.85.0",
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4.2.0",
@@ -53,7 +56,6 @@
"@langchain/langgraph": "^1.1.5",
"@langchain/openai": "^1.2.9",
"@milkdown/crepe": "^7.18.0",
"@milkdown/kit": "^7.18.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",

View File

@@ -1,105 +0,0 @@
# Ralph Agent Instructions
You are an autonomous coding agent working on a software project.
## Your Task
1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
2. Read the PRD at `prd.json` (in the same directory as this file)
3. Read the progress log at `progress.txt` (check Codebase Patterns section first)
4. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main.
5. Pick the **highest priority** user story where `passes: false`
6. Implement that single user story
7. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
8. Update CLAUDE.md files if you discover reusable patterns (see below)
9. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
10. Update the PRD to set `passes: true` for the completed story
11. Append your progress to `progress.txt`
## Progress Report Format
APPEND to progress.txt (never replace, always append):
```
## [Date/Time] - [Story ID]
- What was implemented
- Files changed
- **Learnings for future iterations:**
- Patterns discovered (e.g., "this codebase uses X for Y")
- Gotchas encountered (e.g., "don't forget to update Z when changing W")
- Useful context (e.g., "the evaluation panel is in component X")
---
```
The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better.
## Consolidate Patterns
If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings:
```
## Codebase Patterns
- Example: Use `sql<number>` template for aggregations
- Example: Always use `IF NOT EXISTS` for migrations
- Example: Export types from actions.ts for UI components
```
Only add patterns that are **general and reusable**, not story-specific details.
## Update CLAUDE.md Files
Before committing, check if any edited files have learnings worth preserving in nearby CLAUDE.md files:
1. **Identify directories with edited files** - Look at which directories you modified
2. **Check for existing CLAUDE.md** - Look for CLAUDE.md in those directories or parent directories
3. **Add valuable learnings** - If you discovered something future developers/agents should know:
- API patterns or conventions specific to that module
- Gotchas or non-obvious requirements
- Dependencies between files
- Testing approaches for that area
- Configuration or environment requirements
**Examples of good CLAUDE.md additions:**
- "When modifying X, also update Y to keep them in sync"
- "This module uses pattern Z for all API calls"
- "Tests require the dev server running on PORT 3000"
- "Field names must match the template exactly"
**Do NOT add:**
- Story-specific implementation details
- Temporary debugging notes
- Information already in progress.txt
Only update CLAUDE.md if you have **genuinely reusable knowledge** that would help future work in that directory.
## Quality Requirements
- ALL commits must pass your project's quality checks (typecheck, lint, test)
- Do NOT commit broken code
- Keep changes focused and minimal
- Follow existing code patterns
## Browser Testing (If Available)
For any story that changes UI, verify it works in the browser if you have browser testing tools configured (e.g., via MCP):
1. Navigate to the relevant page
2. Verify the UI changes work as expected
3. Take a screenshot if helpful for the progress log
If no browser tools are available, note in your progress report that manual browser verification is needed.
## Stop Condition
After completing a user story, check if ALL stories have `passes: true`.
If ALL stories are complete and passing, reply with:
<promise>COMPLETE</promise>
If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story).
## Important
- Work on ONE story per iteration
- Commit frequently
- Keep CI green
- Read the Codebase Patterns section in progress.txt before starting

View File

@@ -1,113 +0,0 @@
#!/bin/bash
# Ralph Wiggum - Long-running AI agent loop
# Usage: ./ralph.sh [--tool amp|claude] [max_iterations]
set -e
# Parse arguments
TOOL="claude" # Default to claude for backwards compatibility
MAX_ITERATIONS=10
while [[ $# -gt 0 ]]; do
case $1 in
--tool)
TOOL="$2"
shift 2
;;
--tool=*)
TOOL="${1#*=}"
shift
;;
*)
# Assume it's max_iterations if it's a number
if [[ "$1" =~ ^[0-9]+$ ]]; then
MAX_ITERATIONS="$1"
fi
shift
;;
esac
done
# Validate tool choice
if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then
echo "Error: Invalid tool '$TOOL'. Must be 'amp' or 'claude'."
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PRD_FILE="$SCRIPT_DIR/prd.json"
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
ARCHIVE_DIR="$SCRIPT_DIR/archive"
LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
# Archive previous run if branch changed
if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "")
if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then
# Archive the previous run
DATE=$(date +%Y-%m-%d)
# Strip "ralph/" prefix from branch name for folder
FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||')
ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME"
echo "Archiving previous run: $LAST_BRANCH"
mkdir -p "$ARCHIVE_FOLDER"
[ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/"
[ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/"
echo " Archived to: $ARCHIVE_FOLDER"
# Reset progress file for new run
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
echo "Started: $(date)" >> "$PROGRESS_FILE"
echo "---" >> "$PROGRESS_FILE"
fi
fi
# Track current branch
if [ -f "$PRD_FILE" ]; then
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
if [ -n "$CURRENT_BRANCH" ]; then
echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE"
fi
fi
# Initialize progress file if it doesn't exist
if [ ! -f "$PROGRESS_FILE" ]; then
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
echo "Started: $(date)" >> "$PROGRESS_FILE"
echo "---" >> "$PROGRESS_FILE"
fi
echo "Starting Ralph - Tool: $TOOL - Max iterations: $MAX_ITERATIONS"
for i in $(seq 1 $MAX_ITERATIONS); do
echo ""
echo "==============================================================="
echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)"
echo "==============================================================="
# Run the selected tool with the ralph prompt
if [[ "$TOOL" == "amp" ]]; then
OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
else
# Claude Code: use --dangerously-skip-permissions for autonomous operation, --print for output
OUTPUT=$(claude --dangerously-skip-permissions --print < "$SCRIPT_DIR/CLAUDE.md" 2>&1 | tee /dev/stderr) || true
fi
# Check for completion signal
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
echo ""
echo "Ralph completed all tasks!"
echo "Completed at iteration $i of $MAX_ITERATIONS"
exit 0
fi
echo "Iteration $i complete. Continuing..."
sleep 2
done
echo ""
echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks."
echo "Check $PROGRESS_FILE for status."
exit 1

View File

@@ -24,18 +24,22 @@ 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
// ---------------------------------------------------------------------------
export interface OrchestrateInput {
interface OrchestrateInput {
message: string;
context: { type: 'global' | 'project'; projectId?: string };
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
sender?: Electron.WebContents;
}
export interface OrchestrateResult {
interface OrchestrateResult {
response: string;
error?: string;
}
@@ -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();

View File

@@ -33,7 +33,7 @@ export function getActiveProviderName(): string {
}
/** Switch to a different registered provider. */
export function setActiveProviderName(name: string): void {
function setActiveProviderName(name: string): void {
const provider = providers.get(name);
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
activeProvider = provider;

View File

@@ -100,7 +100,7 @@ export async function setToken(providerName: string, token: string): Promise<voi
}
/** Delete a stored token for the given provider. */
export async function deleteToken(providerName: string): Promise<boolean> {
async function deleteToken(providerName: string): Promise<boolean> {
if (useKeytar()) {
try {
return await keytar!.deletePassword(SERVICE_NAME, providerName);

View File

@@ -14,7 +14,7 @@ import {
type AnyRouter,
} from '@trpc/server';
export const IPC_CHANNEL = 'trpc';
const IPC_CHANNEL = 'trpc';
/** Context passed to every tRPC procedure via the IPC bridge. */
export type TRPCContext = {

View File

@@ -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 }) => {

View File

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

View File

@@ -1,20 +1,17 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { Card, CardContent } from '@/components/ui/card';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
/** Fluid font size for chat messages — scales with viewport width */
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
@@ -23,21 +20,35 @@ const SUGGESTION_CHIPS = [
{ icon: Lightbulb, label: 'Suggest next actions' },
] as const;
function getTimeGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning,';
if (hour < 17) return 'Good afternoon,';
return 'Good evening,';
}
/* Entrance animation: staggered fade-up */
const stagger = {
hidden: {},
show: { transition: { staggerChildren: 0.08 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 16 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const },
},
};
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();
@@ -46,10 +57,18 @@ export function AIChatPanel({
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const chatContext = useMemo<ChatContext>(
() => ({ type: 'global' as const }),
[],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
@@ -57,30 +76,50 @@ export function AIChatPanel({
const briefContentRef = useRef('');
const hasFiredBrief = useRef(false);
const [briefExpanded, setBriefExpanded] = useState(false);
const [briefDismissed, setBriefDismissed] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
// --- Scroll-to-user-message + shrinking placeholder ---
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
const initialPlaceholderRef = useRef(0);
const pendingScrollRef = useRef(false);
const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => {
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Reset input when curtain closes; scroll to bottom when it reopens
// When the user message appears in the list, set the placeholder and scroll it to the top
useEffect(() => {
if (!curtainOpen) {
setInput('');
} else {
setTimeout(scrollToBottom, 50);
}
}, [curtainOpen, scrollToBottom]);
if (!pendingScrollRef.current) return;
const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.role !== 'user') return;
// Auto-scroll when messages change or streaming content updates
pendingScrollRef.current = false;
const ph = Math.round(window.innerHeight * 0.71);
initialPlaceholderRef.current = ph;
setPlaceholderHeight(ph);
// Double-rAF: wait for the placeholder div to actually paint before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => {
lastUserMsgRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
});
}, [messages]);
// Shrink placeholder in real-time as AI streaming content grows
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
if (!isStreaming || !streamingEl) return;
const MIN_PADDING = 80;
const observer = new ResizeObserver(() => {
const contentHeight = streamingEl.getBoundingClientRect().height;
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
});
observer.observe(streamingEl);
return () => observer.disconnect();
}, [isStreaming, streamingEl]);
// Auto-fire daily brief on home page
useEffect(() => {
@@ -116,72 +155,10 @@ export function AIChatPanel({
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming || briefLoading) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]);
if (briefLoading) return;
pendingScrollRef.current = true;
chatHandleSend();
}, [briefLoading, chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -190,92 +167,139 @@ 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>
)}
{/* Sticky brief toast — anchored at top when chatting */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
aria-label="Dismiss brief"
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Scrollable messages area */}
<ScrollArea
className="flex-1 min-h-0"
viewportRef={messagesContainerRef}
viewportClassName={
isHomePage && !hasMessages
? '[&>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}
>
<div className="relative flex-1 min-h-0">
{/* Gradual blur at the bottom of messages */}
{hasMessages && (
<GradualBlur
position="bottom"
strength={0.6}
height="4rem"
divCount={10}
curve="ease-out"
opacity={0.8}
zIndex={20}
/>
)}
<ScrollArea
className="h-full"
viewportRef={messagesContainerRef}
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={
isHomePage && !hasMessages
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
}
>
{/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
<div className="flex flex-col gap-8">
{/* Greeting + brief grouped closely */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
Hello, {userName}
</h1>
<Badge variant="secondary">
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
</Badge>
</div>
<motion.div
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
variants={stagger}
initial="hidden"
animate="show"
>
<div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
{/* Greeting — editorial hero moment */}
<motion.div variants={fadeUp} className="flex flex-col gap-1">
<span
className="font-light tracking-wide text-muted-foreground"
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
<span className="text-primary ml-3 inline-block"></span>
</h1>
{dueCount > 0 && (
<p
className="text-muted-foreground mt-2"
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
>
<span className="text-foreground font-medium">{dueCount}</span>
{' '}task{dueCount !== 1 ? 's' : ''} due today
</p>
)}
</motion.div>
{/* Daily brief */}
<div>
{/* Daily brief */}
<motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
<div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={20} className="text-muted-foreground" />
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief.
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -283,67 +307,68 @@ export function AIChatPanel({
</Button>
</div>
) : briefLoading && !dailyBrief ? (
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<div className="space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-5 w-2/3" />
</div>
) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} />
<ChatMarkdown content={dailyBrief} size="lg" />
) : (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Your daily brief will appear here.
</p>
)}
</div>
</div>
</motion.div>
{/* Inline input + suggestion chips */}
<div>
{/* Input + suggestion links */}
<motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming || briefLoading}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
isHomePage={isHomePage}
/>
<div className="flex flex-wrap items-center justify-center gap-2 mt-4">
<div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => (
<button
key={chip.label}
type="button"
className="group flex items-center gap-2 rounded-full border border-border/50 bg-background/60 backdrop-blur-lg px-4 py-2 text-sm text-foreground shadow-sm ring-1 ring-border/20 transition-all hover:shadow-md hover:-translate-y-0.5 hover:border-ring/40 cursor-pointer"
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(chip.label)}
>
<chip.icon size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
<chip.icon
size={16}
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
/>
<span>{chip.label}</span>
</button>
))}
</div>
</div>
</motion.div>
</div>
</div>
</motion.div>
)}
{/* Home page with messages: brief stays, then messages */}
{isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-32">
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
<div className="flex flex-col gap-4">
{/* Brief persists */}
{dailyBrief && (
<div className="mb-2">
<ChatMarkdown content={dailyBrief} />
</div>
)}
{/* Chat messages */}
{messages.map((msg) => {
{messages.map((msg, idx) => {
const isLastMsg = idx === messages.length - 1;
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div
key={msg.id}
ref={isLastMsg ? lastUserMsgRef : undefined}
className="flex justify-end"
>
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
@@ -352,7 +377,7 @@ export function AIChatPanel({
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap">
<p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
@@ -363,10 +388,10 @@ export function AIChatPanel({
<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>
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
<div className="pl-[22px]">
<ChatMarkdown content={msg.content} />
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
@@ -374,14 +399,14 @@ export function AIChatPanel({
{/* Streaming AI response */}
{isStreaming && (
<div className="mr-auto max-w-[75%]">
<div ref={setStreamingEl} 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>
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} />
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
@@ -391,84 +416,36 @@ export function AIChatPanel({
)}
</div>
)}
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
{placeholderHeight !== null && (
<div
aria-hidden
style={{
height: placeholderHeight,
transition: 'height 180ms ease-out',
flexShrink: 0,
}}
/>
)}
</div>
</div>
)}
{/* 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>
);
}
</ScrollArea>
</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) && (
<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]'}`}>
{/* Fixed input — pinned to the bottom, above the blur */}
{hasMessages && (
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
<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>
@@ -485,7 +462,6 @@ interface ChatInputProps {
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
isHomePage?: boolean;
}
function ChatInput({
@@ -503,6 +479,7 @@ function ChatInput({
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask me anything..."
aria-label="Chat message"
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
@@ -510,6 +487,7 @@ function ChatInput({
<button
onClick={onSend}
disabled={!input.trim() || isStreaming}
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<ArrowUp size={16} />
@@ -521,9 +499,12 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
function ChatMarkdown({ content }: { content: string }) {
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
style={fontSize ? { fontSize } : undefined}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{

View File

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

View File

@@ -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,
@@ -18,6 +16,7 @@ import {
Palette
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
import {
Sidebar,
SidebarContent,
@@ -30,14 +29,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuTrigger,
@@ -55,7 +53,9 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' },
@@ -68,21 +68,17 @@ 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>
<AppShellInner>{children}</AppShellInner>
</FloatingChatProvider>
);
}
export function AppShell({ children }: AppShellProps) {
function AppShellInner({ children }: AppShellProps) {
useDoubleClickAI();
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
});
@@ -119,142 +115,33 @@ export function AppShell({ 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 */}
<AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)}
contextType={contextType}
projectId={projectId}
projectName={projectQuery.data?.name}
curtainOpen={isHomePage || curtainOpen}
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"
>
<SidebarInset>
{isHomePage ? (
<AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)}
isHomePage
/>
) : (
<div className="relative flex flex-col h-full">
<header className="flex items-center gap-2 p-2 md:hidden">
<SidebarTrigger />
</header>
{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>
</div>
</motion.div>
</div>
)}
</SidebarInset>
</SidebarProvider>
{/* Floating AI Chat — portal to document.body */}
<FloatingChatPortal />
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
setTokenDialogOpen(open);
@@ -278,13 +165,13 @@ export function AppShell({ children }: AppShellProps) {
<p className="text-xs text-muted-foreground">
Your token is stored securely in the OS keychain.
{hasTokenQuery.data === true && (
<span className="text-green-600 ml-1">A token is currently stored.</span>
<span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
)}
</p>
</div>
<DialogFooter>
{saved && (
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto">
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
<Check size={14} />
Saved
</span>
@@ -298,17 +185,16 @@ export function AppShell({ 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();
@@ -361,7 +247,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
isActive={isActive}
tooltip={label}
>
<Link to={to} onClick={onNavClick}>
<Link to={to}>
<Icon />
<span>{label}</span>
</Link>

View File

@@ -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>
)}

View File

@@ -1,9 +1,10 @@
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';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import {
Breadcrumb,
@@ -16,6 +17,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 +28,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();
@@ -146,8 +168,17 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Loading project...
<div className="p-6 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-56" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-16 rounded-lg" />
</div>
);
}
@@ -161,7 +192,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,54 +212,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-3 gap-4">
<Item variant="muted">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
{/* 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">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription>
</ItemContent>
</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">
<Milestone />
<Sparkles />
</ItemMedia>
<ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription>
<ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent>
</Item>
</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 */}
<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 +340,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 +406,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

View File

@@ -322,7 +322,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
if (editCreatingClient && editNewClientName.trim()) {
// Create a new client
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
let parentId = result.id;
const parentId = result.id;
if (editCreatingSubClient && editNewSubClientName.trim()) {
// Also create a sub-client under the new client

View File

@@ -4,21 +4,21 @@ export function PriorityBadge({ priority }: { priority: string | null }) {
switch (priority) {
case 'high':
return (
<span className="inline-flex items-center gap-1 text-xs">
<span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<ArrowUp className="h-3 w-3" />
High
</span>
);
case 'medium':
return (
<span className="inline-flex items-center gap-1 text-xs">
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<ArrowRight className="h-3 w-3" />
Medium
</span>
);
case 'low':
return (
<span className="inline-flex items-center gap-1 text-xs">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ArrowDown className="h-3 w-3" />
Low
</span>

View File

@@ -1,4 +1,7 @@
import { Fragment } from 'react';
import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
@@ -57,6 +60,7 @@ export function TaskRow({
onDelete,
onClick,
hideBreadcrumb,
layoutId,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
@@ -64,6 +68,7 @@ export function TaskRow({
onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void;
hideBreadcrumb?: boolean;
layoutId?: string;
}) {
const isDone = task.status === 'done';
@@ -84,13 +89,21 @@ 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
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'}`}
<Wrapper
{...wrapperProps}
className={cn(
'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',
)}
onClick={() => onClick?.(task)}
>
{/* Row 1: checkbox + title + description */}
@@ -102,7 +115,7 @@ export function TaskRow({
className="mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}>
<div className={cn('text-sm font-medium', isDone && 'line-through text-muted-foreground')}>
{task.title}
</div>
{task.description && (
@@ -129,10 +142,12 @@ export function TaskRow({
<Breadcrumb className="shrink-0">
<BreadcrumbList>
{breadcrumb.map((part, i) => (
<BreadcrumbItem key={i}>
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-xs">{part}</span>
</BreadcrumbItem>
<BreadcrumbItem>
<span className="text-xs">{part}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
@@ -146,7 +161,7 @@ export function TaskRow({
)}
</div>
)}
</div>
</Wrapper>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -0,0 +1,109 @@
import { useMemo } from 'react';
type Position = 'top' | 'bottom';
interface GradualBlurProps {
/** Edge to attach the blur overlay */
position?: Position;
/** Base blur strength multiplier */
strength?: number;
/** Overlay height (CSS value) */
height?: string;
/** Number of stacked blur layers (higher = smoother) */
divCount?: number;
/** Use exponential progression for stronger end blur */
exponential?: boolean;
/** Distribution curve: linear | ease-out */
curve?: 'linear' | 'ease-out';
/** Opacity applied to each blur layer */
opacity?: number;
/** z-index for the overlay */
zIndex?: number;
/** Additional class names */
className?: string;
}
const getGradientDirection = (position: Position) =>
position === 'top' ? 'to top' : 'to bottom';
export function GradualBlur({
position = 'top',
strength = 2,
height = '6rem',
divCount = 5,
exponential = false,
curve = 'linear',
opacity = 1,
zIndex = 10,
className = '',
}: GradualBlurProps) {
const blurDivs = useMemo(() => {
const divs: React.ReactNode[] = [];
const increment = 100 / divCount;
const direction = getGradientDirection(position);
const curveFunc = curve === 'ease-out'
? (p: number) => 1 - Math.pow(1 - p, 2)
: (p: number) => p;
for (let i = 1; i <= divCount; i++) {
let progress = i / divCount;
progress = curveFunc(progress);
let blurValue: number;
if (exponential) {
blurValue = Math.pow(2, progress * 4) * 0.0625 * strength;
} else {
blurValue = progress * strength;
}
const p1 = Math.round((increment * i - increment) * 10) / 10;
const p2 = Math.round(increment * i * 10) / 10;
const p3 = Math.round((increment * i + increment) * 10) / 10;
const p4 = Math.round((increment * i + increment * 2) * 10) / 10;
let gradient = `transparent ${p1}%, black ${p2}%`;
if (p3 <= 100) gradient += `, black ${p3}%`;
if (p4 <= 100) gradient += `, transparent ${p4}%`;
const maskImage = `linear-gradient(${direction}, ${gradient})`;
divs.push(
<div
key={i}
style={{
position: 'absolute',
inset: 0,
maskImage,
WebkitMaskImage: maskImage,
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
opacity,
}}
/>,
);
}
return divs;
}, [position, strength, divCount, exponential, curve, opacity]);
return (
<div
className={className}
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height,
pointerEvents: 'none',
zIndex,
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{blurDivs}
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -8,10 +8,12 @@ function ScrollArea({
children,
viewportRef,
viewportClassName,
scrollbarClassName,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>;
viewportClassName?: string;
scrollbarClassName?: string;
}) {
return (
<ScrollAreaPrimitive.Root
@@ -29,7 +31,7 @@ function ScrollArea({
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar className={scrollbarClassName} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
@@ -45,7 +47,7 @@ function ScrollBar({
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
"flex touch-none p-px transition-colors select-none z-50",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&

View File

@@ -0,0 +1,262 @@
import {
createContext,
useContext,
useCallback,
useState,
useRef,
type ReactNode,
type RefObject,
} from 'react';
// ---------- Types ----------
interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
}
interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
}
interface FloatingChatState {
isOpen: boolean;
activeSectionId: string | null;
position: { x: number; y: number; width: number };
morphTargetId: string | null;
projectId?: string;
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
}
interface FloatingChatContextValue {
// State
state: FloatingChatState;
sections: Map<string, AISection>;
// Section registry
registerSection: (section: AISection) => void;
unregisterSection: (id: string) => void;
// Actions
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 ----------
/** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
export function getChatWidth(): number {
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
}
export const CHAT_HEIGHT = 420;
export const PADDING = 16;
// ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
};
}
function computeAnchorPosition(
section: AISection,
opts?: SectionOpenOpts,
): { x: number; y: number; width: number } {
const el = section.ref.current;
const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right';
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: w };
}
// Default: top-right of section
const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: w };
}
/**
* 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();
const w = getChatWidth();
// 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 - w - PADDING,
rect.top + PADDING,
);
return { x, y, width: w };
}
// Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition(
rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING,
);
return { x, y, width: w };
}
// Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition(
rect.right - w - PADDING,
PADDING,
);
return { x, y, width: w };
}
// ---------- Context ----------
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
export function useFloatingChat(): FloatingChatContextValue {
const ctx = useContext(FloatingChatCtx);
if (!ctx)
throw new Error('useFloatingChat must be used within FloatingChatProvider');
return ctx;
}
// ---------- Provider ----------
export function FloatingChatProvider({ children }: { children: ReactNode }) {
const sectionsRef = useRef<Map<string, AISection>>(new Map());
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
const [state, setState] = useState<FloatingChatState>({
isOpen: false,
activeSectionId: null,
position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null,
});
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) => {
sectionsRef.current.delete(id);
setSections(new Map(sectionsRef.current));
}, []);
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState({
isOpen: true,
activeSectionId: sectionId,
position,
morphTargetId: null,
projectId: section.projectId,
});
}, []);
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId);
if (!section) return;
const position = computeAnchorPosition(section, opts);
setState((prev) => ({
...prev,
activeSectionId: sectionId,
position,
projectId: section.projectId,
}));
}, []);
const close = useCallback(() => {
setState((prev) => ({
...prev,
isOpen: false,
activeSectionId: null,
morphTargetId: null,
}));
}, []);
const setMorphTarget = useCallback((id: string | null) => {
setState((prev) => ({ ...prev, morphTargetId: id }));
}, []);
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={{
state,
sections,
registerSection,
unregisterSection,
openAtSection,
moveToSection,
close,
setMorphTarget,
updatePosition,
setPendingSection,
}}
>
{children}
</FloatingChatCtx.Provider>
);
}

View File

@@ -1,6 +1,8 @@
@import '@fontsource/geist/300.css';
@import '@fontsource/geist/400.css';
@import '@fontsource/geist/500.css';
@import '@fontsource/geist/600.css';
@import '@fontsource/geist/700.css';
@import "tailwindcss";
@import "tw-animate-css";
@@ -50,73 +52,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 {
@@ -142,6 +184,78 @@ body {
overflow: hidden;
}
/* ---- Glass Surface (ReactBits-style) ---- */
/*
* Gradient border via padding-box/border-box background split —
* most reliable technique in Chromium/Electron; no pseudo-element mask needed.
*/
.glass-surface {
border: 1px solid transparent;
background:
/* glass fill — clips to padding-box (inside the border) */
rgba(255, 255, 255, 0.55) padding-box,
/* gradient border — clips to border-box (the 1px border strip) */
linear-gradient(
145deg,
rgba(255, 255, 255, 0.90) 0%,
rgba(200, 195, 205, 0.40) 40%,
rgba(200, 195, 205, 0.20) 100%
) border-box;
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.10),
0 1px 2px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.80);
}
.dark .glass-surface {
background:
rgba(255, 255, 255, 0.05) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.04) 40%,
rgba(255, 255, 255, 0.08) 100%
) border-box;
box-shadow:
0 4px 48px rgba(0, 0, 0, 0.50),
0 1px 2px rgba(0, 0, 0, 0.20),
inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
/* Subtle variant — same gradient border, much more transparent fill */
.glass-surface-subtle {
border: 1px solid transparent;
background:
rgba(255, 255, 255, 0.20) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.70) 0%,
rgba(200, 195, 205, 0.25) 40%,
rgba(200, 195, 205, 0.10) 100%
) border-box;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.60);
}
.dark .glass-surface-subtle {
background:
rgba(255, 255, 255, 0.03) padding-box,
linear-gradient(
145deg,
rgba(255, 255, 255, 0.12) 0%,
rgba(255, 255, 255, 0.02) 40%,
rgba(255, 255, 255, 0.05) 100%
) border-box;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.30),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
}
/* Crepe editor layout */
.milkdown-container {
display: flex;

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc';
export interface ChatMessage {
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
@@ -14,7 +14,7 @@ export interface ChatContext {
uiContext?: string;
}
export interface UseAIChatReturn {
interface UseAIChatReturn {
messages: ChatMessage[];
input: string;
setInput: (v: string) => void;
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
clearMessages: () => void;
}
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
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 },

View File

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

View File

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

View File

@@ -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}

View File

@@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import {
ClipboardCheck,
ListTodo,
Loader2,
Clock,
CheckCircle2,
Plus,
Search,
@@ -40,6 +41,19 @@ const ORDER_LABELS: Record<OrderBy, string> = {
};
function TasksPage() {
// AI section refs
const overviewRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
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('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@@ -110,9 +124,9 @@ function TasksPage() {
);
return (
<div 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 />
@@ -133,7 +147,7 @@ function TasksPage() {
</Item>
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
<ItemMedia variant="icon">
<Loader2 />
<Clock />
</ItemMedia>
<ItemContent>
<ItemTitle>{stats.inProgress}</ItemTitle>
@@ -151,50 +165,52 @@ function TasksPage() {
</Item>
</div>
{/* Search + Order By */}
<div className="flex items-center gap-3">
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tasks or projects..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</InputGroup>
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Order by" />
</SelectTrigger>
<SelectContent>
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</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">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tasks or projects..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</InputGroup>
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Order by" />
</SelectTrigger>
<SelectContent>
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
</div>
{/* Status Filter Tabs + New Task Button */}
<div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="todo">To Do</TabsTrigger>
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
<TabsTrigger value="done">Completed</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Task
</Button>
</div>
{/* Task List */}
<div className="flex flex-col gap-1">
{/* Task List */}
<div className="flex flex-col gap-1">
{tasksList.length === 0 ? (
<Empty>
<EmptyHeader>
@@ -216,9 +232,15 @@ 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} />

View File

@@ -1,11 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { useEffect, useRef, useState, useMemo } from 'react';
import { Plus, ChartGantt } 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';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
export const Route = createFileRoute('/timeline')({
component: TimelinePage,
@@ -15,6 +17,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 +80,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>
@@ -98,9 +108,17 @@ function TimelinePage() {
{/* Gantt Chart */}
{ganttCheckpoints.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30">
No checkpoints yet. Click "+ Add" to create your first milestone.
</div>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ChartGantt />
</EmptyMedia>
<EmptyTitle>No milestones yet</EmptyTitle>
<EmptyDescription>
Click "+ Add" to create your first project checkpoint.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="border rounded-md p-4 bg-card">
<GanttChart