11 Commits

Author SHA1 Message Date
Roberto Musso
0f3c63c4de feat(settings): add permissions for git commands in settings.json 2026-03-01 23:31:57 +01:00
Roberto Musso
aa089975df docs: split plan into Electron app + separate backend repo
- AI_REFACTOR_PLAN.md: Electron-only, 7 phases, 18 steps
- BACKEND_PLAN.md: standalone FastAPI backend guide for separate repo

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:20:18 +01:00
Roberto Musso
d82738e7ea feat(AppShell): add SidebarTrigger component for improved sidebar accessibility 2026-03-01 14:37:08 +01:00
Roberto Musso
e005872ba0 feat(AIChatPanel): add aria-labels for accessibility; clean up unused lines
feat(AppShell): improve token storage message styling for better visibility
feat(ProjectDetail): implement skeleton loading state for project details
fix(ProjectSidebar): refactor variable declaration for clarity
style(PriorityBadge): enhance priority badge colors for better contrast
refactor(TaskRow): simplify className handling with utility function
fix(TasksPage): replace loader icon with clock icon for in-progress tasks
feat(TimelinePage): enhance empty state with descriptive messaging and icon
2026-03-01 10:40:22 +01:00
Roberto Musso
d3e82a3ebb feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior 2026-03-01 00:21:57 +01:00
Roberto Musso
af8cbc1c96 fix: update default userName from 'Roberto' to 'there' in store and AIChatPanel 2026-02-28 23:53:25 +01:00
Roberto Musso
ee6467a7ac feat: add knip configuration file and integrate knip for linting; update package.json and package-lock.json for new dependencies; refactor various interfaces to remove export modifiers; delete unused hover-card component 2026-02-28 23:44:10 +01:00
Roberto Musso
cdf9a8bf18 feat(FloatingChat): refactor chat width handling to be dynamic; enhance message panel positioning and styling with glass surface effects 2026-02-28 23:30:47 +01:00
Roberto Musso
f767bb5175 feat(AIChatPanel): update GradualBlur component to enhance blur effect; adjust ScrollArea scrollbar class handling 2026-02-28 22:47:09 +01:00
Roberto Musso
444aa37be2 feat(AIChatPanel): enhance daily brief with animation and expand/collapse functionality; add GradualBlur component for improved UI 2026-02-28 16:19:15 +01:00
26 changed files with 1896 additions and 292 deletions

View File

@@ -1,139 +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
# Build & Package
source ~/.nvm/nvm.sh && npm run make # Build distributable packages source ~/.nvm/nvm.sh && npm run make # Build distributable packages
source ~/.nvm/nvm.sh && npm run package # Package without making installers source ~/.nvm/nvm.sh && npm run package # Package without installers
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
# Lint source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
# Database migrations (Drizzle)
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only) source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
``` ```
There is no test suite currently. No test suite currently.
## Architecture Overview ## Architecture
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPCIPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used). 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 ## Design Context
### Users ### Target User
Freelancers and solo professionals managing their own client work projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload. Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
### Brand Personality ### Brand
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified. **Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
### Aesthetic Direction ### Palette
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`) | | Canvas | Primary | Secondary | Borders |
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white |---|---|---|---|---|
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs` | **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl` | **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional ### 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 ### Design Principles
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped. 2. **AI as quiet partner** — 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
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker. 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
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.

8
.claude/settings.json Normal file
View File

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

389
AI_REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,389 @@
# AI Refactor Plan — Adiuva Electron App
> **Objective:** Transform the Electron app from a single-process AI integration into a local-first multi-agent client with plugin-based batch agents, multi-provider LLM support, E2E encrypted backup, granular permissions, and cloud backend integration.
>
> **Backend:** Lives in a separate repository. See `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`)
- `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)
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
- **Files:** `src/shared/api-types.ts`, `tsconfig.json`
- **Outcome:** Type-safe contracts for all backend communication. 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
### Step 6.1 — Update Settings page for multi-provider config
- [ ] Add provider management UI to Settings:
- List of configured providers with status (active/inactive/error)
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
- Set primary and fallback providers
- Test connection button per provider
- [ ] Add auth section to Settings:
- Login/register form
- Current tier display with upgrade CTA
- Logout button
- [ ] Add backup section to Settings:
- Create/view recovery passphrase
- Manual backup trigger
- Backup history with restore points
- Auto-backup schedule toggle
- **Files:** `src/renderer/components/settings/` (new), route file
- **Outcome:** Users can manage AI providers, auth, and backups from Settings.
### Step 6.2 — Add Permission Dialog and Activity Log
- [ ] Create `src/renderer/components/permissions/PermissionDialog.tsx`:
- Modal shown when a plugin requests new permissions
- Lists requested permissions with reasons
- Per-permission approve/deny toggles
- Shows plugin manifest info (name, description, version)
- [ ] Create `src/renderer/components/permissions/ActivityLog.tsx`:
- Filterable table of all plugin activity
- Columns: timestamp, plugin name, action type, resource, status
- Filter by plugin, date range, action type
- Export as CSV
- [ ] Add tRPC procedures for permission management and activity log queries
- **Files:** `src/renderer/components/permissions/PermissionDialog.tsx`, `src/renderer/components/permissions/ActivityLog.tsx`, `src/main/router/index.ts`
- **Outcome:** Transparent permission system with full activity audit trail.
### Step 6.3 — Update AIChatPanel for backend-powered chat
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
- Support WebSocket streaming from backend (when online)
- Fall back to IPC streaming (when offline, using local orchestrator)
- Add connection status indicator (online/offline/reconnecting)
- Support execution plan responses: show plan preview, allow user to approve/modify before execution
- [ ] Update `src/renderer/components/ai/AIChatPanel.tsx`:
- Add connection status badge
- Add tier indicator (shows current plan limitations)
- Plan approval UI: expandable plan steps with approve/reject buttons
- Enhanced error states: differentiate between offline, auth expired, rate limited, server error
- [ ] Update `src/renderer/components/ai/FloatingChat.tsx`:
- Same streaming changes as AIChatPanel
- Compact plan approval for inline context
- **Files:** `src/renderer/hooks/useAIChat.ts`, `src/renderer/components/ai/AIChatPanel.tsx`, `src/renderer/components/ai/FloatingChat.tsx`
- **Outcome:** Chat UI seamlessly handles both online (backend) and offline (local) modes.
---
## 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 (EmailScanner plugin) |
| `onnxruntime-node` | Local embeddings (optional) |
---
## 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).

358
BACKEND_PLAN.md Normal file
View File

@@ -0,0 +1,358 @@
# Backend Plan — Adiuva Cloud API
> **Separate repository.** This document defines the FastAPI backend that the Electron app communicates with.
>
> The backend owns: orchestration logic, chat agent intelligence, prompt IP, auth, billing, and backup blob storage.
> The backend NEVER persists user data. It receives context in requests, uses it for orchestration, and discards it.
---
## Project Structure
```
adiuva-backend/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI entry + CORS + lifespan + router includes
│ ├── core/
│ │ ├── __init__.py
│ │ ├── agent_registry.py # Base classes + singleton registry
│ │ ├── orchestrator.py # LLM-based intent router
│ │ ├── execution_plan.py # Plan builder + cache
│ │ └── plugin_loader.py # Dynamic agent loading
│ ├── agents/
│ │ ├── __init__.py # Auto-registers all agents
│ │ ├── task_agent.py
│ │ ├── calendar_agent.py
│ │ ├── email_agent.py
│ │ └── analytics_agent.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── routes/
│ │ │ ├── __init__.py
│ │ │ ├── chat.py # POST /chat + WS /chat/stream
│ │ │ ├── plans.py # GET /plans/playbook
│ │ │ ├── backup.py # PUT/GET /backup
│ │ │ ├── auth.py # Register/login/refresh
│ │ │ └── billing.py # Checkout/webhook/subscription
│ │ └── middleware/
│ │ ├── __init__.py
│ │ ├── auth.py # JWT validation
│ │ ├── rate_limit.py # Tier-aware rate limiting
│ │ └── sanitizer.py # Strip prompt metadata from responses
│ ├── billing/
│ │ ├── __init__.py
│ │ ├── stripe_service.py # Stripe checkout + webhooks
│ │ └── tier_manager.py # Feature matrix per tier
│ └── config/
│ ├── __init__.py
│ └── settings.py # Pydantic BaseSettings (env-based)
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures: test client, mock agents, mock LLM
│ ├── test_orchestrator.py
│ ├── test_agents.py
│ ├── test_auth.py
│ └── test_backup.py
├── alembic/ # DB migrations (auth/billing tables only)
│ ├── alembic.ini
│ └── versions/
├── requirements.txt
├── Dockerfile
├── docker-compose.yml # App + PostgreSQL + Redis (dev)
├── .env.example
└── README.md
```
---
## Step-by-Step Implementation
### Step 1 — Project scaffolding
- [ ] Initialize repo with the directory structure above
- [ ] Write `requirements.txt`:
```
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
langchain>=0.3.0
langchain-openai>=0.3.0
pydantic>=2.10.0
python-jose[cryptography]>=3.3.0
stripe>=11.0.0
boto3>=1.35.0
slowapi>=0.1.9
sqlalchemy>=2.0.0
asyncpg>=0.30.0
alembic>=1.14.0
bcrypt>=4.2.0
python-dotenv>=1.0.0
httpx>=0.28.0
websockets>=14.0
pytest>=8.0.0
pytest-asyncio>=0.24.0
```
- [ ] Write `app/main.py`: FastAPI app with CORS (allow `app://`, `http://localhost:*`), lifespan (init DB pool, init agent registry), include all routers under `/api/v1`
- [ ] Write `app/config/settings.py`: `Settings(BaseSettings)` with fields: `DATABASE_URL`, `JWT_SECRET`, `JWT_ALGORITHM` (default HS256), `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `S3_BUCKET`, `S3_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `CORS_ORIGINS`, `ENV` (dev/prod)
- [ ] Write `Dockerfile`: Python 3.12 slim, multi-stage (builder + runtime), non-root user
- [ ] Write `docker-compose.yml`: app, postgres:16, optional redis
- [ ] Write `.env.example`
- **Outcome:** Runnable FastAPI skeleton (returns 404 on all routes).
### Step 2 — Pydantic schemas (API contracts)
- [ ] Create `app/schemas.py` (mirrors `src/shared/api-types.ts` from Electron repo):
- `ChatRequest`: `message: str`, `context: ChatContext`, `execution_mode: Literal['direct', 'plan']`
- `ChatContext`: `user_profile: dict`, `relevant_documents: list[str]`, `recent_tasks: list[dict]`, `conversation_history: list[dict]`
- `ChatResponse`: `response: str`, `actions: list[PlanAction]`
- `PlanAction`: `type: Literal['create_record', 'update_record', 'delete_record', 'index_document', 'send_notification']`, `table: str | None`, `data: dict | None`
- `ExecutionPlan`: `agent: str`, `steps: list[PlanStep]`
- `PlanStep`: `action: str`, `prompt_template: str | None`, `variables: dict | None`, `data_from_step: int | None`
- `BackupMetadata`: `version: int`, `timestamp: int`, `checksum: str`, `chunk_count: int`
- `BillingTier`: `Literal['free', 'pro', 'power', 'team']`
- `AuthTokens`: `access_token: str`, `refresh_token: str`, `expires_at: int`
- `UserProfile`: `id: str`, `email: str`, `tier: BillingTier`
- **Outcome:** All request/response models defined and validated.
### Step 3 — Agent Registry + base classes
- [ ] `app/core/agent_registry.py`:
- `BaseAgent(ABC)`:
- `user_id: str`, `shared_memory: dict`, `vector_store_context: list[str]`, `skills: list[str]`
- Abstract `get_name() -> str`, `get_description() -> str`
- `ChatAgent(BaseAgent)`:
- Abstract `async handle(query: str, context: dict) -> str`
- Abstract `get_tools() -> list` (LangChain tool definitions)
- Concrete `_tool_loop(llm, messages, tools, max_iter=5) -> str` — shared tool-calling loop
- `AgentRegistry` (singleton):
- `_agents: dict[str, ChatAgent]`
- `register(agent_class)` — decorator pattern
- `get(name) -> ChatAgent`
- `list_agents() -> list[dict]` — returns `[{name, description}]` for orchestrator prompt
- `async call_agent(name, query, context) -> str` — for inter-agent calls
- [ ] Unit tests: register, get, list, call_agent with mock
- **Outcome:** Pluggable agent framework.
### Step 4 — Orchestrator
- [ ] `app/core/orchestrator.py`:
- `async classify_intent(message, context, registry) -> str`:
- System prompt: "You are an intent classifier. Given the user message and context, decide which agent to route to. Available agents: {registry.list_agents()}. Respond with just the agent name."
- Uses gpt-4o-mini via LangChain for low latency
- Falls back to `task_agent` if no clear match
- `async route_single(agent_name, message, context) -> ChatResponse`:
- Instantiates agent from registry
- Calls `agent.handle(message, context)`
- Returns response + any actions the agent produced
- `async route_pipeline(agent_names, message, context) -> ChatResponse`:
- Executes agents in sequence
- Each agent receives `{...context, previous_results: [...]}`
- Final synthesis via LLM: "Summarize these agent results into a coherent response"
- `async orchestrate(request: ChatRequest) -> ChatResponse | ExecutionPlan`:
- Main entry point
- Classifies intent
- If `execution_mode == 'direct'`: route + return response
- If `execution_mode == 'plan'`: route + return execution plan with template IDs
- `async orchestrate_stream(request: ChatRequest) -> AsyncGenerator[str, None]`:
- Same as orchestrate but yields tokens for WebSocket streaming
- [ ] Integration tests with mocked LLM and mocked agents
- **Outcome:** Intelligent routing with single-agent and pipeline modes.
### Step 5 — Execution Plan generator
- [ ] `app/core/execution_plan.py`:
- `PromptTemplateRegistry`: dict of `template_id -> prompt_text`. Templates are server-side only — client receives IDs.
- `ExecutionPlanBuilder`:
- `add_step(action, params) -> self`
- `add_llm_step(template_id, variables) -> self`
- `add_data_step(action, data_from_step) -> self`
- `build() -> ExecutionPlan` — validates step references
- `PlanCache`:
- In-memory LRU (maxsize=1000)
- `cache_plan(key, plan)`, `get_plan(key)`, `get_all_playbooks() -> list[ExecutionPlan]`
- Playbooks are pre-built plans for common operations (e.g., "create task from email", "generate weekly report")
- **Outcome:** Plans are cacheable as playbooks. Prompt IP never leaves the server.
### Step 6 — Chat Agents
- [ ] `app/agents/task_agent.py` — `@registry.register`:
- Description: "Manages tasks: create, update, list, suggest"
- Tools: `create_task(title, description, priority, due_date)`, `update_task(id, updates)`, `list_tasks(filters)`, `suggest_tasks(notes_context)`
- System prompt: PM-oriented, validates task structure, infers priority from context
- `handle()`: LLM + tool loop via `_tool_loop()`, returns response text + list of actions performed
- [ ] `app/agents/calendar_agent.py` — `@registry.register`:
- Description: "Calendar management: events, conflicts, scheduling"
- Tools: `list_events(date_range)`, `detect_conflicts(events)`, `suggest_reschedule(conflict)`
- Works with event metadata passed in context (never raw calendar data stored)
- [ ] `app/agents/email_agent.py` — `@registry.register`:
- Description: "Email analysis: classify, extract actions, draft responses"
- Tools: `classify_email(metadata)`, `extract_action_items(metadata)`, `draft_response(thread_context)`
- Only processes metadata sent by client — never raw email bodies
- [ ] `app/agents/analytics_agent.py` — `@registry.register`:
- Description: "Workspace analytics: metrics, reports, trends"
- Tools: `calculate_metrics(task_data)`, `generate_report(period, data)`, `trend_analysis(data_points)`
- Crunches numbers from context, returns structured insights
- [ ] `app/agents/__init__.py`: imports all agent modules to trigger `@registry.register` decorators
- [ ] Unit tests per agent with mocked LLM
- **Outcome:** Four specialized agents, all registered and tested.
### Step 7 — API Routes
#### 7a — Chat endpoint
- [ ] `app/api/routes/chat.py`:
- `POST /api/v1/chat`:
- Request: `ChatRequest`
- Calls `orchestrate(request)` or `orchestrate()` + `build_plan()`
- Response: `ChatResponse` or `ExecutionPlan`
- `WebSocket /api/v1/chat/stream`:
- Client sends `ChatRequest` as first JSON frame
- Server yields token strings via `orchestrate_stream()`
- Final frame: JSON `ChatResponse` with `{"done": true, "response": "...", "actions": [...]}`
- Heartbeat ping every 30s to keep connection alive
#### 7b — Plans endpoint
- [ ] `app/api/routes/plans.py`:
- `GET /api/v1/plans/playbook`: Returns all playbooks available for the user's tier
- `GET /api/v1/plans/playbook/{plan_id}`: Returns a specific plan
#### 7c — Backup endpoint
- [ ] `app/api/routes/backup.py`:
- `PUT /api/v1/backup`: Accepts binary blob + metadata headers (`X-Backup-Version`, `X-Backup-Timestamp`, `X-Backup-Checksum`). Stores in S3 keyed by `{user_id}/{timestamp}`. Enforces tier limits:
- Free: 0 (no backup)
- Pro: 5 GB
- Power: 50 GB
- Team: unlimited
- `GET /api/v1/backup`: Returns latest blob for authenticated user. Supports `If-Modified-Since`.
- `GET /api/v1/backup/history`: Returns list of `BackupMetadata` (no blobs).
- `DELETE /api/v1/backup/{backup_id}`: Delete specific backup.
#### 7d — Auth endpoint
- [ ] `app/api/routes/auth.py`:
- `POST /api/v1/auth/register`: `{email, password}` → bcrypt hash → insert user → return `AuthTokens`
- `POST /api/v1/auth/login`: Validate credentials → return `AuthTokens`
- `POST /api/v1/auth/refresh`: Rotate refresh token → return new `AuthTokens`
- `GET /api/v1/auth/me`: Return `UserProfile` for current JWT
#### 7e — Billing endpoint
- [ ] `app/api/routes/billing.py`:
- `POST /api/v1/billing/checkout`: Creates Stripe checkout session → returns URL
- `POST /api/v1/billing/webhook`: Handles Stripe webhooks (subscription lifecycle)
- `GET /api/v1/billing/subscription`: Returns current subscription info
- `DELETE /api/v1/billing/subscription`: Cancels subscription
- **Outcome:** Complete REST + WebSocket API.
### Step 8 — Middleware
#### 8a — Auth middleware
- [ ] `app/api/middleware/auth.py`:
- FastAPI dependency: `get_current_user(token: str = Depends(oauth2_scheme)) -> UserProfile`
- Validates JWT signature, expiry, extracts `user_id` and `tier`
- Raises `401` on invalid/expired token
- Exempt routes: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/billing/webhook`
#### 8b — Rate limiter
- [ ] `app/api/middleware/rate_limit.py`:
- Uses `slowapi` with `Limiter(key_func=get_user_id_from_jwt)`
- Tier-based limits:
- Free: 20 req/min
- Pro: 60 req/min
- Power: 120 req/min
- Team: 200 req/seat/min
- Custom 429 response with `Retry-After` header
#### 8c — Sanitizer
- [ ] `app/api/middleware/sanitizer.py`:
- Response middleware that scans response bodies
- Strips: system prompt fragments, agent internal reasoning, tool schemas, routing metadata
- Pattern-based detection + exact match against known prompt fingerprints
- Logs sanitization events for monitoring
- **Outcome:** Secure, rate-limited API with prompt IP protection.
### Step 9 — Billing & Tier management
- [ ] `app/billing/stripe_service.py`:
- `create_checkout_session(user_id, tier) -> str`
- `handle_webhook(payload, sig_header) -> None`: processes `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`
- `get_subscription(user_id) -> dict | None`
- `cancel_subscription(user_id) -> None`
- [ ] `app/billing/tier_manager.py`:
- `TierManager`:
- Feature matrix:
```python
FEATURES = {
'free': {'agents': 3, 'batch': False, 'providers': 1, 'backup_gb': 0},
'pro': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': 5},
'power': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': 50, 'byok': True},
'team': {'agents': -1, 'batch': True, 'providers': -1, 'backup_gb': -1, 'sso': True},
}
```
- `get_tier(user_id) -> BillingTier`
- `check_feature(user_id, feature) -> bool`
- `get_rate_limit(tier) -> int`
- **Outcome:** Stripe integration with tier-based feature gating.
### Step 10 — Database (auth/billing only)
- [ ] PostgreSQL schema via Alembic:
- `users`: `id UUID PK`, `email UNIQUE`, `password_hash`, `tier` (default 'free'), `stripe_customer_id`, `created_at`, `updated_at`
- `refresh_tokens`: `id UUID PK`, `user_id FK`, `token_hash`, `expires_at`, `created_at`
- `subscriptions`: `id UUID PK`, `user_id FK`, `stripe_subscription_id`, `tier`, `status`, `current_period_end`, `created_at`
- `backup_metadata`: `id UUID PK`, `user_id FK`, `s3_key`, `version`, `timestamp`, `checksum`, `size_bytes`, `created_at`
- [ ] Initial Alembic migration
- [ ] SQLAlchemy models in `app/models.py`
- **Outcome:** Auth and billing persistence. Zero user data stored.
### Step 11 — Testing & deployment
- [ ] `tests/conftest.py`: TestClient fixture, mock LLM fixture (`AsyncMock` returning canned responses), mock agent fixture, test DB (SQLite in-memory for speed)
- [ ] `tests/test_orchestrator.py`: classify_intent routing, single agent, pipeline, plan mode
- [ ] `tests/test_agents.py`: each agent with mocked tools
- [ ] `tests/test_auth.py`: register → login → access protected → refresh → expired token
- [ ] `tests/test_backup.py`: upload → download → history → delete, tier limit enforcement
- [ ] `Dockerfile` optimized for production (gunicorn + uvicorn workers)
- [ ] GitHub Actions CI: lint (ruff), test (pytest), build Docker image
- **Outcome:** Fully tested, deployable backend.
---
## API Contract Summary
| Method | Endpoint | Auth | Request | Response |
|--------|----------|------|---------|----------|
| POST | `/api/v1/auth/register` | No | `{email, password}` | `AuthTokens` |
| POST | `/api/v1/auth/login` | No | `{email, password}` | `AuthTokens` |
| POST | `/api/v1/auth/refresh` | No | `{refresh_token}` | `AuthTokens` |
| GET | `/api/v1/auth/me` | JWT | — | `UserProfile` |
| POST | `/api/v1/chat` | JWT | `ChatRequest` | `ChatResponse \| ExecutionPlan` |
| WS | `/api/v1/chat/stream` | JWT | `ChatRequest` (first frame) | Token stream + final JSON |
| GET | `/api/v1/plans/playbook` | JWT | — | `ExecutionPlan[]` |
| GET | `/api/v1/plans/playbook/:id` | JWT | — | `ExecutionPlan` |
| PUT | `/api/v1/backup` | JWT | Binary blob + headers | `{ok: true}` |
| GET | `/api/v1/backup` | JWT | — | Binary blob |
| GET | `/api/v1/backup/history` | JWT | — | `BackupMetadata[]` |
| DELETE | `/api/v1/backup/:id` | JWT | — | `{ok: true}` |
| POST | `/api/v1/billing/checkout` | JWT | `{tier}` | `{checkout_url}` |
| POST | `/api/v1/billing/webhook` | Stripe sig | Stripe event | `{ok: true}` |
| GET | `/api/v1/billing/subscription` | JWT | — | Subscription info |
| DELETE | `/api/v1/billing/subscription` | JWT | — | `{ok: true}` |
| GET | `/api/v1/health` | No | — | `{status, version}` |
---
## Stack
| Layer | Technology |
|-------|-----------|
| Framework | FastAPI + Uvicorn |
| LLM | LangChain + langchain-openai |
| Auth | PyJWT + bcrypt + OAuth2 |
| Billing | stripe-python |
| Storage | boto3 (S3) |
| Database | PostgreSQL + SQLAlchemy + Alembic |
| Rate limiting | slowapi |
| Testing | pytest + pytest-asyncio + httpx |
| Deployment | Docker → fly.io / Railway / AWS ECS |
---
## Development Rules
1. **NEVER persist user data.** The DB stores only auth, billing, and backup metadata. User context arrives in requests and is discarded after processing.
2. **NEVER expose prompts.** System prompts are composed server-side from fragments. Responses are sanitized before sending.
3. **Stateless request handling.** No server-side session state. All context comes from the client + JWT.
4. **Type hints everywhere.** All functions have full type annotations.
5. **Test every agent.** Each chat agent has unit tests with mocked LLM responses.
6. **Structured logging.** JSON logs with request ID correlation.

22
knip.json Normal file
View File

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

458
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"@langchain/langgraph": "^1.1.5", "@langchain/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",

View File

@@ -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",

View File

@@ -33,13 +33,13 @@ let currentSender: Electron.WebContents | undefined;
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface OrchestrateInput { interface OrchestrateInput {
message: string; message: string;
context: { type: 'global' | 'project'; projectId?: string; uiContext?: 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;
} }

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,17 @@
import { useState, useEffect, useRef, useCallback, useMemo } 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 { useAIChat, type ChatContext } from '@/hooks/useAIChat'; 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';
/** Fluid font size for chat messages — scales with viewport width */
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
const SUGGESTION_CHIPS = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
@@ -16,6 +20,28 @@ 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;
isHomePage?: boolean; isHomePage?: boolean;
@@ -50,19 +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);
// --- Scroll-to-user-message + shrinking placeholder ---
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
const initialPlaceholderRef = useRef(0);
const pendingScrollRef = useRef(false);
const briefMutation = trpc.ai.dailyBrief.useMutation(); const 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 });
}, []);
// Auto-scroll when messages change or streaming content updates
useEffect(() => { useEffect(() => {
scrollToBottom(); if (!pendingScrollRef.current) return;
}, [messages, streamingContent, scrollToBottom]); const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.role !== 'user') return;
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(() => {
if (!isStreaming || !streamingEl) return;
const MIN_PADDING = 80;
const observer = new ResizeObserver(() => {
const contentHeight = streamingEl.getBoundingClientRect().height;
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
});
observer.observe(streamingEl);
return () => observer.disconnect();
}, [isStreaming, streamingEl]);
// Auto-fire daily brief on home page // Auto-fire daily brief on home page
useEffect(() => { useEffect(() => {
@@ -99,6 +156,7 @@ export function AIChatPanel({
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
if (briefLoading) return; if (briefLoading) return;
pendingScrollRef.current = true;
chatHandleSend(); chatHandleSend();
}, [briefLoading, chatHandleSend]); }, [briefLoading, chatHandleSend]);
@@ -109,7 +167,6 @@ export function AIChatPanel({
} }
}; };
const hasMessages = messages.length > 0 || isStreaming; const hasMessages = messages.length > 0 || isStreaming;
// Derived values for home page // Derived values for home page
@@ -118,10 +175,84 @@ export function AIChatPanel({
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">
{/* Sticky brief toast — anchored at top when chatting */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
aria-label={briefExpanded ? 'Collapse brief' : 'Expand brief'}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
aria-label="Dismiss brief"
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Scrollable messages area */} {/* Scrollable messages area */}
<div className="relative flex-1 min-h-0">
{/* Gradual blur at the bottom of messages */}
{hasMessages && (
<GradualBlur
position="bottom"
strength={0.6}
height="4rem"
divCount={10}
curve="ease-out"
opacity={0.8}
zIndex={20}
/>
)}
<ScrollArea <ScrollArea
className="flex-1 min-h-0" className="h-full"
viewportRef={messagesContainerRef} viewportRef={messagesContainerRef}
scrollbarClassName={hasMessages ? 'z-30' : undefined}
viewportClassName={ viewportClassName={
isHomePage && !hasMessages 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-center'
@@ -130,25 +261,45 @@ export function AIChatPanel({
> >
{/* 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)' }}>
{/* Greeting — editorial hero moment */}
<motion.div variants={fadeUp} className="flex flex-col gap-1">
<span
className="font-light tracking-wide text-muted-foreground"
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
<span className="text-primary ml-3 inline-block"></span>
</h1> </h1>
<Badge variant="secondary"> {dueCount > 0 && (
{dueCount} Task{dueCount !== 1 ? 's' : ''} due <p
</Badge> className="text-muted-foreground mt-2"
</div> 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}>
@@ -156,23 +307,22 @@ 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}
@@ -180,42 +330,45 @@ export function AIChatPanel({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onSend={handleSend} onSend={handleSend}
/> />
<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>
</motion.div>
</div> </div>
</div> </motion.div>
</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>
); );
@@ -224,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>
@@ -235,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>
); );
@@ -246,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]">
@@ -263,17 +416,29 @@ 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 */}
</ScrollArea> </ScrollArea>
</div>
{/* Fixed input — pinned to the bottom (hidden on initial state) */} {/* Fixed input — pinned to the bottom, above the blur */}
{hasMessages && ( {hasMessages && (
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-5 pt-16 pointer-events-none"> <div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 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 max-w-3xl"> <div className="relative pointer-events-auto mx-auto max-w-3xl">
<ChatInput <ChatInput
input={input} input={input}
@@ -314,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}
@@ -321,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} />
@@ -332,9 +499,12 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
export 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={{

View File

@@ -6,7 +6,7 @@ import { X, ArrowUp } from 'lucide-react';
import { import {
useFloatingChat, useFloatingChat,
computeDualAnchor, computeDualAnchor,
CHAT_WIDTH, getChatWidth,
CHAT_HEIGHT, CHAT_HEIGHT,
PADDING, PADDING,
} from '@/context/FloatingChatContext'; } from '@/context/FloatingChatContext';
@@ -66,7 +66,7 @@ function FloatingChatInner() {
if (route === 'project' && state.projectId) { if (route === 'project' && state.projectId) {
// Navigate to the project page (stay on same project) // Navigate to the project page (stay on same project)
// Project sections re-register on mount and pendingSection will auto-open // Project sections re-register on mount and pendingSection will auto-open
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } }); void navigate({ to: '/projects', search: { projectId: state.projectId } });
} else if (route.startsWith('/')) { } else if (route.startsWith('/')) {
void navigate({ to: route }); void navigate({ to: route });
} }
@@ -154,7 +154,7 @@ function FloatingChatInner() {
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) { if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`; el.style.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`; el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
} }
} }
@@ -241,6 +241,10 @@ function FloatingChatInner() {
const hasMessages = messages.length > 0 || isStreaming; 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 ( return (
<AnimatePresence> <AnimatePresence>
{state.isOpen && ( {state.isOpen && (
@@ -260,30 +264,37 @@ function FloatingChatInner() {
width: state.position.width, width: state.position.width,
zIndex: 9999, zIndex: 9999,
}} }}
className="flex flex-col gap-2" className="relative"
> >
{/* ---- Messages panel (appears when chat has content) ---- */} {/* ---- Messages panel — floats above or below the input bar ---- */}
<AnimatePresence> <AnimatePresence>
{hasMessages && ( {hasMessages && (
<motion.div <motion.div
key="messages-panel" key="messages-panel"
initial={{ opacity: 0, height: 0, scale: 0.97 }} initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
animate={{ opacity: 1, height: 'auto', scale: 1 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, height: 0, scale: 0.97 }} exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="rounded-2xl" style={{
position: 'absolute',
width: '100%',
...(expandUp
? { bottom: 'calc(100% + 8px)' }
: { top: 'calc(100% + 8px)' }),
}}
className="rounded-2xl overflow-hidden"
> >
<div <div
ref={scrollRef} 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" 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"> <div className="flex flex-col gap-2.5 p-3">
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
return ( return (
<div key={msg.id} className="flex justify-end"> <div key={msg.id} className="flex justify-end">
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-accent text-primary-foreground px-3.5 py-2 shadow-sm"> <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"> <p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -294,7 +305,7 @@ function FloatingChatInner() {
if (msg.error) { if (msg.error) {
return ( return (
<div key={msg.id} className="flex justify-start"> <div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2"> <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"> <p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
{msg.content} {msg.content}
</p> </p>
@@ -305,8 +316,8 @@ function FloatingChatInner() {
return ( return (
<div key={msg.id} className="flex justify-start"> <div key={msg.id} className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={msg.content} /> <ChatMarkdown content={msg.content} />
</div> </div>
</div> </div>
@@ -317,9 +328,9 @@ function FloatingChatInner() {
{/* Streaming */} {/* Streaming */}
{isStreaming && ( {isStreaming && (
<div className="flex justify-start"> <div className="flex justify-start">
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2"> <div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
{streamingContent ? ( {streamingContent ? (
<div className="text-xs"> <div className="text-xs text-foreground">
<ChatMarkdown content={streamingContent} /> <ChatMarkdown content={streamingContent} />
</div> </div>
) : ( ) : (
@@ -338,7 +349,7 @@ function FloatingChatInner() {
</AnimatePresence> </AnimatePresence>
{/* ---- Floating input bar ---- */} {/* ---- Floating input bar ---- */}
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.5)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.7)] focus-within:ring-ring/20"> <div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
{/* Close button */} {/* Close button */}
<button <button
onClick={close} onClick={close}

View File

@@ -29,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,
@@ -131,6 +130,9 @@ function AppShellInner({ children }: AppShellProps) {
/> />
) : ( ) : (
<div className="relative flex flex-col h-full"> <div className="relative flex flex-col h-full">
<header className="flex items-center gap-2 p-2 md:hidden">
<SidebarTrigger />
</header>
{children} {children}
</div> </div>
)} )}
@@ -163,13 +165,13 @@ function AppShellInner({ 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>

View File

@@ -4,6 +4,7 @@ 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,
@@ -167,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>
); );
} }

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { Fragment } from 'react';
import { motion } from 'framer-motion'; 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 {
@@ -95,9 +97,13 @@ export function TaskRow({
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Wrapper <Wrapper
{...wrapperProps} {...wrapperProps}
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${ className={cn(
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border' 'flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors',
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`} 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 */}
@@ -109,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 && (
@@ -136,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 />}
<BreadcrumbItem>
<span className="text-xs">{part}</span> <span className="text-xs">{part}</span>
</BreadcrumbItem> </BreadcrumbItem>
</Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>

View File

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

View File

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

View File

@@ -8,10 +8,12 @@ function ScrollArea({
children, 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" &&

View File

@@ -2,7 +2,6 @@ import {
createContext, createContext,
useContext, useContext,
useCallback, useCallback,
useEffect,
useState, useState,
useRef, useRef,
type ReactNode, type ReactNode,
@@ -11,7 +10,7 @@ import {
// ---------- Types ---------- // ---------- Types ----------
export interface AISection { interface AISection {
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart" id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
label: string; // Human-readable, e.g. "Tasks", "Project Timeline" label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>; ref: RefObject<HTMLElement | null>;
@@ -19,7 +18,7 @@ export interface AISection {
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right' anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
} }
export interface SectionOpenOpts { interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click clickY?: number; // For right-margin mode: Y-coordinate of the double-click
} }
@@ -52,15 +51,20 @@ interface FloatingChatContextValue {
// ---------- Constants ---------- // ---------- Constants ----------
export const CHAT_WIDTH = 380; /** 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 CHAT_HEIGHT = 420;
export const PADDING = 16; export const PADDING = 16;
// ---------- Position computation ---------- // ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } { function clampPosition(x: number, y: number): { x: number; y: number } {
const w = getChatWidth();
return { return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)), x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)), y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
}; };
} }
@@ -70,7 +74,8 @@ function computeAnchorPosition(
opts?: SectionOpenOpts, opts?: SectionOpenOpts,
): { x: number; y: number; width: number } { ): { x: number; y: number; width: number } {
const el = section.ref.current; const el = section.ref.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; const w = getChatWidth();
if (!el) return { x: PADDING, y: PADDING, width: w };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right'; const mode = section.anchorMode ?? 'top-right';
@@ -80,14 +85,14 @@ function computeAnchorPosition(
const rawX = rect.right + PADDING; const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING; const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Default: top-right of section // Default: top-right of section
const rawX = rect.right - CHAT_WIDTH - PADDING; const rawX = rect.right - w - PADDING;
const rawY = rect.top + PADDING; const rawY = rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY); const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
/** /**
@@ -104,6 +109,7 @@ export function computeDualAnchor(
if (section.anchorMode === 'right-margin') return null; if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const w = getChatWidth();
// Fully off-screen — freeze // Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null; if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
@@ -111,27 +117,27 @@ export function computeDualAnchor(
// Primary anchor: top-right (when section top is visible) // Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) { if (rect.top >= PADDING) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.top + PADDING, rect.top + PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Fallback anchor: bottom-right (when section top scrolled off) // Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) { if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING, rect.bottom - CHAT_HEIGHT - PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// Section visible but too small for fallback — clamp to top // Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition( const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING, rect.right - w - PADDING,
PADDING, PADDING,
); );
return { x, y, width: CHAT_WIDTH }; return { x, y, width: w };
} }
// ---------- Context ---------- // ---------- Context ----------
@@ -145,15 +151,6 @@ export function useFloatingChat(): FloatingChatContextValue {
return ctx; return ctx;
} }
// Convenience hook for pages to register a section
export function useAISection(section: AISection): void {
const { registerSection, unregisterSection } = useFloatingChat();
useEffect(() => {
registerSection(section);
return () => unregisterSection(section.id);
}, [section.id, registerSection, unregisterSection]);
}
// ---------- Provider ---------- // ---------- Provider ----------
@@ -163,7 +160,7 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<FloatingChatState>({ const [state, setState] = useState<FloatingChatState>({
isOpen: false, isOpen: false,
activeSectionId: null, activeSectionId: null,
position: { x: 0, y: 0, width: CHAT_WIDTH }, position: { x: 0, y: 0, width: getChatWidth() },
morphTargetId: null, morphTargetId: null,
}); });

View File

@@ -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";
@@ -182,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;

View File

@@ -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,7 @@ export interface UseAIChatReturn {
clearMessages: () => void; clearMessages: () => void;
} }
export interface UseAIChatOptions { interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void; onSectionTag?: (sectionId: string) => void;
} }
@@ -68,7 +68,7 @@ export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOption
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/); const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) { if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length); finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]); options?.onSectionTag?.(sectionMatch[1]!);
} }
setMessages((prev) => [ setMessages((prev) => [

View File

@@ -4,7 +4,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
import { import {
ClipboardCheck, ClipboardCheck,
ListTodo, ListTodo,
Loader2, Clock,
CheckCircle2, CheckCircle2,
Plus, Plus,
Search, Search,
@@ -147,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>

View File

@@ -1,12 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useRef, 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 { 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,
@@ -107,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