Compare commits
29 Commits
4b2162505c
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
| b77c6d1195 | |||
| 489e8e3bc9 | |||
| 1ba9c9eee2 | |||
|
|
0f3c63c4de | ||
|
|
aa089975df | ||
|
|
a6c04e52af | ||
|
|
d82738e7ea | ||
|
|
e005872ba0 | ||
|
|
d3e82a3ebb | ||
|
|
af8cbc1c96 | ||
|
|
ee6467a7ac | ||
|
|
cdf9a8bf18 | ||
|
|
f767bb5175 | ||
|
|
444aa37be2 | ||
|
|
15051cfa7a | ||
|
|
c5e78311e6 | ||
|
|
60b76c6d97 | ||
|
|
d12681b79f | ||
|
|
6c498c5f40 | ||
|
|
310370ef66 | ||
|
|
f4e6238176 | ||
|
|
d8cf7814ab | ||
|
|
50b69aadbf | ||
|
|
6cd121fa80 | ||
|
|
28a5d65f1a | ||
|
|
b4e97e14f3 | ||
|
|
78b4df1028 | ||
|
|
96101e4310 | ||
|
|
9c07d3195f |
@@ -1,110 +1,164 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
|
||||||
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload
|
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||||
|
source ~/.nvm/nvm.sh && npm run package # Package without installers
|
||||||
# Build & Package
|
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
|
||||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
|
||||||
source ~/.nvm/nvm.sh && npm run package # Package without making installers
|
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||||
|
|
||||||
# Lint
|
|
||||||
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
|
|
||||||
|
|
||||||
# Database migrations (Drizzle)
|
|
||||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
|
|
||||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no test suite currently.
|
No test suite currently.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture
|
||||||
|
|
||||||
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
|
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).
|
||||||
|
|
||||||
### Process Boundaries
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
### Main Process (`src/main/`)
|
||||||
- `index.ts` — Window creation, app lifecycle
|
|
||||||
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
|
|
||||||
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
|
|
||||||
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
|
|
||||||
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
|
|
||||||
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
|
|
||||||
|
|
||||||
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
|
Owns the database and all business logic.
|
||||||
|
|
||||||
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly
|
| File | Purpose |
|
||||||
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC`
|
|---|---|
|
||||||
- `lib/trpc.ts` — `createTRPCReact<AppRouter>()` typed client
|
| `index.ts` | Window creation, app lifecycle |
|
||||||
- `index.tsx` — QueryClient + tRPC + Router providers
|
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
|
||||||
- All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()`
|
| `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
|
### 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:
|
File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
|
||||||
- `__root.tsx` — Root layout wrapping everything in `AppShell`
|
|
||||||
- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx`
|
Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
|
||||||
|
|
||||||
|
### tRPC Routers
|
||||||
|
|
||||||
|
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
|
||||||
|
|
||||||
### Database
|
### 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`
|
1. **Schema** — `src/main/db/schema.ts`
|
||||||
2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter`
|
2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
|
||||||
3. **Types** — `AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically
|
3. **Types** — Flow automatically via `AppRouter` export
|
||||||
4. **UI** — Create components under `src/renderer/components/<feature>/`, use `trpc.*.*useQuery()` for data
|
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:
|
### Orchestrator (`orchestrator.ts`)
|
||||||
- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks`
|
|
||||||
- **Knowledge agent** — cross-project semantic search via `vector_search_all`
|
|
||||||
- **General agent** — workspace-wide `add_task`
|
|
||||||
|
|
||||||
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally).
|
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:
|
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
|
||||||
1. keytar (OS keychain) — preferred, encrypted per-user
|
|
||||||
|
### 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
|
2. electron-store + `safeStorage` — encrypted at rest
|
||||||
3. Plain electron-store — WSL fallback
|
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)
|
Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestions appear pending user approval (dashed borders in UI).
|
||||||
- `migrateNotesIfNeeded()` backfills existing notes on first startup
|
|
||||||
- `searchNotes(query, limit=5)` is called by the Knowledge agent tool
|
|
||||||
|
|
||||||
### Key Config Notes
|
## Config Notes
|
||||||
|
|
||||||
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin
|
- Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
|
||||||
- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias)
|
- `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
|
||||||
- shadcn/ui style: **new-york**, base color: **neutral**
|
- **shadcn/ui**: new-york style, neutral base color
|
||||||
- Icons: **lucide-react** throughout — do not introduce other icon libraries
|
- **Icons**: lucide-react only — do not introduce other icon libraries
|
||||||
- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js`
|
- **Tailwind 4** — CSS variable theming in `globals.css`, no `tailwind.config.js`
|
||||||
- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`)
|
- **Notes editor**: Milkdown (`@milkdown/crepe`) at `src/renderer/components/notes/MilkdownEditor.tsx`
|
||||||
- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param)
|
|
||||||
|
## 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
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"shadcn"
|
|
||||||
],
|
|
||||||
"enableAllProjectMcpServers": true
|
|
||||||
}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -92,5 +92,5 @@ typings/
|
|||||||
out/
|
out/
|
||||||
|
|
||||||
# local config files
|
# local config files
|
||||||
.claude/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
11
.vscode/mcp.json
vendored
11
.vscode/mcp.json
vendored
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"servers": {
|
|
||||||
"shadcn": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"shadcn@latest",
|
|
||||||
"mcp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
526
AI_REFACTOR_PLAN.md
Normal file
526
AI_REFACTOR_PLAN.md
Normal 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>`.
|
||||||
@@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl
|
|||||||
|
|
||||||
### Workflow Protocol for Each Step
|
### 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:
|
2. **Before writing any code**, the agent MUST:
|
||||||
- Read ALL files listed in the step's "Files to Read First" section
|
- 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
|
- 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 |
|
| Step | Title | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| 1 | Extract shared `useAIChat` hook | [ ] |
|
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
|
||||||
| 2 | Create section registry + `FloatingChatContext` | [ ] |
|
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
||||||
| 3 | Create double-click hook | [ ] |
|
| 3 | Create double-click hook | [x] 2026-02-27 |
|
||||||
| 4 | Build `FloatingChat` component | [ ] |
|
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
||||||
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
| 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 |
|
||||||
| 6 | Pass `uiContext` through to the AI | [ ] |
|
| 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 |
|
||||||
| 7 | Implement morph animation (FLIP) | [ ] |
|
| 7 | Implement morph animation (FLIP) | [x] 2026-02-28 |
|
||||||
| 8a | Page interactions — Project Detail | [ ] |
|
| 8a | Page interactions — Project Detail | [ ] |
|
||||||
| 8b | Page interactions — Tasks page | [ ] |
|
| 8b | Page interactions — Tasks page | [ ] |
|
||||||
| 8c | Page interactions — Timeline page | [ ] |
|
| 8c | Page interactions — Timeline page | [ ] |
|
||||||
@@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists:
|
|||||||
|
|
||||||
## Step 1: Extract Shared `useAIChat` Hook
|
## Step 1: Extract Shared `useAIChat` Hook
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] 2026-02-27
|
||||||
**Prerequisites**: None
|
**Prerequisites**: None
|
||||||
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
|
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
|
||||||
**Modifies**: `src/renderer/components/ai/AIChatPanel.tsx`
|
**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`
|
## Step 2: Create Section Registry + `FloatingChatContext`
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] 2026-02-27
|
||||||
**Prerequisites**: Step 1 completed
|
**Prerequisites**: Step 1 completed
|
||||||
**Creates**: `src/renderer/context/FloatingChatContext.tsx`
|
**Creates**: `src/renderer/context/FloatingChatContext.tsx`
|
||||||
**Modifies**: `src/renderer/components/layout/AppShell.tsx`
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx`
|
||||||
@@ -355,7 +355,7 @@ return (
|
|||||||
|
|
||||||
## Step 3: Create Double-Click Hook
|
## Step 3: Create Double-Click Hook
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] 2026-02-27
|
||||||
**Prerequisites**: Step 2 completed
|
**Prerequisites**: Step 2 completed
|
||||||
**Creates**: `src/renderer/hooks/useDoubleClickAI.ts`
|
**Creates**: `src/renderer/hooks/useDoubleClickAI.ts`
|
||||||
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call)
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call)
|
||||||
@@ -452,7 +452,7 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
|
|
||||||
## Step 4: Build `FloatingChat` Component
|
## Step 4: Build `FloatingChat` Component
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] 2026-02-27
|
||||||
**Prerequisites**: Steps 1-3 completed
|
**Prerequisites**: Steps 1-3 completed
|
||||||
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
|
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
|
||||||
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
|
**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
|
## Step 5: Add `ai:action` IPC Side-Channel
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Step 4 completed
|
**Prerequisites**: Step 4 completed
|
||||||
**Modifies**:
|
**Modifies**:
|
||||||
- `src/preload/trpc.ts`
|
- `src/preload/trpc.ts`
|
||||||
@@ -740,7 +740,7 @@ currentSender = sender;
|
|||||||
|
|
||||||
## Step 6: Pass `uiContext` Through to the AI
|
## Step 6: Pass `uiContext` Through to the AI
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Step 5 completed
|
**Prerequisites**: Step 5 completed
|
||||||
**Modifies**:
|
**Modifies**:
|
||||||
- `src/main/router/index.ts` (line ~550-556)
|
- `src/main/router/index.ts` (line ~550-556)
|
||||||
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
|
|||||||
|
|
||||||
## Step 8a: Page Interactions — Project Detail
|
## Step 8a: Page Interactions — Project Detail
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
||||||
|
|
||||||
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
|
|||||||
|
|
||||||
## Step 8b: Page Interactions — Tasks Page
|
## Step 8b: Page Interactions — Tasks Page
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/tasks.tsx`
|
**Modifies**: `src/renderer/routes/tasks.tsx`
|
||||||
|
|
||||||
@@ -1055,7 +1055,7 @@ Same pattern as 8a — create refs, add `data-ai-section` attributes, register i
|
|||||||
|
|
||||||
## Step 8c: Page Interactions — Timeline Page
|
## Step 8c: Page Interactions — Timeline Page
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/timeline.tsx`
|
**Modifies**: `src/renderer/routes/timeline.tsx`
|
||||||
|
|
||||||
@@ -1082,7 +1082,7 @@ Register 1 section:
|
|||||||
|
|
||||||
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
||||||
|
|
||||||
|
|||||||
22
knip.json
Normal file
22
knip.json
Normal 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
458
package-lock.json
generated
@@ -17,7 +17,6 @@
|
|||||||
"@langchain/langgraph": "^1.1.5",
|
"@langchain/langgraph": "^1.1.5",
|
||||||
"@langchain/openai": "^1.2.9",
|
"@langchain/openai": "^1.2.9",
|
||||||
"@milkdown/crepe": "^7.18.0",
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
@@ -59,6 +58,7 @@
|
|||||||
"@tanstack/router-vite-plugin": "^1.161.1",
|
"@tanstack/router-vite-plugin": "^1.161.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"knip": "^5.85.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
@@ -4930,6 +4931,306 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -7934,9 +8235,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.0",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13488,6 +13789,16 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
@@ -13758,6 +14069,22 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
@@ -15711,6 +16038,74 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/langsmith": {
|
||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.6.tgz",
|
||||||
@@ -18270,6 +18665,38 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/p-cancelable": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
@@ -21397,6 +21824,19 @@
|
|||||||
"npm": ">= 3.0.0"
|
"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": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.7",
|
"version": "2.8.7",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||||
@@ -23646,6 +24086,16 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "eslint --ext .ts,.tsx ."
|
"lint": "eslint --ext .ts,.tsx .",
|
||||||
|
"knip": "knip"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "rmusso",
|
"author": "rmusso",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@tanstack/router-vite-plugin": "^1.161.1",
|
"@tanstack/router-vite-plugin": "^1.161.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"knip": "^5.85.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.0",
|
||||||
@@ -53,7 +56,6 @@
|
|||||||
"@langchain/langgraph": "^1.1.5",
|
"@langchain/langgraph": "^1.1.5",
|
||||||
"@langchain/openai": "^1.2.9",
|
"@langchain/openai": "^1.2.9",
|
||||||
"@milkdown/crepe": "^7.18.0",
|
"@milkdown/crepe": "^7.18.0",
|
||||||
"@milkdown/kit": "^7.18.0",
|
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
|||||||
@@ -24,18 +24,22 @@ import { searchNotes, type SearchResult } from '../db/vectordb';
|
|||||||
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
|
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
|
||||||
|
|
||||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||||
|
const AI_ACTION_CHANNEL = 'ai:action';
|
||||||
|
|
||||||
|
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
|
||||||
|
let currentSender: Electron.WebContents | undefined;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface OrchestrateInput {
|
interface OrchestrateInput {
|
||||||
message: string;
|
message: string;
|
||||||
context: { type: 'global' | 'project'; projectId?: string };
|
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||||
sender?: Electron.WebContents;
|
sender?: Electron.WebContents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrchestrateResult {
|
interface OrchestrateResult {
|
||||||
response: string;
|
response: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -185,9 +189,11 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
|||||||
priority: input.priority ?? 'medium',
|
priority: input.priority ?? 'medium',
|
||||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||||
projectId,
|
projectId,
|
||||||
|
isAiSuggested: 1,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||||
return `Task added: ${input.title}`;
|
return `Task added: ${input.title}`;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -262,6 +268,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
|
||||||
return jsonStr;
|
return jsonStr;
|
||||||
} catch {
|
} catch {
|
||||||
return '[]';
|
return '[]';
|
||||||
@@ -311,6 +318,7 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
|
||||||
return jsonStr;
|
return jsonStr;
|
||||||
} catch {
|
} catch {
|
||||||
return '[]';
|
return '[]';
|
||||||
@@ -348,9 +356,11 @@ function buildGlobalTools(): StructuredTool[] {
|
|||||||
priority: input.priority ?? 'medium',
|
priority: input.priority ?? 'medium',
|
||||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||||
projectId: input.projectId ?? null,
|
projectId: input.projectId ?? null,
|
||||||
|
isAiSuggested: 1,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||||
return `Task added: ${input.title}`;
|
return `Task added: ${input.title}`;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -435,7 +445,7 @@ function buildKnowledgeTools(): StructuredTool[] {
|
|||||||
// System prompts
|
// System prompts
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function makeProjectAgentPrompt(contextData: string, withTools = true): string {
|
function makeProjectAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You also have access to the following tools — use them proactively when appropriate:
|
You also have access to the following tools — use them proactively when appropriate:
|
||||||
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
||||||
@@ -454,10 +464,10 @@ ${contextData}
|
|||||||
${toolsSection}
|
${toolsSection}
|
||||||
Answer the user's question based on this project context. Be concise and helpful.
|
Answer the user's question based on this project context. Be concise and helpful.
|
||||||
When referencing tasks, notes, or checkpoints, mention them by name.
|
When referencing tasks, notes, or checkpoints, mention them by name.
|
||||||
If you don't have enough information, say so.`;
|
If you don't have enough information, say so.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeGeneralAgentPrompt(contextData: string, withTools = true): string {
|
function makeGeneralAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You also have access to the following tools — use them proactively when appropriate:
|
You also have access to the following tools — use them proactively when appropriate:
|
||||||
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
||||||
@@ -471,10 +481,10 @@ You have access to the following workspace data:
|
|||||||
${contextData}
|
${contextData}
|
||||||
${toolsSection}
|
${toolsSection}
|
||||||
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
||||||
When discussing tasks or projects, reference them by name.`;
|
When discussing tasks or projects, reference them by name.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeKnowledgeAgentPrompt(contextData: string, withTools = true): string {
|
function makeKnowledgeAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||||
const toolsSection = withTools ? `
|
const toolsSection = withTools ? `
|
||||||
You have access to the following tools — use them proactively:
|
You have access to the following tools — use them proactively:
|
||||||
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
||||||
@@ -494,7 +504,7 @@ ${contextData}
|
|||||||
${toolsSection}
|
${toolsSection}
|
||||||
Your primary job is to find and synthesize information from notes across all projects.
|
Your primary job is to find and synthesize information from notes across all projects.
|
||||||
Always use the vector_search_all tool to search for relevant notes before answering.
|
Always use the vector_search_all tool to search for relevant notes before answering.
|
||||||
If no results are found, say so clearly.`;
|
If no results are found, say so clearly.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -505,7 +515,7 @@ const OrchestratorState = Annotation.Root({
|
|||||||
/** The user's original message */
|
/** The user's original message */
|
||||||
userMessage: Annotation<string>(),
|
userMessage: Annotation<string>(),
|
||||||
/** Chat context (global vs project-scoped) */
|
/** Chat context (global vs project-scoped) */
|
||||||
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string }>(),
|
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(),
|
||||||
/** The route chosen by the orchestrator */
|
/** The route chosen by the orchestrator */
|
||||||
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
||||||
/** Messages for the specialist agent */
|
/** Messages for the specialist agent */
|
||||||
@@ -573,7 +583,8 @@ async function projectAgent(state: State): Promise<Partial<State>> {
|
|||||||
// Including text tool descriptions in the system prompt causes the model to output
|
// Including text tool descriptions in the system prompt causes the model to output
|
||||||
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
if (!supportsTools) {
|
if (!supportsTools) {
|
||||||
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
||||||
@@ -661,7 +672,8 @@ async function knowledgeAgent(state: State): Promise<Partial<State>> {
|
|||||||
|
|
||||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||||
|
|
||||||
@@ -739,7 +751,8 @@ async function generalAgent(state: State): Promise<Partial<State>> {
|
|||||||
|
|
||||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||||
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt);
|
const uiContext = state.chatContext.uiContext;
|
||||||
|
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||||
|
|
||||||
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||||
|
|
||||||
@@ -849,12 +862,18 @@ function sendStreamChunk(sender: Electron.WebContents | undefined, token: string
|
|||||||
sender.send(AI_STREAM_CHANNEL, { token, done });
|
sender.send(AI_STREAM_CHANNEL, { token, done });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
|
||||||
|
if (!sender || sender.isDestroyed()) return;
|
||||||
|
sender.send(AI_ACTION_CHANNEL, action);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Orchestrate (public entry point)
|
// Orchestrate (public entry point)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
||||||
const { message, context, sender } = input;
|
const { message, context, sender } = input;
|
||||||
|
currentSender = sender;
|
||||||
|
|
||||||
// Quick check: is an LLM available?
|
// Quick check: is an LLM available?
|
||||||
const llm = await getLLM();
|
const llm = await getLLM();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function getActiveProviderName(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Switch to a different registered provider. */
|
/** Switch to a different registered provider. */
|
||||||
export function setActiveProviderName(name: string): void {
|
function setActiveProviderName(name: string): void {
|
||||||
const provider = providers.get(name);
|
const provider = providers.get(name);
|
||||||
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||||
activeProvider = provider;
|
activeProvider = provider;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function setToken(providerName: string, token: string): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a stored token for the given provider. */
|
/** 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()) {
|
if (useKeytar()) {
|
||||||
try {
|
try {
|
||||||
return await keytar!.deletePassword(SERVICE_NAME, providerName);
|
return await keytar!.deletePassword(SERVICE_NAME, providerName);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type AnyRouter,
|
type AnyRouter,
|
||||||
} from '@trpc/server';
|
} from '@trpc/server';
|
||||||
|
|
||||||
export const IPC_CHANNEL = 'trpc';
|
const IPC_CHANNEL = 'trpc';
|
||||||
|
|
||||||
/** Context passed to every tRPC procedure via the IPC bridge. */
|
/** Context passed to every tRPC procedure via the IPC bridge. */
|
||||||
export type TRPCContext = {
|
export type TRPCContext = {
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ const aiRouter = router({
|
|||||||
context: z.object({
|
context: z.object({
|
||||||
type: z.enum(['global', 'project']),
|
type: z.enum(['global', 'project']),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
|
uiContext: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('electronTRPC', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||||
|
const AI_ACTION_CHANNEL = 'ai:action';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAI', {
|
contextBridge.exposeInMainWorld('electronAI', {
|
||||||
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
||||||
@@ -30,4 +31,13 @@ contextBridge.exposeInMainWorld('electronAI', {
|
|||||||
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
|
||||||
|
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
|
||||||
|
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
|
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { trpc } from '@/lib/trpc';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||||
|
|
||||||
interface ChatMessage {
|
/** Fluid font size for chat messages — scales with viewport width */
|
||||||
id: string;
|
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
error?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUGGESTION_CHIPS = [
|
const SUGGESTION_CHIPS = [
|
||||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||||
@@ -23,21 +20,35 @@ const SUGGESTION_CHIPS = [
|
|||||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||||
] as const;
|
] 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 {
|
interface AIChatPanelProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
contextType: 'global' | 'project';
|
|
||||||
projectId?: string;
|
|
||||||
projectName?: string;
|
|
||||||
curtainOpen: boolean;
|
|
||||||
isHomePage?: boolean;
|
isHomePage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatPanel({
|
export function AIChatPanel({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
contextType,
|
|
||||||
projectId,
|
|
||||||
projectName,
|
|
||||||
curtainOpen,
|
|
||||||
isHomePage,
|
isHomePage,
|
||||||
}: AIChatPanelProps) {
|
}: AIChatPanelProps) {
|
||||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||||
@@ -46,10 +57,18 @@ export function AIChatPanel({
|
|||||||
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
|
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
|
||||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||||
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const chatContext = useMemo<ChatContext>(
|
||||||
const [input, setInput] = useState('');
|
() => ({ type: 'global' as const }),
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
[],
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
);
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
isStreaming,
|
||||||
|
streamingContent,
|
||||||
|
handleSend: chatHandleSend,
|
||||||
|
} = useAIChat(chatContext);
|
||||||
|
|
||||||
// Daily brief state (home page only)
|
// Daily brief state (home page only)
|
||||||
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
||||||
@@ -57,30 +76,50 @@ export function AIChatPanel({
|
|||||||
const briefContentRef = useRef('');
|
const briefContentRef = useRef('');
|
||||||
const hasFiredBrief = useRef(false);
|
const hasFiredBrief = useRef(false);
|
||||||
|
|
||||||
|
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||||
|
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||||
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const streamingContentRef = useRef('');
|
// --- Scroll-to-user-message + shrinking placeholder ---
|
||||||
const chatMutation = trpc.ai.chat.useMutation();
|
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 briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||||
const el = messagesContainerRef.current;
|
|
||||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset input when curtain closes; scroll to bottom when it reopens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!curtainOpen) {
|
if (!pendingScrollRef.current) return;
|
||||||
setInput('');
|
const lastMsg = messages[messages.length - 1];
|
||||||
} else {
|
if (!lastMsg || lastMsg.role !== 'user') return;
|
||||||
setTimeout(scrollToBottom, 50);
|
|
||||||
}
|
|
||||||
}, [curtainOpen, scrollToBottom]);
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
if (!isStreaming || !streamingEl) return;
|
||||||
}, [messages, streamingContent, scrollToBottom]);
|
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
|
// Auto-fire daily brief on home page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,72 +155,10 @@ export function AIChatPanel({
|
|||||||
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
|
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const trimmed = input.trim();
|
if (briefLoading) return;
|
||||||
if (!trimmed || isStreaming || briefLoading) return;
|
pendingScrollRef.current = true;
|
||||||
|
chatHandleSend();
|
||||||
const userMsg: ChatMessage = {
|
}, [briefLoading, chatHandleSend]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
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 hasMessages = messages.length > 0 || isStreaming;
|
||||||
|
|
||||||
const contextLabel =
|
|
||||||
contextType === 'project' && projectName
|
|
||||||
? `Chatting about: ${projectName}`
|
|
||||||
: 'Global workspace';
|
|
||||||
|
|
||||||
// Derived values for home page
|
// Derived values for home page
|
||||||
const dueCount = dueTodayQuery.data?.length ?? 0;
|
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||||
const userName = userNameQuery.data ?? 'there';
|
const userName = userNameQuery.data ?? 'there';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||||
{/* Context header (non-home) */}
|
{/* Sticky brief toast — anchored at top when chatting */}
|
||||||
{!isHomePage && (
|
<AnimatePresence>
|
||||||
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
|
||||||
<Badge variant="outline">{contextLabel}</Badge>
|
<motion.div
|
||||||
</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 */}
|
{/* Scrollable messages area */}
|
||||||
<ScrollArea
|
<div className="relative flex-1 min-h-0">
|
||||||
className="flex-1 min-h-0"
|
{/* Gradual blur at the bottom of messages */}
|
||||||
viewportRef={messagesContainerRef}
|
{hasMessages && (
|
||||||
viewportClassName={
|
<GradualBlur
|
||||||
isHomePage && !hasMessages
|
position="bottom"
|
||||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
strength={0.6}
|
||||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
height="4rem"
|
||||||
}
|
divCount={10}
|
||||||
onWheel={handleWheel}
|
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 */}
|
{/* Home page initial state: greeting + brief */}
|
||||||
{isHomePage && !hasMessages && (
|
{isHomePage && !hasMessages && (
|
||||||
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
|
<motion.div
|
||||||
<div className="flex flex-col gap-8">
|
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||||
{/* Greeting + brief grouped closely */}
|
variants={stagger}
|
||||||
<div className="flex flex-col gap-1">
|
initial="hidden"
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
animate="show"
|
||||||
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
|
>
|
||||||
✦ Hello, {userName}
|
<div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
|
||||||
</h1>
|
{/* Greeting — editorial hero moment */}
|
||||||
<Badge variant="secondary">
|
<motion.div variants={fadeUp} className="flex flex-col gap-1">
|
||||||
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
|
<span
|
||||||
</Badge>
|
className="font-light tracking-wide text-muted-foreground"
|
||||||
</div>
|
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 */}
|
{/* Daily brief */}
|
||||||
<div>
|
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||||
{hasTokenQuery.data === false ? (
|
{hasTokenQuery.data === false ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-2">
|
<div className="flex flex-col items-start gap-3 py-2">
|
||||||
<KeyRound size={24} className="text-muted-foreground" />
|
<KeyRound size={20} className="text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
<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.
|
Configure your AI provider in Settings to enable the daily brief.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||||
@@ -283,67 +307,68 @@ export function AIChatPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : briefLoading && !dailyBrief ? (
|
) : briefLoading && !dailyBrief ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-5 w-3/4" />
|
||||||
<Skeleton className="h-4 w-1/2" />
|
<Skeleton className="h-5 w-1/2" />
|
||||||
<Skeleton className="h-4 w-2/3" />
|
<Skeleton className="h-5 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
) : dailyBrief ? (
|
) : 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.
|
Your daily brief will appear here.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline input + suggestion chips */}
|
{/* Input + suggestion links */}
|
||||||
<div>
|
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
isStreaming={isStreaming || briefLoading}
|
isStreaming={isStreaming || briefLoading}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isHomePage={isHomePage}
|
|
||||||
/>
|
/>
|
||||||
<div 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) => (
|
{SUGGESTION_CHIPS.map((chip) => (
|
||||||
<button
|
<button
|
||||||
key={chip.label}
|
key={chip.label}
|
||||||
type="button"
|
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)}
|
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>
|
<span>{chip.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Home page with messages: brief stays, then messages */}
|
{/* Home page with messages: brief stays, then messages */}
|
||||||
{isHomePage && hasMessages && (
|
{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">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Brief persists */}
|
|
||||||
{dailyBrief && (
|
|
||||||
<div className="mb-2">
|
|
||||||
<ChatMarkdown content={dailyBrief} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat messages */}
|
{/* Chat messages */}
|
||||||
{messages.map((msg) => {
|
{messages.map((msg, idx) => {
|
||||||
|
const isLastMsg = idx === messages.length - 1;
|
||||||
|
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
return (
|
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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -352,7 +377,7 @@ export function AIChatPanel({
|
|||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
<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}
|
{msg.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,10 +388,10 @@ export function AIChatPanel({
|
|||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Sparkles size={16} className="text-foreground" />
|
<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>
|
||||||
<div className="pl-[22px]">
|
<div className="pl-[22px]">
|
||||||
<ChatMarkdown content={msg.content} />
|
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -374,14 +399,14 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
{/* Streaming AI response */}
|
{/* Streaming AI response */}
|
||||||
{isStreaming && (
|
{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">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Sparkles size={16} className="text-foreground" />
|
<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>
|
||||||
{streamingContent ? (
|
{streamingContent ? (
|
||||||
<div className="pl-[22px]">
|
<div className="pl-[22px]">
|
||||||
<ChatMarkdown content={streamingContent} />
|
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 pl-[22px]">
|
<div className="space-y-2 pl-[22px]">
|
||||||
@@ -391,84 +416,36 @@ export function AIChatPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Non-home messages */}
|
{/* Non-home messages */}
|
||||||
{!isHomePage && hasMessages && (
|
</ScrollArea>
|
||||||
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-32">
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{messages.map((msg) => {
|
|
||||||
if (msg.role === 'user') {
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="flex justify-end">
|
|
||||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
|
||||||
<ChatMarkdown content={msg.content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.error) {
|
{/* Fixed input — pinned to the bottom, above the blur */}
|
||||||
return (
|
{hasMessages && (
|
||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||||
<p className="text-sm text-destructive whitespace-pre-wrap">
|
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||||
{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]'}`}>
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
isStreaming={isStreaming || briefLoading}
|
isStreaming={isStreaming || briefLoading}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
isHomePage={isHomePage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +462,6 @@ interface ChatInputProps {
|
|||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
isHomePage?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatInput({
|
function ChatInput({
|
||||||
@@ -503,6 +479,7 @@ function ChatInput({
|
|||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Ask me anything..."
|
placeholder="Ask me anything..."
|
||||||
|
aria-label="Chat message"
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
|
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}
|
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||||
@@ -510,6 +487,7 @@ function ChatInput({
|
|||||||
<button
|
<button
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
disabled={!input.trim() || isStreaming}
|
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"
|
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} />
|
<ArrowUp size={16} />
|
||||||
@@ -521,9 +499,12 @@ function ChatInput({
|
|||||||
|
|
||||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||||
|
|
||||||
function ChatMarkdown({ content }: { content: string }) {
|
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||||
return (
|
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
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
389
src/renderer/components/ai/FloatingChat.tsx
Normal file
389
src/renderer/components/ai/FloatingChat.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useRouterState } from '@tanstack/react-router';
|
import { Link, useRouterState } from '@tanstack/react-router';
|
||||||
import { motion, useMotionValue, useSpring } from 'framer-motion';
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
ChartGantt,
|
ChartGantt,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Check,
|
Check,
|
||||||
@@ -18,6 +16,7 @@ import {
|
|||||||
Palette
|
Palette
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -30,14 +29,13 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -55,7 +53,9 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||||
|
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||||
import { useTheme } from '@/components/theme-provider';
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/', icon: House, label: 'Home' },
|
{ to: '/', icon: House, label: 'Home' },
|
||||||
@@ -68,21 +68,17 @@ interface AppShellProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Walk up the DOM to find the nearest scrollable ancestor. */
|
export function AppShell({ children }: AppShellProps) {
|
||||||
function findScrollableAncestor(el: Element | null): Element | null {
|
return (
|
||||||
if (!el || el === document.body) return null;
|
<FloatingChatProvider>
|
||||||
const style = window.getComputedStyle(el);
|
<AppShellInner>{children}</AppShellInner>
|
||||||
const overflowY = style.overflowY;
|
</FloatingChatProvider>
|
||||||
if (
|
);
|
||||||
(overflowY === 'auto' || overflowY === 'scroll') &&
|
|
||||||
el.scrollHeight > el.clientHeight
|
|
||||||
) {
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
return findScrollableAncestor(el.parentElement);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
function AppShellInner({ children }: AppShellProps) {
|
||||||
|
useDoubleClickAI();
|
||||||
|
|
||||||
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
@@ -119,142 +115,33 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
const isHomePage = currentPath === '/';
|
const isHomePage = currentPath === '/';
|
||||||
|
|
||||||
// Curtain is disabled on home page and on /projects without a selected project
|
|
||||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
|
||||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
|
||||||
const curtainEnabled =
|
|
||||||
currentPath !== '/' &&
|
|
||||||
!(currentPath === '/projects' && !projectId);
|
|
||||||
const curtainEnabledRef = useRef(curtainEnabled);
|
|
||||||
curtainEnabledRef.current = curtainEnabled;
|
|
||||||
|
|
||||||
// Derive AI chat context from current route
|
|
||||||
const isProjectView = currentPath === '/projects' && !!projectId;
|
|
||||||
const contextType = isProjectView ? 'project' as const : 'global' as const;
|
|
||||||
const projectQuery = trpc.projects.get.useQuery(
|
|
||||||
{ id: projectId ?? '' },
|
|
||||||
{ enabled: !!projectId },
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Curtain animation state ---
|
|
||||||
const [curtainOpen, setCurtainOpen] = useState(false);
|
|
||||||
const curtainOpenRef = useRef(false);
|
|
||||||
|
|
||||||
const y = useMotionValue(0);
|
|
||||||
const springY = useSpring(y, { stiffness: 300, damping: 30 });
|
|
||||||
|
|
||||||
const openCurtain = useCallback(() => {
|
|
||||||
curtainOpenRef.current = true;
|
|
||||||
setCurtainOpen(true);
|
|
||||||
y.set(window.innerHeight);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
const closeCurtain = useCallback(() => {
|
|
||||||
curtainOpenRef.current = false;
|
|
||||||
setCurtainOpen(false);
|
|
||||||
y.set(0);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
const toggleCurtain = useCallback(() => {
|
|
||||||
if (curtainOpenRef.current) closeCurtain();
|
|
||||||
else openCurtain();
|
|
||||||
}, [openCurtain, closeCurtain]);
|
|
||||||
|
|
||||||
// Keep curtain position in sync with window height on resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (curtainOpenRef.current) {
|
|
||||||
y.set(window.innerHeight);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, [y]);
|
|
||||||
|
|
||||||
// Keyboard shortcut: Cmd/Ctrl+K
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!curtainEnabledRef.current) return;
|
|
||||||
toggleCurtain();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [toggleCurtain]);
|
|
||||||
|
|
||||||
// Wheel event: overscroll detection
|
|
||||||
useEffect(() => {
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
|
||||||
if (!curtainOpenRef.current) {
|
|
||||||
if (!curtainEnabledRef.current) return;
|
|
||||||
// Opening: overscroll UP (deltaY < 0) when content is at top
|
|
||||||
if (e.deltaY < 0) {
|
|
||||||
const scrollable = findScrollableAncestor(e.target as Element);
|
|
||||||
const atTop = !scrollable || scrollable.scrollTop === 0;
|
|
||||||
if (atTop) openCurtain();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Closing: scroll DOWN (deltaY > 0) while curtain is open
|
|
||||||
if (e.deltaY > 0) {
|
|
||||||
closeCurtain();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('wheel', handleWheel, { passive: true });
|
|
||||||
return () => document.removeEventListener('wheel', handleWheel);
|
|
||||||
}, [openCurtain, closeCurtain]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<LayoutGroup>
|
||||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
setTokenDialogOpen={setTokenDialogOpen}
|
setTokenDialogOpen={setTokenDialogOpen}
|
||||||
onNavClick={closeCurtain}
|
|
||||||
/>
|
/>
|
||||||
<SidebarInset className="overflow-hidden">
|
<SidebarInset>
|
||||||
{/* AI Chat layer: always mounted behind the content panel */}
|
{isHomePage ? (
|
||||||
<AIChatPanel
|
<AIChatPanel
|
||||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||||
contextType={contextType}
|
isHomePage
|
||||||
projectId={projectId}
|
/>
|
||||||
projectName={projectQuery.data?.name}
|
) : (
|
||||||
curtainOpen={isHomePage || curtainOpen}
|
<div className="relative flex flex-col h-full">
|
||||||
isHomePage={isHomePage}
|
<header className="flex items-center gap-2 p-2 md:hidden">
|
||||||
/>
|
<SidebarTrigger />
|
||||||
|
</header>
|
||||||
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
|
|
||||||
{!isHomePage && (
|
|
||||||
<motion.div
|
|
||||||
style={{ y: springY }}
|
|
||||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
{/* Right-edge vertical affordance (non-interactive) */}
|
|
||||||
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
|
||||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
|
||||||
{curtainOpen ? (
|
|
||||||
<ChevronDown size={10} />
|
|
||||||
) : (
|
|
||||||
<ChevronUp size={10} />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="text-[9px] tracking-widest uppercase font-medium"
|
|
||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
|
||||||
>
|
|
||||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
|
{/* Floating AI Chat — portal to document.body */}
|
||||||
|
<FloatingChatPortal />
|
||||||
|
|
||||||
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
||||||
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
||||||
setTokenDialogOpen(open);
|
setTokenDialogOpen(open);
|
||||||
@@ -278,13 +165,13 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Your token is stored securely in the OS keychain.
|
Your token is stored securely in the OS keychain.
|
||||||
{hasTokenQuery.data === true && (
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{saved && (
|
{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} />
|
<Check size={14} />
|
||||||
Saved
|
Saved
|
||||||
</span>
|
</span>
|
||||||
@@ -298,17 +185,16 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</LayoutGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
setTokenDialogOpen: (open: boolean) => void;
|
setTokenDialogOpen: (open: boolean) => void;
|
||||||
onNavClick: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
|
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||||
const { toggleSidebar } = useSidebar();
|
const { toggleSidebar } = useSidebar();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
@@ -361,7 +247,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarP
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
tooltip={label}
|
tooltip={label}
|
||||||
>
|
>
|
||||||
<Link to={to} onClick={onNavClick}>
|
<Link to={to}>
|
||||||
<Icon />
|
<Icon />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
@@ -22,6 +23,7 @@ type KanbanBoardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||||
|
const { state: floatingState } = useFloatingChat();
|
||||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
|
|||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
hideBreadcrumb
|
hideBreadcrumb
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -16,6 +17,7 @@ import { KanbanBoard } from './KanbanBoard';
|
|||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
type ProjectDetailProps = {
|
type ProjectDetailProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -26,6 +28,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// AI section refs
|
||||||
|
const summaryRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tasksRef = useRef<HTMLDivElement>(null);
|
||||||
|
const notesRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||||
|
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
|
||||||
|
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||||
|
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||||
|
return () => {
|
||||||
|
unregisterSection('project-summary');
|
||||||
|
unregisterSection('project-timeline');
|
||||||
|
unregisterSection('project-tasks');
|
||||||
|
unregisterSection('project-notes');
|
||||||
|
};
|
||||||
|
}, [projectId, registerSection, unregisterSection]);
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||||
@@ -146,8 +168,17 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
<div className="p-6 flex flex-col gap-6">
|
||||||
Loading project...
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,7 +192,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 pe-8 flex flex-col gap-6">
|
<div className="p-6 flex flex-col gap-6">
|
||||||
{/* Breadcrumb + Project Name */}
|
{/* Breadcrumb + Project Name */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{breadcrumbPath.length > 0 && (
|
{breadcrumbPath.length > 0 && (
|
||||||
@@ -181,54 +212,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat Cards */}
|
{/* Project Summary Section */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||||
<Item variant="muted">
|
{/* Stat Cards */}
|
||||||
<ItemMedia variant="icon">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<FileText />
|
<Item variant="muted">
|
||||||
</ItemMedia>
|
<ItemMedia variant="icon">
|
||||||
<ItemContent>
|
<FileText />
|
||||||
<ItemTitle>{notesCount}</ItemTitle>
|
</ItemMedia>
|
||||||
<ItemDescription>Notes</ItemDescription>
|
<ItemContent>
|
||||||
</ItemContent>
|
<ItemTitle>{notesCount}</ItemTitle>
|
||||||
</Item>
|
<ItemDescription>Notes</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<CheckCircle2 />
|
<CheckCircle2 />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||||
<ItemDescription>Tasks Complete</ItemDescription>
|
<ItemDescription>Tasks Complete</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<Milestone />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
||||||
|
<ItemDescription>Checkpoints</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Project Summary */}
|
||||||
|
<Item variant="outline">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<Milestone />
|
<Sparkles />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
<ItemTitle>AI Project Summary</ItemTitle>
|
||||||
<ItemDescription>Checkpoints</ItemDescription>
|
<ItemDescription>
|
||||||
|
{project.aiSummary || 'AI summary will appear here'}
|
||||||
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Project Summary */}
|
|
||||||
<Item variant="outline">
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<Sparkles />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>AI Project Summary</ItemTitle>
|
|
||||||
<ItemDescription>
|
|
||||||
{project.aiSummary || 'AI summary will appear here'}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
|
|
||||||
{/* Project Timeline */}
|
{/* Project Timeline */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -306,7 +340,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Kanban */}
|
{/* Tasks Kanban */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -372,7 +406,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Notes</h2>
|
<h2 className="text-lg font-semibold">Notes</h2>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
|||||||
if (editCreatingClient && editNewClientName.trim()) {
|
if (editCreatingClient && editNewClientName.trim()) {
|
||||||
// Create a new client
|
// Create a new client
|
||||||
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
|
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
|
||||||
let parentId = result.id;
|
const parentId = result.id;
|
||||||
|
|
||||||
if (editCreatingSubClient && editNewSubClientName.trim()) {
|
if (editCreatingSubClient && editNewSubClientName.trim()) {
|
||||||
// Also create a sub-client under the new client
|
// Also create a sub-client under the new client
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ export function PriorityBadge({ priority }: { priority: string | null }) {
|
|||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'high':
|
case 'high':
|
||||||
return (
|
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" />
|
<ArrowUp className="h-3 w-3" />
|
||||||
High
|
High
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'medium':
|
case 'medium':
|
||||||
return (
|
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" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
Medium
|
Medium
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'low':
|
case 'low':
|
||||||
return (
|
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" />
|
<ArrowDown className="h-3 w-3" />
|
||||||
Low
|
Low
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +60,7 @@ export function TaskRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onClick,
|
onClick,
|
||||||
hideBreadcrumb,
|
hideBreadcrumb,
|
||||||
|
layoutId,
|
||||||
}: {
|
}: {
|
||||||
task: TaskItem;
|
task: TaskItem;
|
||||||
onToggle: (id: string, status: string | null) => void;
|
onToggle: (id: string, status: string | null) => void;
|
||||||
@@ -64,6 +68,7 @@ export function TaskRow({
|
|||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onClick?: (task: TaskItem) => void;
|
onClick?: (task: TaskItem) => void;
|
||||||
hideBreadcrumb?: boolean;
|
hideBreadcrumb?: boolean;
|
||||||
|
layoutId?: string;
|
||||||
}) {
|
}) {
|
||||||
const isDone = task.status === 'done';
|
const isDone = task.status === 'done';
|
||||||
|
|
||||||
@@ -84,13 +89,21 @@ export function TaskRow({
|
|||||||
breadcrumb.length > 0 ||
|
breadcrumb.length > 0 ||
|
||||||
task.assignee;
|
task.assignee;
|
||||||
|
|
||||||
|
const Wrapper = layoutId ? motion.div : 'div';
|
||||||
|
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<Wrapper
|
||||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
{...wrapperProps}
|
||||||
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
className={cn(
|
||||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
'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)}
|
onClick={() => onClick?.(task)}
|
||||||
>
|
>
|
||||||
{/* Row 1: checkbox + title + description */}
|
{/* Row 1: checkbox + title + description */}
|
||||||
@@ -102,7 +115,7 @@ export function TaskRow({
|
|||||||
className="mt-0.5 shrink-0"
|
className="mt-0.5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-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}
|
{task.title}
|
||||||
</div>
|
</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
@@ -129,10 +142,12 @@ export function TaskRow({
|
|||||||
<Breadcrumb className="shrink-0">
|
<Breadcrumb className="shrink-0">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{breadcrumb.map((part, i) => (
|
{breadcrumb.map((part, i) => (
|
||||||
<BreadcrumbItem key={i}>
|
<Fragment key={i}>
|
||||||
{i > 0 && <BreadcrumbSeparator />}
|
{i > 0 && <BreadcrumbSeparator />}
|
||||||
<span className="text-xs">{part}</span>
|
<BreadcrumbItem>
|
||||||
</BreadcrumbItem>
|
<span className="text-xs">{part}</span>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
@@ -146,7 +161,7 @@ export function TaskRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Wrapper>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
109
src/renderer/components/ui/gradual-blur.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
@@ -8,10 +8,12 @@ function ScrollArea({
|
|||||||
children,
|
children,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
viewportClassName,
|
viewportClassName,
|
||||||
|
scrollbarClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||||
viewportRef?: React.Ref<HTMLDivElement>;
|
viewportRef?: React.Ref<HTMLDivElement>;
|
||||||
viewportClassName?: string;
|
viewportClassName?: string;
|
||||||
|
scrollbarClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
@@ -29,7 +31,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar className={scrollbarClassName} />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
)
|
)
|
||||||
@@ -45,7 +47,7 @@ function ScrollBar({
|
|||||||
data-slot="scroll-area-scrollbar"
|
data-slot="scroll-area-scrollbar"
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex touch-none p-px transition-colors select-none",
|
"flex touch-none p-px transition-colors select-none z-50",
|
||||||
orientation === "vertical" &&
|
orientation === "vertical" &&
|
||||||
"h-full w-2.5 border-l border-l-transparent",
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
orientation === "horizontal" &&
|
orientation === "horizontal" &&
|
||||||
|
|||||||
262
src/renderer/context/FloatingChatContext.tsx
Normal file
262
src/renderer/context/FloatingChatContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
@import '@fontsource/geist/300.css';
|
||||||
@import '@fontsource/geist/400.css';
|
@import '@fontsource/geist/400.css';
|
||||||
@import '@fontsource/geist/500.css';
|
@import '@fontsource/geist/500.css';
|
||||||
@import '@fontsource/geist/600.css';
|
@import '@fontsource/geist/600.css';
|
||||||
|
@import '@fontsource/geist/700.css';
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@@ -50,73 +52,113 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
/* #f4edf3 - Light Pinkish-White Canvas */
|
||||||
--card: oklch(1 0 0);
|
--background: oklch(0.945 0.012 328.5);
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
/* #040404 - Almost Black Text */
|
||||||
--popover: oklch(1 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
--card: oklch(0.945 0.012 328.5);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--popover: oklch(0.945 0.012 328.5);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
/* #fbc881 - Golden Yellow Accent */
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--primary: oklch(0.838 0.117 76.8);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--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: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--border: oklch(0.811 0.014 300.2);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--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-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--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 uses the custom palette */
|
||||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.945 0.012 328.5);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-primary: oklch(0.838 0.117 76.8);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-primary-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-accent: oklch(0.811 0.014 300.2);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--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 {
|
.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);
|
--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);
|
--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);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.92 0.004 286.32);
|
|
||||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
/* #fbfbfb - Primary actions (like the active white circle menu item) */
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--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);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
/* #323232 - Dark gray for muted backgrounds */
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--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);
|
--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);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
/* #323232 - Distinct dark gray borders for the cards/panels */
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--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-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--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-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.985 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.15 0 0);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.335 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.335 0 0);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.765 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -142,6 +184,78 @@ body {
|
|||||||
overflow: hidden;
|
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 */
|
/* Crepe editor layout */
|
||||||
.milkdown-container {
|
.milkdown-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
export interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
@@ -14,7 +14,7 @@ export interface ChatContext {
|
|||||||
uiContext?: string;
|
uiContext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseAIChatReturn {
|
interface UseAIChatReturn {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
input: string;
|
input: string;
|
||||||
setInput: (v: string) => void;
|
setInput: (v: string) => void;
|
||||||
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
|
|||||||
clearMessages: () => void;
|
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 [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
|||||||
|
|
||||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||||
if (done) {
|
if (done) {
|
||||||
const finalContent = streamingContentRef.current;
|
let finalContent = streamingContentRef.current;
|
||||||
|
|
||||||
|
// Parse and strip [SECTION:xxx] tag from AI response
|
||||||
|
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
finalContent = finalContent.slice(sectionMatch[0].length);
|
||||||
|
options?.onSectionTag?.(sectionMatch[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||||
|
|||||||
54
src/renderer/hooks/useDoubleClickAI.ts
Normal file
54
src/renderer/hooks/useDoubleClickAI.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ interface ElectronTRPC {
|
|||||||
|
|
||||||
interface ElectronAI {
|
interface ElectronAI {
|
||||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
|
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
|
||||||
|
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
export const Route = createFileRoute('/notes/$noteId')({
|
export const Route = createFileRoute('/notes/$noteId')({
|
||||||
component: NoteDetailPage,
|
component: NoteDetailPage,
|
||||||
@@ -29,6 +30,21 @@ function NoteDetailPage() {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
||||||
|
|
||||||
|
// AI section — register with right-margin anchor mode
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
const noteProjectId = note?.projectId ?? undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({
|
||||||
|
id: 'note-editor',
|
||||||
|
label: 'Note Editor',
|
||||||
|
ref: editorRef,
|
||||||
|
projectId: noteProjectId,
|
||||||
|
anchorMode: 'right-margin',
|
||||||
|
});
|
||||||
|
return () => unregisterSection('note-editor');
|
||||||
|
}, [noteId, noteProjectId, registerSection, unregisterSection]);
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -139,7 +155,7 @@ function NoteDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 pe-8 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -188,7 +204,7 @@ function NoteDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
<MilkdownEditor
|
<MilkdownEditor
|
||||||
key={noteId}
|
key={noteId}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
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 {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Loader2,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
@@ -40,6 +41,19 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TasksPage() {
|
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 [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
@@ -110,9 +124,9 @@ function TasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<ClipboardCheck />
|
<ClipboardCheck />
|
||||||
@@ -133,7 +147,7 @@ function TasksPage() {
|
|||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<Loader2 />
|
<Clock />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{stats.inProgress}</ItemTitle>
|
<ItemTitle>{stats.inProgress}</ItemTitle>
|
||||||
@@ -151,50 +165,52 @@ function TasksPage() {
|
|||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Order By */}
|
{/* Task List Section */}
|
||||||
<div className="flex items-center gap-3">
|
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||||
<InputGroup className="flex-1">
|
{/* Search + Order By */}
|
||||||
<InputGroupAddon>
|
<div className="flex items-center gap-3">
|
||||||
<Search />
|
<InputGroup className="flex-1">
|
||||||
</InputGroupAddon>
|
<InputGroupAddon>
|
||||||
<InputGroupInput
|
<Search />
|
||||||
placeholder="Search tasks or projects..."
|
</InputGroupAddon>
|
||||||
value={search}
|
<InputGroupInput
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
placeholder="Search tasks or projects..."
|
||||||
/>
|
value={search}
|
||||||
</InputGroup>
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
/>
|
||||||
<SelectTrigger className="w-[180px]">
|
</InputGroup>
|
||||||
<SelectValue placeholder="Order by" />
|
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||||
</SelectTrigger>
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectContent>
|
<SelectValue placeholder="Order by" />
|
||||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={key} value={key}>
|
<SelectContent>
|
||||||
{label}
|
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||||
</SelectItem>
|
<SelectItem key={key} value={key}>
|
||||||
))}
|
{label}
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Filter Tabs + New Task Button */}
|
{/* Status Filter Tabs + New Task Button */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All</TabsTrigger>
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
New Task
|
New Task
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List */}
|
{/* Task List */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{tasksList.length === 0 ? (
|
{tasksList.length === 0 ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
@@ -216,9 +232,15 @@ function TasksPage() {
|
|||||||
onEdit={setEditTask}
|
onEdit={setEditTask}
|
||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, ChartGantt } from 'lucide-react';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||||
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||||
|
|
||||||
export const Route = createFileRoute('/timeline')({
|
export const Route = createFileRoute('/timeline')({
|
||||||
component: TimelinePage,
|
component: TimelinePage,
|
||||||
@@ -15,6 +17,14 @@ function TimelinePage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||||
|
|
||||||
|
// AI section
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef });
|
||||||
|
return () => unregisterSection('timeline-chart');
|
||||||
|
}, [registerSection, unregisterSection]);
|
||||||
|
|
||||||
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
||||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -70,7 +80,7 @@ function TimelinePage() {
|
|||||||
}, [ganttCheckpoints]);
|
}, [ganttCheckpoints]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 pe-8 w-full">
|
<div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||||
@@ -98,9 +108,17 @@ function TimelinePage() {
|
|||||||
|
|
||||||
{/* Gantt Chart */}
|
{/* Gantt Chart */}
|
||||||
{ganttCheckpoints.length === 0 ? (
|
{ganttCheckpoints.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground text-sm py-12 border rounded-md bg-muted/30">
|
<Empty>
|
||||||
No checkpoints yet. Click "+ Add" to create your first milestone.
|
<EmptyHeader>
|
||||||
</div>
|
<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">
|
<div className="border rounded-md p-4 bg-card">
|
||||||
<GanttChart
|
<GanttChart
|
||||||
|
|||||||
Reference in New Issue
Block a user